git stacks can now set a custom icon
This commit is contained in:
4 files changed
+115
-15
No files matched your search
@@ -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:<dataUrl>' -> 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<string | null | undefined> {
|
||||
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;
|
||||
}
|
||||
@@ -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<string | null>(icon);
|
||||
let showIconPicker = $state(false);
|
||||
$effect(() => { formIcon = icon; });
|
||||
|
||||
// value: '' clear, 'upload:<dataUrl>' custom upload, or a lucide name / 'selfhst:<ref>'.
|
||||
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 @@
|
||||
<Dialog.Header class="px-5 py-3 border-b border-zinc-200 dark:border-zinc-700 flex-shrink-0">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
{#if gitStack}
|
||||
<button
|
||||
type="button"
|
||||
title="Change stack icon"
|
||||
onclick={() => (showIconPicker = true)}
|
||||
class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700 hover:ring-2 hover:ring-primary transition-shadow"
|
||||
>
|
||||
{#if formIcon}
|
||||
<StackIcon icon={formIcon} stackName={gitStack.stackName} envId={effectiveEnvId} class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{:else}
|
||||
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<div class="p-1.5 rounded-md bg-zinc-200 dark:bg-zinc-700">
|
||||
<GitBranch class="w-4 h-4 text-zinc-600 dark:text-zinc-300" />
|
||||
</div>
|
||||
{/if}
|
||||
<div>
|
||||
<Dialog.Title class="text-sm font-semibold text-zinc-800 dark:text-zinc-100">
|
||||
{gitStack ? 'Edit git stack' : 'Deploy from Git'}
|
||||
@@ -1424,3 +1462,5 @@
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<IconPickerModal bind:open={showIconPicker} value={formIcon} onselect={onIconSelect} title="Choose a stack icon" />
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user