feat(#248): overhaul tag edit page — TagParentPicker, new components, merge+subtree actions
Some checks failed
CI / Unit & Component Tests (push) Failing after 2m51s
CI / Backend Unit Tests (push) Failing after 2m46s
CI / Unit & Component Tests (pull_request) Failing after 2m39s
CI / Backend Unit Tests (pull_request) Failing after 2m58s

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-04-16 23:46:06 +02:00
parent f1889ff20c
commit 172c5613ed
4 changed files with 222 additions and 111 deletions

View File

@@ -32,10 +32,14 @@ export const actions: Actions = {
return { success: true };
},
delete: async ({ params, fetch }) => {
merge: async ({ params, request, fetch }) => {
const data = await request.formData();
const targetId = data.get('targetId') as string;
const api = createApiClient(fetch);
const result = await api.DELETE('/api/tags/{id}', {
params: { path: { id: params.id } }
const result = await api.POST('/api/tags/{id}/merge', {
params: { path: { id: params.id } },
body: { targetId }
});
if (!result.response.ok) {
@@ -43,6 +47,28 @@ export const actions: Actions = {
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, `/admin/tags/${result.data!.id}`);
},
delete: async ({ params, request, fetch }) => {
const data = await request.formData();
const deleteMode = (data.get('deleteMode') as string) || 'single';
const api = createApiClient(fetch);
const result =
deleteMode === 'subtree'
? await api.DELETE('/api/tags/{id}/subtree', {
params: { path: { id: params.id } }
})
: await api.DELETE('/api/tags/{id}', {
params: { path: { id: params.id } }
});
if (!result.response.ok) {
const code = (result.error as unknown as { code?: string })?.code;
return fail(result.response.status, { error: getErrorMessage(code) });
}
throw redirect(303, '/admin/tags');
}
};

View File

@@ -3,12 +3,14 @@ import { enhance } from '$app/forms';
import { m } from '$lib/paraglide/messages.js';
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.svelte';
import TagParentPicker from '$lib/components/TagParentPicker.svelte';
import TagAncestry from './TagAncestry.svelte';
import TagChildrenPreview from './TagChildrenPreview.svelte';
import TagMergeZone from './TagMergeZone.svelte';
import TagDeleteGuard from './TagDeleteGuard.svelte';
let { data, form } = $props();
let deleteConfirmName = $state('');
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
const unsaved = createUnsavedWarning();
function getInitialParentId() {
@@ -17,18 +19,25 @@ function getInitialParentId() {
function getInitialColor() {
return data.tag.color ?? '';
}
function getInitialParentName() {
if (!data.tag.parentId) return '';
return data.tags.find((t: { id: string }) => t.id === data.tag.parentId)?.name ?? '';
}
let parentId = $state(getInitialParentId());
let selectedColor = $state(getInitialColor());
let parentName = $state(getInitialParentName());
// SvelteKit reuses the same component instance when navigating between tags client-side.
// $state() only initialises on mount, so we need an effect to reset local form state
// whenever the server switches to a different tag.
// Reset state when navigating between tags client-side
$effect(() => {
void data.tag.id; // declare dependency
void data.tag.id;
parentId = data.tag.parentId ?? '';
selectedColor = data.tag.color ?? '';
deleteConfirmName = '';
parentName = getInitialParentName();
});
$effect(() => {
if (form?.success) unsaved.clearOnSuccess();
});
const colors = [
@@ -43,14 +52,10 @@ const colors = [
'sand',
'coral'
];
$effect(() => {
if (form?.success) unsaved.clearOnSuccess();
});
</script>
<div class="flex flex-1 flex-col overflow-hidden">
<!-- Detail panel header -->
<!-- Header -->
<div class="flex items-center border-b border-line px-5 py-3">
<a
href="/admin/tags"
@@ -75,6 +80,9 @@ $effect(() => {
<!-- Scrollable body -->
<div class="flex-1 overflow-y-auto px-5 py-5">
<!-- TagAncestry breadcrumb -->
<TagAncestry tag={data.tag} allTags={data.tags} />
{#if unsaved.showUnsavedWarning}
<UnsavedWarningBanner onDiscard={unsaved.discard} />
{/if}
@@ -98,6 +106,7 @@ $effect(() => {
oninput={unsaved.markDirty}
class="mb-5"
>
<!-- Name card -->
<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()}
@@ -112,28 +121,20 @@ $effect(() => {
/>
</div>
<!-- Parent selector -->
<!-- Parent selector (TagParentPicker) -->
<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"
<TagParentPicker
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>
excludeIds={[data.tag.id]}
initialName={parentName}
/>
</div>
<!-- Color picker (only shown when no parent selected) -->
<!-- Color picker (only when no parent) -->
{#if parentId === ''}
<div
data-testid="color-picker"
@@ -149,9 +150,7 @@ $effect(() => {
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;
}}
onclick={() => { selectedColor = selectedColor === colorName ? '' : colorName; }}
></button>
{/each}
<button
@@ -168,38 +167,17 @@ $effect(() => {
{/if}
</form>
<!-- Danger zone -->
<div
class="rounded-sm border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30"
>
<h3 class="mb-3 text-xs font-bold tracking-widest text-red-700 uppercase dark:text-red-400">
{m.btn_delete()}
</h3>
<p class="mb-3 text-xs text-red-700 dark:text-red-400">
{m.admin_tag_delete_confirm()}
</p>
<p class="mb-2 text-xs font-bold text-ink-2">
Gib <span class="font-mono">{data.tag.name}</span> zur Bestätigung ein:
</p>
<input
type="text"
bind:value={deleteConfirmName}
placeholder={data.tag.name}
class="mb-3 w-full rounded-sm border border-red-200 bg-white px-3 py-2 text-sm text-ink focus:ring-1 focus:ring-red-400 focus:outline-none"
/>
<form method="POST" action="?/delete" use:enhance>
<button
type="submit"
disabled={!deleteEnabled}
class="rounded-sm bg-red-600 px-4 py-2 font-sans text-xs font-bold tracking-widest text-white uppercase transition-opacity hover:opacity-80 disabled:cursor-not-allowed disabled:opacity-40"
>
{m.btn_delete()}
</button>
</form>
</div>
<!-- Children preview -->
<TagChildrenPreview tag={data.tag} allTags={data.tags} />
<!-- Merge zone -->
<TagMergeZone tag={data.tag} allTags={data.tags} form={form} />
<!-- Delete guard -->
<TagDeleteGuard tag={data.tag} allTags={data.tags} />
</div>
<!-- Docked footer -->
<!-- Footer -->
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
<a
href="/admin/tags"

View File

@@ -3,6 +3,7 @@ import { actions } from './+page.server';
const mockApi = {
PUT: vi.fn(),
POST: vi.fn(),
DELETE: vi.fn()
};
@@ -49,25 +50,121 @@ describe('tags/[id] — update action', () => {
});
});
// ─── merge action ─────────────────────────────────────────────────────────────
describe('tags/[id] — merge action', () => {
it('redirects to target tag on successful merge', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: true },
data: { id: 't2', name: 'Reise' }
});
const formData = new FormData();
formData.set('targetId', 't2');
let redirectUrl: string | null = null;
try {
await actions.merge({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch (e: unknown) {
const r = e as { location?: string };
redirectUrl = r.location ?? null;
}
expect(redirectUrl).toBe('/admin/tags/t2');
});
it('returns fail when merge API responds not ok', async () => {
mockApi.POST.mockResolvedValue({
response: { ok: false, status: 400 },
error: { code: 'TAG_MERGE_SELF' }
});
const formData = new FormData();
formData.set('targetId', 't1');
const result = await actions.merge({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
expect((result as { status: number }).status).toBe(400);
});
});
// ─── delete action ─────────────────────────────────────────────────────────────
describe('tags/[id] — delete action', () => {
it('redirects to /admin/tags on successful delete', async () => {
describe('tags/[id] — delete action (single)', () => {
it('calls DELETE /api/tags/{id} when deleteMode=single', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'single');
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch {
// redirect expected
}
expect(mockApi.DELETE).toHaveBeenCalledWith(
'/api/tags/{id}',
expect.objectContaining({ params: { path: { id: 't1' } } })
);
});
it('redirects to /admin/tags on successful single delete', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'single');
let redirectUrl: string | null = null;
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch (e: unknown) {
const r = e as { location?: string; status?: number };
const r = e as { location?: string };
redirectUrl = r.location ?? null;
}
expect(redirectUrl).toBe('/admin/tags');
});
});
describe('tags/[id] — delete action (subtree)', () => {
it('calls DELETE /api/tags/{id}/subtree when deleteMode=subtree', async () => {
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
const formData = new FormData();
formData.set('deleteMode', 'subtree');
try {
await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);
} catch {
// redirect expected
}
expect(mockApi.DELETE).toHaveBeenCalledWith(
'/api/tags/{id}/subtree',
expect.objectContaining({ params: { path: { id: 't1' } } })
);
});
it('returns fail with error message when delete API responds not ok', async () => {
mockApi.DELETE.mockResolvedValue({
@@ -75,8 +172,12 @@ describe('tags/[id] — delete action', () => {
error: { code: 'FORBIDDEN' }
});
const formData = new FormData();
formData.set('deleteMode', 'single');
const result = await actions.delete({
params: { id: 't1' },
request: { formData: async () => formData },
fetch
} as never);

View File

@@ -8,10 +8,16 @@ vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn(), goto: vi.fn() }));
import { beforeNavigate, goto } from '$app/navigation';
const baseTag = { id: 't1', name: 'Familie' };
const baseTag = { id: 't1', name: 'Familie', documentCount: 0 };
const baseData = {
tag: baseTag,
tags: [] as { id: string; name: string; parentId?: string; color?: string }[]
tags: [] as {
id: string;
name: string;
parentId?: string;
color?: string;
documentCount: number;
}[]
};
afterEach(cleanup);
@@ -36,12 +42,6 @@ describe('Admin edit tag page rendering', () => {
.element(page.getByRole('link', { name: /Abbrechen/i }))
.toHaveAttribute('href', '/admin/tags');
});
it('delete button is disabled until tag name is typed in confirm field', async () => {
render(Page, { data: baseData, form: null });
const deleteBtn = document.querySelector<HTMLButtonElement>('button[type="submit"]');
expect(deleteBtn?.disabled).toBe(true);
});
});
// ─── Unsaved-changes guard ────────────────────────────────────────────────────
@@ -95,42 +95,14 @@ describe('Admin edit tag page unsaved-changes guard', () => {
});
});
// ─── Parent selector ──────────────────────────────────────────────────────────
// ─── Parent selector (TagParentPicker combobox) ───────────────────────────────
describe('Admin edit tag page parent selector', () => {
it('renders a parent selector', async () => {
it('renders a TagParentPicker combobox', 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');
await expect
.element(page.getByRole('combobox', { name: /Übergeordnetes Schlagwort/i }))
.toBeInTheDocument();
});
});
@@ -139,7 +111,7 @@ describe('Admin edit tag page parent selector', () => {
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: [] },
data: { tag: { id: 't1', name: 'Familie', parentId: undefined, documentCount: 0 }, tags: [] },
form: null
});
await expect.element(page.getByTestId('color-picker')).toBeInTheDocument();
@@ -148,8 +120,8 @@ describe('Admin edit tag page color picker', () => {
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' }]
tag: { id: 't1', name: 'Familie', parentId: 't2', documentCount: 0 },
tags: [{ id: 't2', name: 'Reise', documentCount: 0 }]
},
form: null
});
@@ -158,10 +130,44 @@ describe('Admin edit tag page color picker', () => {
it('pre-selects the current tag color in the color picker', async () => {
render(Page, {
data: { tag: { id: 't1', name: 'Familie', color: 'sage' }, tags: [] },
data: { tag: { id: 't1', name: 'Familie', color: 'sage', documentCount: 0 }, tags: [] },
form: null
});
const selected = page.getByTestId('color-swatch-sage');
await expect.element(selected).toHaveAttribute('aria-pressed', 'true');
});
});
// ─── New components present ───────────────────────────────────────────────────
describe('Admin edit tag page new components', () => {
it('renders TagAncestry nav when tag has a parent', async () => {
const { container } = render(Page, {
data: {
tag: { id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 },
tags: [
{ id: 't1', name: 'Eltern', documentCount: 0 },
{ id: 't2', name: 'Kind', parentId: 't1', documentCount: 0 }
]
},
form: null
});
expect(container.querySelector('nav')).toBeTruthy();
});
it('does not render TagAncestry nav for root tag', async () => {
const { container } = render(Page, { data: baseData, form: null });
expect(container.querySelector('nav')).toBeFalsy();
});
it('renders TagMergeZone with merge heading', async () => {
render(Page, { data: baseData, form: null });
await expect.element(page.getByText(/Zusammenführen/i)).toBeInTheDocument();
});
it('renders TagDeleteGuard with two radio options', async () => {
render(Page, { data: baseData, form: null });
const radios = document.querySelectorAll<HTMLInputElement>('input[type="radio"]');
expect(radios.length).toBe(2);
});
});