feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249

Merged
marcel merged 51 commits from feat/issue-221-tag-hierarchy into main 2026-04-17 10:24:10 +02:00
4 changed files with 230 additions and 0 deletions
Showing only changes of commit b6b1b142dc - Show all commits

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { name: string; parentId?: string };
allTags: FlatTag[];
}
let { tag, allTags }: Props = $props();
const ancestors = $derived.by(() => {
const chain: FlatTag[] = [];
let current: FlatTag | undefined = allTags.find((t) => t.id === tag.parentId);
while (current) {
chain.push(current);
const parentId = current.parentId;
current = parentId ? allTags.find((t) => t.id === parentId) : undefined;
}
return chain.reverse();
});
</script>
{#if ancestors.length > 0}
<nav aria-label={m.admin_tag_ancestry_label()}>
<ol class="flex items-center gap-1 text-xs text-ink-3">
{#each ancestors as ancestor (ancestor.id)}
<li>
<a href="/admin/tags/{ancestor.id}" class="hover:text-ink">{ancestor.name}</a>
</li>
<li aria-hidden="true"></li>
{/each}
<li class="text-ink">{tag.name}</li>
</ol>
</nav>
{/if}

View File

@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagAncestry from './TagAncestry.svelte';
afterEach(cleanup);
const allTags = [
{ id: 't1', name: 'Root', documentCount: 0 },
{ id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
{ id: 't3', name: 'Grandchild', parentId: 't2', documentCount: 0 }
];
describe('TagAncestry', () => {
it('renders nothing for a root tag', async () => {
const { container } = render(TagAncestry, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
expect(container.querySelector('nav')).toBeFalsy();
});
it('renders a nav element for a child tag', async () => {
const { container } = render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
expect(container.querySelector('nav')).toBeTruthy();
});
it('shows parent name as a link', async () => {
render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
await expect.element(page.getByRole('link', { name: 'Root' })).toBeInTheDocument();
await expect
.element(page.getByRole('link', { name: 'Root' }))
.toHaveAttribute('href', '/admin/tags/t1');
});
it('shows full ancestor chain for deeply nested tag', async () => {
render(TagAncestry, {
tag: { id: 't3', name: 'Grandchild', parentId: 't2', documentCount: 0 },
allTags
});
await expect.element(page.getByRole('link', { name: 'Root' })).toBeInTheDocument();
await expect.element(page.getByRole('link', { name: 'Child' })).toBeInTheDocument();
});
it('does not render current tag as a link', async () => {
render(TagAncestry, {
tag: { id: 't2', name: 'Child', parentId: 't1', documentCount: 0 },
allTags
});
const links = document.querySelectorAll('nav a');
const linkTexts = Array.from(links).map((a) => a.textContent?.trim());
expect(linkTexts).not.toContain('Child');
});
});

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages.js';
type FlatTag = {
id: string;
name: string;
color?: string;
parentId?: string;
documentCount: number;
};
interface Props {
tag: { id: string };
allTags: FlatTag[];
}
let { tag, allTags }: Props = $props();
let showAll = $state(false);
const children = $derived(allTags.filter((t) => t.parentId === tag.id));
const visibleChildren = $derived(showAll ? children : children.slice(0, 5));
</script>
{#if children.length > 0}
<div
data-testid="children-preview"
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_tag_children_label()}
</h3>
<div class="flex flex-wrap gap-2">
{#each visibleChildren as child (child.id)}
<a
href="/admin/tags/{child.id}"
class="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink hover:bg-accent-bg"
>{child.name}</a
>
{/each}
{#if !showAll && children.length > 5}
<button
type="button"
data-testid="expand-children"
onclick={() => showAll = true}
class="rounded-full border border-line bg-surface px-3 py-1 text-xs text-ink-3 hover:text-ink"
>{m.admin_tag_children_more({ count: children.length - 5 })}</button
>
{/if}
</div>
</div>
{/if}

View File

@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser';
import TagChildrenPreview from './TagChildrenPreview.svelte';
afterEach(cleanup);
const allTags = [
{ id: 't1', name: 'Root', documentCount: 0 },
{ id: 't2', name: 'Alpha', parentId: 't1', documentCount: 0 },
{ id: 't3', name: 'Beta', parentId: 't1', documentCount: 0 },
{ id: 't4', name: 'Gamma', parentId: 't1', documentCount: 0 },
{ id: 't5', name: 'Delta', parentId: 't1', documentCount: 0 },
{ id: 't6', name: 'Epsilon', parentId: 't1', documentCount: 0 },
{ id: 't7', name: 'Zeta', parentId: 't1', documentCount: 0 }
];
describe('TagChildrenPreview', () => {
it('renders nothing for a leaf tag', async () => {
const { container } = render(TagChildrenPreview, {
tag: { id: 't2', name: 'Alpha', parentId: 't1', documentCount: 0 },
allTags
});
expect(container.querySelector('[data-testid="children-preview"]')).toBeFalsy();
});
it('renders section heading for a tag with children', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
await expect.element(page.getByTestId('children-preview')).toBeInTheDocument();
});
it('shows up to 5 children as chips', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// Alpha through Epsilon should be visible (5 chips)
await expect.element(page.getByText('Alpha')).toBeInTheDocument();
await expect.element(page.getByText('Epsilon')).toBeInTheDocument();
});
it('shows expand button when there are more than 5 children', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// There are 6 children — should show "und 1 weitere" expand button
const expandBtn = document.querySelector<HTMLButtonElement>(
'button[data-testid="expand-children"]'
);
expect(expandBtn).toBeTruthy();
});
it('expand button reveals hidden children inline', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags
});
// Zeta is hidden behind the expand button
await expect.element(page.getByText('Zeta')).not.toBeInTheDocument();
await page.getByTestId('expand-children').click();
await expect.element(page.getByText('Zeta')).toBeInTheDocument();
});
it('does not show expand button when children count <= 5', async () => {
render(TagChildrenPreview, {
tag: { id: 't1', name: 'Root', documentCount: 0 },
allTags: allTags.slice(0, 4) // Root + 3 children
});
expect(document.querySelector('[data-testid="expand-children"]')).toBeFalsy();
});
});