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 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) {
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,8 @@ const emptyData = {
|
||||
tags: [],
|
||||
sort: 'DATE' as const,
|
||||
dir: 'desc' as const,
|
||||
tagQ: ''
|
||||
tagQ: '',
|
||||
tagOp: 'AND'
|
||||
},
|
||||
documents: [],
|
||||
total: 0,
|
||||
|
||||
Reference in New Issue
Block a user