From b6b1b142dc2c6dd77d0fbf4c3c5a35a4dc11de3f Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 23:26:02 +0200 Subject: [PATCH] feat(#248): add TagAncestry and TagChildrenPreview components Co-Authored-By: Claude Sonnet 4.6 --- .../routes/admin/tags/[id]/TagAncestry.svelte | 43 +++++++++++ .../tags/[id]/TagAncestry.svelte.spec.ts | 60 +++++++++++++++ .../admin/tags/[id]/TagChildrenPreview.svelte | 52 +++++++++++++ .../[id]/TagChildrenPreview.svelte.spec.ts | 75 +++++++++++++++++++ 4 files changed, 230 insertions(+) create mode 100644 frontend/src/routes/admin/tags/[id]/TagAncestry.svelte create mode 100644 frontend/src/routes/admin/tags/[id]/TagAncestry.svelte.spec.ts create mode 100644 frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte create mode 100644 frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte.spec.ts diff --git a/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte b/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte new file mode 100644 index 00000000..81453b05 --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte @@ -0,0 +1,43 @@ + + +{#if ancestors.length > 0} + +{/if} diff --git a/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte.spec.ts b/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte.spec.ts new file mode 100644 index 00000000..5f3f72b7 --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/TagAncestry.svelte.spec.ts @@ -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'); + }); +}); diff --git a/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte b/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte new file mode 100644 index 00000000..8df5c1bc --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte @@ -0,0 +1,52 @@ + + +{#if children.length > 0} +
+

+ {m.admin_tag_children_label()} +

+
+ {#each visibleChildren as child (child.id)} + {child.name} + {/each} + {#if !showAll && children.length > 5} + + {/if} +
+
+{/if} diff --git a/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte.spec.ts b/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte.spec.ts new file mode 100644 index 00000000..49cb0708 --- /dev/null +++ b/frontend/src/routes/admin/tags/[id]/TagChildrenPreview.svelte.spec.ts @@ -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( + '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(); + }); +});