feat(#248): add TagAncestry and TagChildrenPreview components
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
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