feat(#248): overhaul tag edit page — TagParentPicker, new components, merge+subtree actions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user