From a3660a79e1654e7e86a30e5397c8c0eef2916b2d Mon Sep 17 00:00:00 2001 From: Marcel Date: Thu, 16 Apr 2026 23:16:18 +0200 Subject: [PATCH] feat(#248): add TagParentPicker combobox component with excludeIds filtering Co-Authored-By: Claude Sonnet 4.6 --- .../src/lib/components/TagParentPicker.svelte | 115 ++++++++++++++++ .../components/TagParentPicker.svelte.spec.ts | 127 ++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 frontend/src/lib/components/TagParentPicker.svelte create mode 100644 frontend/src/lib/components/TagParentPicker.svelte.spec.ts diff --git a/frontend/src/lib/components/TagParentPicker.svelte b/frontend/src/lib/components/TagParentPicker.svelte new file mode 100644 index 00000000..9e12bd10 --- /dev/null +++ b/frontend/src/lib/components/TagParentPicker.svelte @@ -0,0 +1,115 @@ + + +
typeahead.close()}> + + +
+ + + {#if value} + + {/if} +
+ + {#if typeahead.isOpen && filteredResults.length > 0} +
+ {#each filteredResults as tag (tag.id)} +
selectTag(tag)} + onkeydown={(e) => e.key === 'Enter' && selectTag(tag)} + role="button" + tabindex="0" + > + {tag.name} + {#if tag.parentId} + {tag.parentId} + {/if} +
+ {/each} +
+ {/if} +
diff --git a/frontend/src/lib/components/TagParentPicker.svelte.spec.ts b/frontend/src/lib/components/TagParentPicker.svelte.spec.ts new file mode 100644 index 00000000..f2c2bb0c --- /dev/null +++ b/frontend/src/lib/components/TagParentPicker.svelte.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; +import TagParentPicker from './TagParentPicker.svelte'; + +function hiddenInput(name: string) { + return document.querySelector(`input[type="hidden"][name="${name}"]`); +} + +function mockFetchWithTags(tags: { id: string; name: string; parentId?: string }[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(tags) + }) + ); +} + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +// ─── Rendering ──────────────────────────────────────────────────────────────── + +describe('TagParentPicker – rendering', () => { + it('renders the text input', async () => { + render(TagParentPicker, { name: 'parentId' }); + await expect.element(page.getByRole('combobox')).toBeInTheDocument(); + }); + + it('renders hidden input with correct name', async () => { + render(TagParentPicker, { name: 'parentId' }); + await vi.advanceTimersByTimeAsync(0); + expect(hiddenInput('parentId')).toBeTruthy(); + }); +}); + +// ─── Search ─────────────────────────────────────────────────────────────────── + +describe('TagParentPicker – search', () => { + it('typing shows dropdown results', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + await expect.element(page.getByText('Haus')).toBeInTheDocument(); + }); + + it('filters excludeIds from results', async () => { + mockFetchWithTags([ + { id: 't1', name: 'Haus' }, + { id: 't2', name: 'Garten' } + ]); + render(TagParentPicker, { name: 'parentId', excludeIds: ['t1'] }); + + const input = page.getByRole('combobox'); + await input.fill('a'); + await vi.advanceTimersByTimeAsync(300); + + await expect.element(page.getByText('Haus')).not.toBeInTheDocument(); + await expect.element(page.getByText('Garten')).toBeInTheDocument(); + }); +}); + +// ─── Selection ──────────────────────────────────────────────────────────────── + +describe('TagParentPicker – selection', () => { + it('selecting an option sets the hidden input value', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + document.querySelector('[role="button"]')!.click(); + await vi.advanceTimersByTimeAsync(0); + + expect(hiddenInput('parentId')?.value).toBe('t1'); + }); + + it('clear button appears when value is set', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + document.querySelector('[role="button"]')!.click(); + await vi.advanceTimersByTimeAsync(0); + + await expect + .element(page.getByRole('button', { name: 'Auswahl entfernen' })) + .toBeInTheDocument(); + }); + + it('clear button resets value', async () => { + mockFetchWithTags([{ id: 't1', name: 'Haus' }]); + render(TagParentPicker, { name: 'parentId' }); + + const input = page.getByRole('combobox'); + await input.fill('H'); + await vi.advanceTimersByTimeAsync(300); + + document.querySelector('[role="button"]')!.click(); + await vi.advanceTimersByTimeAsync(0); + + expect(hiddenInput('parentId')?.value).toBe('t1'); + + const clearBtn = document.querySelector('button[aria-label="Auswahl entfernen"]')!; + clearBtn.click(); + await vi.advanceTimersByTimeAsync(0); + + expect(hiddenInput('parentId')?.value).toBe(''); + }); +});