feat(#221): add parent selector and color picker to admin tag edit form

Tag edit form gains a parent <select> listing all other tags (self
excluded) and a 10-swatch color picker that is only shown when no
parent is selected. Submitting passes parentId and color to the PUT
/api/tags/{id} endpoint via TagUpdateDTO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 16:39:02 +02:00
parent abba85a451
commit d900480920
4 changed files with 162 additions and 4 deletions

View File

@@ -15,9 +15,13 @@ export const actions: Actions = {
const data = await request.formData();
const api = createApiClient(fetch);
const name = data.get('name') as string;
const parentId = (data.get('parentId') as string) || null;
const color = (data.get('color') as string) || null;
const result = await api.PUT('/api/tags/{id}', {
params: { path: { id: params.id } },
body: { name: data.get('name') as string }
body: { name, parentId: parentId ?? undefined, color: color ?? undefined }
});
if (!result.response.ok) {

View File

@@ -11,6 +11,29 @@ const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
const unsaved = createUnsavedWarning();
function getInitialParentId() {
return data.tag.parentId ?? '';
}
function getInitialColor() {
return data.tag.color ?? '';
}
let parentId = $state(getInitialParentId());
let selectedColor = $state(getInitialColor());
const colors = [
'sage',
'sienna',
'amber',
'slate',
'violet',
'rose',
'cobalt',
'moss',
'sand',
'coral'
];
$effect(() => {
if (form?.success) unsaved.clearOnSuccess();
});
@@ -21,6 +44,7 @@ $effect(() => {
<div class="flex items-center border-b border-line px-5 py-3">
<a
href="/admin/tags"
aria-label="Zurück zur Tag-Übersicht"
class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden"
>
<svg
@@ -64,7 +88,7 @@ $effect(() => {
oninput={unsaved.markDirty}
class="mb-5"
>
<div class="rounded-sm border border-line bg-surface p-5 shadow-sm">
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
{m.admin_col_name()}
</h3>
@@ -77,6 +101,61 @@ $effect(() => {
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
/>
</div>
<!-- Parent selector -->
<div class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm">
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
Übergeordnetes Schlagwort
</h3>
<label for="parentId" class="mb-1 block text-xs font-medium text-ink-2">
Übergeordnetes Schlagwort
</label>
<select
id="parentId"
name="parentId"
bind:value={parentId}
class="w-full rounded-sm border border-line bg-surface px-3 py-2 text-sm text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
<option value="">Kein übergeordnetes Schlagwort</option>
{#each data.tags.filter((t) => t.id !== data.tag.id) as tag (tag.id)}
<option value={tag.id}>{tag.name}</option>
{/each}
</select>
</div>
<!-- Color picker (only shown when no parent selected) -->
{#if parentId === ''}
<div
data-testid="color-picker"
class="mb-5 rounded-sm border border-line bg-surface p-5 shadow-sm"
>
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">Farbe</h3>
<div class="mb-3 flex flex-wrap gap-2" role="group" aria-label="Farbe auswählen">
{#each colors as colorName (colorName)}
<button
type="button"
data-testid="color-swatch-{colorName}"
aria-pressed={selectedColor === colorName ? 'true' : 'false'}
aria-label={colorName}
style="background-color: var(--c-tag-{colorName})"
class="h-8 w-8 rounded-full {selectedColor === colorName ? 'ring-2 ring-current ring-offset-2' : ''}"
onclick={() => {
selectedColor = selectedColor === colorName ? '' : colorName;
}}
></button>
{/each}
<button
type="button"
class="flex h-8 w-8 items-center justify-center rounded-full border border-line bg-surface text-sm text-ink-3 hover:text-ink"
onclick={() => { selectedColor = ''; }}
aria-label="Farbe zurücksetzen">×</button
>
</div>
<input type="hidden" name="color" value={selectedColor} />
</div>
{:else}
<input type="hidden" name="color" value="" />
{/if}
</form>
<!-- Danger zone -->

View File

@@ -9,7 +9,10 @@ vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
const baseTag = { id: 't1', name: 'Familie' };
const baseData = { tag: baseTag };
const baseData = {
tag: baseTag,
tags: [] as { id: string; name: string; parentId?: string; color?: string }[]
};
afterEach(cleanup);
@@ -91,3 +94,74 @@ describe('Admin edit tag page unsaved-changes guard', () => {
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2');
});
});
// ─── Parent selector ──────────────────────────────────────────────────────────
describe('Admin edit tag page parent selector', () => {
it('renders a parent selector', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByRole('combobox', { name: /übergeordnet/i })).toBeInTheDocument();
});
it('shows other tags in the parent selector', async () => {
render(Page, {
data: {
tag: { id: 't1', name: 'Familie' },
tags: [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Reise' }
]
},
form: null
});
await expect.element(page.getByRole('option', { name: 'Reise' })).toBeInTheDocument();
});
it('does not show self in the parent selector', async () => {
render(Page, {
data: {
tag: { id: 't1', name: 'Familie' },
tags: [
{ id: 't1', name: 'Familie' },
{ id: 't2', name: 'Reise' }
]
},
form: null
});
const options = document.querySelectorAll<HTMLOptionElement>('select[name="parentId"] option');
const values = Array.from(options).map((o) => o.value);
expect(values).not.toContain('t1');
});
});
// ─── Color picker ─────────────────────────────────────────────────────────────
describe('Admin edit tag page color picker', () => {
it('renders color picker when tag has no parent', async () => {
render(Page, {
data: { tag: { id: 't1', name: 'Familie', parentId: undefined }, tags: [] },
form: null
});
await expect.element(page.getByTestId('color-picker')).toBeInTheDocument();
});
it('hides color picker when tag already has a parent', async () => {
render(Page, {
data: {
tag: { id: 't1', name: 'Familie', parentId: 't2' },
tags: [{ id: 't2', name: 'Reise' }]
},
form: null
});
await expect.element(page.getByTestId('color-picker')).not.toBeInTheDocument();
});
it('pre-selects the current tag color in the color picker', async () => {
render(Page, {
data: { tag: { id: 't1', name: 'Familie', color: 'sage' }, tags: [] },
form: null
});
const selected = page.getByTestId('color-swatch-sage');
await expect.element(selected).toHaveAttribute('aria-pressed', 'true');
});
});

View File

@@ -31,7 +31,8 @@ const emptyData = {
tags: [],
sort: 'DATE' as const,
dir: 'desc' as const,
tagQ: ''
tagQ: '',
tagOp: 'AND'
},
documents: [],
total: 0,