feat(#248): admin tag page complete overhaul — tree panel, merge, subtree delete, new edit components #249
43
frontend/src/routes/admin/tags/[id]/TagAncestry.svelte
Normal file
43
frontend/src/routes/admin/tags/[id]/TagAncestry.svelte
Normal 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}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user