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 };
|
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 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) {
|
if (!result.response.ok) {
|
||||||
@@ -43,6 +47,28 @@ export const actions: Actions = {
|
|||||||
return fail(result.response.status, { error: getErrorMessage(code) });
|
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');
|
throw redirect(303, '/admin/tags');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { enhance } from '$app/forms';
|
|||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
import { createUnsavedWarning } from '$lib/hooks/useUnsavedWarning.svelte';
|
||||||
import UnsavedWarningBanner from '$lib/components/UnsavedWarningBanner.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 { data, form } = $props();
|
||||||
|
|
||||||
let deleteConfirmName = $state('');
|
|
||||||
const deleteEnabled = $derived(deleteConfirmName === data.tag.name);
|
|
||||||
|
|
||||||
const unsaved = createUnsavedWarning();
|
const unsaved = createUnsavedWarning();
|
||||||
|
|
||||||
function getInitialParentId() {
|
function getInitialParentId() {
|
||||||
@@ -17,18 +19,25 @@ function getInitialParentId() {
|
|||||||
function getInitialColor() {
|
function getInitialColor() {
|
||||||
return data.tag.color ?? '';
|
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 parentId = $state(getInitialParentId());
|
||||||
let selectedColor = $state(getInitialColor());
|
let selectedColor = $state(getInitialColor());
|
||||||
|
let parentName = $state(getInitialParentName());
|
||||||
|
|
||||||
// SvelteKit reuses the same component instance when navigating between tags client-side.
|
// Reset state 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.
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
void data.tag.id; // declare dependency
|
void data.tag.id;
|
||||||
parentId = data.tag.parentId ?? '';
|
parentId = data.tag.parentId ?? '';
|
||||||
selectedColor = data.tag.color ?? '';
|
selectedColor = data.tag.color ?? '';
|
||||||
deleteConfirmName = '';
|
parentName = getInitialParentName();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (form?.success) unsaved.clearOnSuccess();
|
||||||
});
|
});
|
||||||
|
|
||||||
const colors = [
|
const colors = [
|
||||||
@@ -43,14 +52,10 @@ const colors = [
|
|||||||
'sand',
|
'sand',
|
||||||
'coral'
|
'coral'
|
||||||
];
|
];
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
if (form?.success) unsaved.clearOnSuccess();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<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">
|
<div class="flex items-center border-b border-line px-5 py-3">
|
||||||
<a
|
<a
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
@@ -75,6 +80,9 @@ $effect(() => {
|
|||||||
|
|
||||||
<!-- Scrollable body -->
|
<!-- Scrollable body -->
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
<div class="flex-1 overflow-y-auto px-5 py-5">
|
||||||
|
<!-- TagAncestry breadcrumb -->
|
||||||
|
<TagAncestry tag={data.tag} allTags={data.tags} />
|
||||||
|
|
||||||
{#if unsaved.showUnsavedWarning}
|
{#if unsaved.showUnsavedWarning}
|
||||||
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
<UnsavedWarningBanner onDiscard={unsaved.discard} />
|
||||||
{/if}
|
{/if}
|
||||||
@@ -98,6 +106,7 @@ $effect(() => {
|
|||||||
oninput={unsaved.markDirty}
|
oninput={unsaved.markDirty}
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
>
|
>
|
||||||
|
<!-- Name card -->
|
||||||
<div class="mb-5 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()}
|
||||||
@@ -112,28 +121,20 @@ $effect(() => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Parent selector -->
|
<!-- Parent selector (TagParentPicker) -->
|
||||||
<div class="mb-5 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">
|
||||||
Übergeordnetes Schlagwort
|
Übergeordnetes Schlagwort
|
||||||
</h3>
|
</h3>
|
||||||
<label for="parentId" class="mb-1 block text-xs font-medium text-ink-2">
|
<TagParentPicker
|
||||||
Übergeordnetes Schlagwort
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="parentId"
|
|
||||||
name="parentId"
|
name="parentId"
|
||||||
bind:value={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"
|
excludeIds={[data.tag.id]}
|
||||||
>
|
initialName={parentName}
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Color picker (only shown when no parent selected) -->
|
<!-- Color picker (only when no parent) -->
|
||||||
{#if parentId === ''}
|
{#if parentId === ''}
|
||||||
<div
|
<div
|
||||||
data-testid="color-picker"
|
data-testid="color-picker"
|
||||||
@@ -149,9 +150,7 @@ $effect(() => {
|
|||||||
aria-label={colorName}
|
aria-label={colorName}
|
||||||
style="background-color: var(--c-tag-{colorName})"
|
style="background-color: var(--c-tag-{colorName})"
|
||||||
class="h-8 w-8 rounded-full {selectedColor === colorName ? 'ring-2 ring-current ring-offset-2' : ''}"
|
class="h-8 w-8 rounded-full {selectedColor === colorName ? 'ring-2 ring-current ring-offset-2' : ''}"
|
||||||
onclick={() => {
|
onclick={() => { selectedColor = selectedColor === colorName ? '' : colorName; }}
|
||||||
selectedColor = selectedColor === colorName ? '' : colorName;
|
|
||||||
}}
|
|
||||||
></button>
|
></button>
|
||||||
{/each}
|
{/each}
|
||||||
<button
|
<button
|
||||||
@@ -168,38 +167,17 @@ $effect(() => {
|
|||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<!-- Danger zone -->
|
<!-- Children preview -->
|
||||||
<div
|
<TagChildrenPreview tag={data.tag} allTags={data.tags} />
|
||||||
class="rounded-sm border border-red-200 bg-red-50 p-5 dark:border-red-900 dark:bg-red-950/30"
|
|
||||||
>
|
<!-- Merge zone -->
|
||||||
<h3 class="mb-3 text-xs font-bold tracking-widest text-red-700 uppercase dark:text-red-400">
|
<TagMergeZone tag={data.tag} allTags={data.tags} form={form} />
|
||||||
{m.btn_delete()}
|
|
||||||
</h3>
|
<!-- Delete guard -->
|
||||||
<p class="mb-3 text-xs text-red-700 dark:text-red-400">
|
<TagDeleteGuard tag={data.tag} allTags={data.tags} />
|
||||||
{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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Docked footer -->
|
<!-- Footer -->
|
||||||
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
|
<div class="flex items-center justify-between border-t border-line bg-surface px-5 py-3">
|
||||||
<a
|
<a
|
||||||
href="/admin/tags"
|
href="/admin/tags"
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { actions } from './+page.server';
|
|||||||
|
|
||||||
const mockApi = {
|
const mockApi = {
|
||||||
PUT: vi.fn(),
|
PUT: vi.fn(),
|
||||||
|
POST: vi.fn(),
|
||||||
DELETE: 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 ─────────────────────────────────────────────────────────────
|
// ─── delete action ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('tags/[id] — delete action', () => {
|
describe('tags/[id] — delete action (single)', () => {
|
||||||
it('redirects to /admin/tags on successful delete', async () => {
|
it('calls DELETE /api/tags/{id} when deleteMode=single', async () => {
|
||||||
mockApi.DELETE.mockResolvedValue({ response: { ok: true } });
|
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;
|
let redirectUrl: string | null = null;
|
||||||
try {
|
try {
|
||||||
await actions.delete({
|
await actions.delete({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
|
request: { formData: async () => formData },
|
||||||
fetch
|
fetch
|
||||||
} as never);
|
} as never);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
const r = e as { location?: string; status?: number };
|
const r = e as { location?: string };
|
||||||
redirectUrl = r.location ?? null;
|
redirectUrl = r.location ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(redirectUrl).toBe('/admin/tags');
|
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 () => {
|
it('returns fail with error message when delete API responds not ok', async () => {
|
||||||
mockApi.DELETE.mockResolvedValue({
|
mockApi.DELETE.mockResolvedValue({
|
||||||
@@ -75,8 +172,12 @@ describe('tags/[id] — delete action', () => {
|
|||||||
error: { code: 'FORBIDDEN' }
|
error: { code: 'FORBIDDEN' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('deleteMode', 'single');
|
||||||
|
|
||||||
const result = await actions.delete({
|
const result = await actions.delete({
|
||||||
params: { id: 't1' },
|
params: { id: 't1' },
|
||||||
|
request: { formData: async () => formData },
|
||||||
fetch
|
fetch
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ 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', documentCount: 0 };
|
||||||
const baseData = {
|
const baseData = {
|
||||||
tag: baseTag,
|
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);
|
afterEach(cleanup);
|
||||||
@@ -36,12 +42,6 @@ describe('Admin edit tag page – rendering', () => {
|
|||||||
.element(page.getByRole('link', { name: /Abbrechen/i }))
|
.element(page.getByRole('link', { name: /Abbrechen/i }))
|
||||||
.toHaveAttribute('href', '/admin/tags');
|
.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 ────────────────────────────────────────────────────
|
// ─── 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', () => {
|
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 });
|
render(Page, { data: baseData, form: null });
|
||||||
await expect.element(page.getByRole('combobox', { name: /übergeordnet/i })).toBeInTheDocument();
|
await expect
|
||||||
});
|
.element(page.getByRole('combobox', { name: /Übergeordnetes Schlagwort/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');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -139,7 +111,7 @@ describe('Admin edit tag page – parent selector', () => {
|
|||||||
describe('Admin edit tag page – color picker', () => {
|
describe('Admin edit tag page – color picker', () => {
|
||||||
it('renders color picker when tag has no parent', async () => {
|
it('renders color picker when tag has no parent', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { tag: { id: 't1', name: 'Familie', parentId: undefined }, tags: [] },
|
data: { tag: { id: 't1', name: 'Familie', parentId: undefined, documentCount: 0 }, tags: [] },
|
||||||
form: null
|
form: null
|
||||||
});
|
});
|
||||||
await expect.element(page.getByTestId('color-picker')).toBeInTheDocument();
|
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 () => {
|
it('hides color picker when tag already has a parent', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: {
|
data: {
|
||||||
tag: { id: 't1', name: 'Familie', parentId: 't2' },
|
tag: { id: 't1', name: 'Familie', parentId: 't2', documentCount: 0 },
|
||||||
tags: [{ id: 't2', name: 'Reise' }]
|
tags: [{ id: 't2', name: 'Reise', documentCount: 0 }]
|
||||||
},
|
},
|
||||||
form: null
|
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 () => {
|
it('pre-selects the current tag color in the color picker', async () => {
|
||||||
render(Page, {
|
render(Page, {
|
||||||
data: { tag: { id: 't1', name: 'Familie', color: 'sage' }, tags: [] },
|
data: { tag: { id: 't1', name: 'Familie', color: 'sage', documentCount: 0 }, tags: [] },
|
||||||
form: null
|
form: null
|
||||||
});
|
});
|
||||||
const selected = page.getByTestId('color-swatch-sage');
|
const selected = page.getByTestId('color-swatch-sage');
|
||||||
await expect.element(selected).toHaveAttribute('aria-pressed', 'true');
|
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