From fdca2087956903b1bcda6f0727a8bbf1cad22dec Mon Sep 17 00:00:00 2001 From: Jarek Krochmalski Date: Thu, 27 Aug 2026 07:34:37 +0200 Subject: [PATCH] git stacks can now set a custom icon --- src/lib/utils/stack-icon.ts | 23 ++++++++++++ src/routes/stacks/GitStackModal.svelte | 48 +++++++++++++++++++++++--- src/routes/stacks/StackModal.svelte | 14 ++------ tests/stack-icon.test.ts | 45 ++++++++++++++++++++++++ 4 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 src/lib/utils/stack-icon.ts create mode 100644 tests/stack-icon.test.ts diff --git a/src/lib/utils/stack-icon.ts b/src/lib/utils/stack-icon.ts new file mode 100644 index 0000000..a949599 --- /dev/null +++ b/src/lib/utils/stack-icon.ts @@ -0,0 +1,23 @@ +/** + * Persist a stack icon to the name-based /api/stacks/[name]/icon endpoint. Shared by + * StackModal and GitStackModal so the DELETE/upload/set contract lives in one place. The + * caller builds `target` (it owns env resolution and create-vs-edit gating); `value` is + * the picker output: '' -> clear (DELETE), 'upload:' -> custom upload, else a + * lucide/selfhst ref. Returns the new icon on success, null after a clear, and undefined + * when a POST failed (so the caller leaves the current icon untouched). + */ +export async function persistStackIcon(target: string, value: string): Promise { + if (!value) { + await fetch(target, { method: 'DELETE' }); + return null; + } + const body = value.startsWith('upload:') + ? { image: value.slice('upload:'.length) } + : { icon: value }; + const res = await fetch(target, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + return res.ok ? (await res.json()).icon : undefined; +} diff --git a/src/routes/stacks/GitStackModal.svelte b/src/routes/stacks/GitStackModal.svelte index 13afc7f..44734dc 100644 --- a/src/routes/stacks/GitStackModal.svelte +++ b/src/routes/stacks/GitStackModal.svelte @@ -17,6 +17,10 @@ import StackEnvVarsPanel from '$lib/components/StackEnvVarsPanel.svelte'; import SecretProviderPicker from '$lib/components/SecretProviderPicker.svelte'; import BranchCombobox from './BranchCombobox.svelte'; + import IconPickerModal from './IconPickerModal.svelte'; + import StackIcon from '$lib/components/StackIcon.svelte'; + import { appendEnvParam } from '$lib/stores/environment'; + import { persistStackIcon } from '$lib/utils/stack-icon'; import { type EnvVar, type ValidationResult } from '$lib/components/StackEnvVarsEditor.svelte'; import { toast } from 'svelte-sonner'; import { focusFirstInput } from '$lib/utils'; @@ -72,13 +76,32 @@ open: boolean; gitStack?: GitStack | null; environmentId?: number | null; + icon?: string | null; repositories: GitRepository[]; credentials: GitCredential[]; onClose: () => void; onSaved: () => void; } - let { open = $bindable(), gitStack = null, environmentId = null, repositories, credentials, onClose, onSaved }: Props = $props(); + let { open = $bindable(), gitStack = null, environmentId = null, icon = null, repositories, credentials, onClose, onSaved }: Props = $props(); + + // Per-stack icon override (same name-based /icon endpoint as internal stacks, #1473). + let formIcon = $state(icon); + let showIconPicker = $state(false); + $effect(() => { formIcon = icon; }); + + // value: '' clear, 'upload:' custom upload, or a lucide name / 'selfhst:'. + async function onIconSelect(value: string) { + if (!gitStack?.stackName) return; + const target = appendEnvParam(`/api/stacks/${encodeURIComponent(gitStack.stackName)}/icon`, effectiveEnvId); + try { + const next = await persistStackIcon(target, value); + if (next !== undefined) formIcon = next; // undefined = POST failed, keep current + onSaved(); + } catch (e) { + console.error('Failed to set stack icon:', e); + } + } // Form state - repository selection or creation let formRepoMode = $state<'existing' | 'new'>('existing'); @@ -746,9 +769,24 @@
-
- -
+ {#if gitStack} + + {:else} +
+ +
+ {/if}
{gitStack ? 'Edit git stack' : 'Deploy from Git'} @@ -1424,3 +1462,5 @@
+ + diff --git a/src/routes/stacks/StackModal.svelte b/src/routes/stacks/StackModal.svelte index 47f4159..85ba2e3 100644 --- a/src/routes/stacks/StackModal.svelte +++ b/src/routes/stacks/StackModal.svelte @@ -24,6 +24,7 @@ import * as Tooltip from '$lib/components/ui/tooltip'; import { Badge } from '$lib/components/ui/badge'; import { currentEnvironment, appendEnvParam } from '$lib/stores/environment'; + import { persistStackIcon } from '$lib/utils/stack-icon'; import { appSettings } from '$lib/stores/settings'; import { page } from '$app/stores'; // BETA GATE: backups feature flag import { focusFirstInput } from '$lib/utils'; @@ -85,17 +86,8 @@ const envId = $currentEnvironment?.id ?? null; const target = appendEnvParam(`/api/stacks/${encodeURIComponent(stackName)}/icon`, envId); try { - if (!value) { - await fetch(target, { method: 'DELETE' }); - formIcon = null; - } else if (value.startsWith('upload:')) { - const image = value.slice('upload:'.length); - const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image }) }); - if (res.ok) formIcon = (await res.json()).icon; - } else { - const res = await fetch(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ icon: value }) }); - if (res.ok) formIcon = (await res.json()).icon; - } + const next = await persistStackIcon(target, value); + if (next !== undefined) formIcon = next; // undefined = POST failed, keep current onSuccess?.(); } catch (e) { console.error('Failed to set stack icon:', e); diff --git a/tests/stack-icon.test.ts b/tests/stack-icon.test.ts new file mode 100644 index 0000000..68a8332 --- /dev/null +++ b/tests/stack-icon.test.ts @@ -0,0 +1,45 @@ +/** + * persistStackIcon: the shared DELETE/upload/set contract for the name-based stack icon + * endpoint (used by StackModal and GitStackModal, #1473). null after a clear, the new + * icon on success, undefined when a POST failed (so callers keep the current icon). + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { persistStackIcon } from '../src/lib/utils/stack-icon'; + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; }); + +function stubFetch(impl: (url: string, init?: any) => { ok: boolean; json?: () => any }) { + globalThis.fetch = (async (url: any, init?: any) => { + const r = impl(String(url), init); + return { ok: r.ok, json: async () => (r.json ? r.json() : {}) } as any; + }) as any; +} + +describe('persistStackIcon', () => { + it('clears with DELETE and returns null for an empty value', async () => { + let method = ''; + stubFetch((_u, init) => { method = init?.method; return { ok: true }; }); + expect(await persistStackIcon('/api/stacks/x/icon', '')).toBeNull(); + expect(method).toBe('DELETE'); + }); + + it('POSTs {image} for an upload: value and returns the stored icon', async () => { + let body: any = null; + stubFetch((_u, init) => { body = JSON.parse(init.body); return { ok: true, json: () => ({ icon: 'custom:stack' }) }; }); + expect(await persistStackIcon('/api/stacks/x/icon', 'upload:data:image/png;base64,AAAA')).toBe('custom:stack'); + expect(body.image).toBe('data:image/png;base64,AAAA'); + }); + + it('POSTs {icon} for a lucide/selfhst value and returns it', async () => { + let body: any = null; + stubFetch((_u, init) => { body = JSON.parse(init.body); return { ok: true, json: () => ({ icon: 'database' }) }; }); + expect(await persistStackIcon('/api/stacks/x/icon', 'database')).toBe('database'); + expect(body.icon).toBe('database'); + }); + + it('returns undefined when the POST fails (caller keeps the current icon)', async () => { + stubFetch(() => ({ ok: false })); + expect(await persistStackIcon('/api/stacks/x/icon', 'database')).toBeUndefined(); + }); +});