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:
@@ -15,9 +15,13 @@ export const actions: Actions = {
|
|||||||
const data = await request.formData();
|
const data = await request.formData();
|
||||||
const api = createApiClient(fetch);
|
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}', {
|
const result = await api.PUT('/api/tags/{id}', {
|
||||||
params: { path: { id: params.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) {
|
if (!result.response.ok) {
|
||||||
|
|||||||
@@ -11,6 +11,29 @@ const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
|||||||
|
|
||||||
const unsaved = createUnsavedWarning();
|
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(() => {
|
$effect(() => {
|
||||||
if (form?.success) unsaved.clearOnSuccess();
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
});
|
});
|
||||||
@@ -21,6 +44,7 @@ $effect(() => {
|
|||||||
<div class="flex items-center border-b border-line px-5 py-3">
|
<div class="flex items-center border-b border-line px-5 py-3">
|
||||||
<a
|
<a
|
||||||
href="/admin/tags"
|
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"
|
class="mr-3 inline-flex items-center gap-1 text-xs text-ink-3 hover:text-ink md:hidden"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -64,7 +88,7 @@ $effect(() => {
|
|||||||
oninput={unsaved.markDirty}
|
oninput={unsaved.markDirty}
|
||||||
class="mb-5"
|
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">
|
<h3 class="mb-3 text-xs font-bold tracking-widest text-ink-3 uppercase">
|
||||||
{m.admin_col_name()}
|
{m.admin_col_name()}
|
||||||
</h3>
|
</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"
|
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>
|
</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>
|
</form>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Danger zone -->
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
|
|||||||
import { beforeNavigate, goto } from '$app/navigation';
|
import { beforeNavigate, goto } from '$app/navigation';
|
||||||
|
|
||||||
const baseTag = { id: 't1', name: 'Familie' };
|
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);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -91,3 +94,74 @@ describe('Admin edit tag page – unsaved-changes guard', () => {
|
|||||||
expect(vi.mocked(goto)).toHaveBeenCalledWith('http://localhost/admin/tags/t2');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ const emptyData = {
|
|||||||
tags: [],
|
tags: [],
|
||||||
sort: 'DATE' as const,
|
sort: 'DATE' as const,
|
||||||
dir: 'desc' as const,
|
dir: 'desc' as const,
|
||||||
tagQ: ''
|
tagQ: '',
|
||||||
|
tagOp: 'AND'
|
||||||
},
|
},
|
||||||
documents: [],
|
documents: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
|
|||||||
Reference in New Issue
Block a user