diff --git a/frontend/src/routes/admin/tags/[id]/+page.server.ts b/frontend/src/routes/admin/tags/[id]/+page.server.ts index 45085f86..3288b743 100644 --- a/frontend/src/routes/admin/tags/[id]/+page.server.ts +++ b/frontend/src/routes/admin/tags/[id]/+page.server.ts @@ -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'); } }; diff --git a/frontend/src/routes/admin/tags/[id]/+page.svelte b/frontend/src/routes/admin/tags/[id]/+page.svelte index ad14951c..1e0e5174 100644 --- a/frontend/src/routes/admin/tags/[id]/+page.svelte +++ b/frontend/src/routes/admin/tags/[id]/+page.svelte @@ -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(); -});
- +
{
+ + + {#if unsaved.showUnsavedWarning} {/if} @@ -98,6 +106,7 @@ $effect(() => { oninput={unsaved.markDirty} class="mb-5" > +

{m.admin_col_name()} @@ -112,28 +121,20 @@ $effect(() => { />

- +

Übergeordnetes Schlagwort

- - + excludeIds={[data.tag.id]} + initialName={parentName} + />
- + {#if parentId === ''}
{ 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; }} > {/each} - -
+ + + + + + + +
- +
{ }); }); +// ─── 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); diff --git a/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts index e6909200..f2ffa118 100644 --- a/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts +++ b/frontend/src/routes/admin/tags/[id]/page.svelte.spec.ts @@ -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('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('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('input[type="radio"]'); + expect(radios.length).toBe(2); + }); +});