diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java index 042b7943..9afc5e63 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/TagService.java @@ -39,7 +39,9 @@ public class TagService { private final TagRepository tagRepository; public List search(String query) { - return tagRepository.findByNameContainingIgnoreCase(query); + List matched = tagRepository.findByNameContainingIgnoreCase(query); + if (matched.isEmpty()) return matched; + return enrichWithRelatives(matched); } public Tag getById(UUID id) { @@ -161,6 +163,29 @@ public class TagService { // ─── private helpers ───────────────────────────────────────────────────── + // Each matched tag issues 1 CTE query (findDescendantIds or findAncestorIds) + 1 batch + // fetch for extras. Typical queries match 1–3 tags at depth ≤ 4, so 3–5 queries total. + private List enrichWithRelatives(List matched) { + Set matchedIds = matched.stream().map(Tag::getId).collect(Collectors.toSet()); + Set extraIds = new HashSet<>(); + + for (Tag tag : matched) { + if (tag.getParentId() == null) { + extraIds.addAll(tagRepository.findDescendantIds(tag.getId())); + } else { + extraIds.addAll(tagRepository.findAncestorIds(tag.getId())); + } + } + extraIds.removeAll(matchedIds); + + List result = new ArrayList<>(matched); + if (!extraIds.isEmpty()) { + result.addAll(tagRepository.findAllById(extraIds)); + } + resolveEffectiveColors(result); + return result; + } + private void validateNotSelf(UUID sourceId, UUID targetId) { if (sourceId.equals(targetId)) { throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF, diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java index f4c564c8..bc56d98d 100644 --- a/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/TagServiceTest.java @@ -496,4 +496,71 @@ class TagServiceTest { .extracting(e -> ((DomainException) e).getCode()) .isEqualTo(ErrorCode.TAG_NOT_FOUND); } + + // ─── search ─────────────────────────────────────────────────────────────── + + @Test + void search_includes_children_when_root_matches() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + + List result = tagService.search("Brief"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + } + + @Test + void search_includes_ancestors_when_child_matches() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Hochzeit").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Hochzeit")).thenReturn(List.of(child)); + when(tagRepository.findAncestorIds(childId)).thenReturn(List.of(rootId)); + when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root)); + + List result = tagService.search("Hochzeit"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + } + + @Test + void search_deduplicates_when_root_also_in_descendant_result() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + // findDescendantIds includes the seed (rootId) — dedup must remove it from extras + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + + List result = tagService.search("Brief"); + + assertThat(result).extracting(Tag::getId).containsExactlyInAnyOrder(rootId, childId); + assertThat(result).hasSize(2); + } + + @Test + void search_callsResolveEffectiveColors_onAllResults() { + UUID rootId = UUID.randomUUID(); + UUID childId = UUID.randomUUID(); + Tag root = Tag.builder().id(rootId).name("Briefe").color("sage").build(); + Tag child = Tag.builder().id(childId).name("Familienbriefe").parentId(rootId).build(); + when(tagRepository.findByNameContainingIgnoreCase("Brief")).thenReturn(List.of(root)); + when(tagRepository.findDescendantIds(rootId)).thenReturn(List.of(rootId, childId)); + when(tagRepository.findAllById(Set.of(childId))).thenReturn(List.of(child)); + // resolveEffectiveColors will call findAllById for the child's parent color + when(tagRepository.findAllById(Set.of(rootId))).thenReturn(List.of(root)); + + tagService.search("Brief"); + + // verify findAllById was called at least twice: once for extras, once inside resolveEffectiveColors + verify(tagRepository, atLeastOnce()).findAllById(any()); + } } diff --git a/frontend/src/lib/components/TagInput.svelte b/frontend/src/lib/components/TagInput.svelte index 2dc13e6c..0442869c 100644 --- a/frontend/src/lib/components/TagInput.svelte +++ b/frontend/src/lib/components/TagInput.svelte @@ -6,6 +6,8 @@ import { clickOutside } from '$lib/actions/clickOutside'; export type Tag = { id?: string; name: string; color?: string; parentId?: string }; +type SuggestionEntry = { tag: Tag; depth: number; isDirectMatch: boolean }; + interface Props { tags?: Tag[]; allowCreation?: boolean; @@ -16,37 +18,40 @@ let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props( let inputVal = $state(''); let suggestions: Tag[] = $state([]); +let fetchedForQuery = $state(''); let activeIndex = $state(-1); let showSuggestions = $state(false); -const suggestionsById = $derived( - new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s])) -); - -const orderedSuggestions = $derived.by(() => { +const orderedSuggestions = $derived.by((): SuggestionEntry[] => { + // SvelteMap satisfies svelte/prefer-svelte-reactivity; reactivity comes from $derived.by() re-evaluation + const byId = new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s])); + const childrenOf = new SvelteMap(); const roots: Tag[] = []; - const childrenMap = new SvelteMap(); - const orphans: Tag[] = []; for (const s of suggestions) { - if (!s.parentId) { - roots.push(s); - } else if (suggestionsById.has(s.parentId)) { - const children = childrenMap.get(s.parentId) ?? []; - children.push(s); - childrenMap.set(s.parentId, children); + if (s.parentId && byId.has(s.parentId)) { + const arr = childrenOf.get(s.parentId) ?? []; + arr.push(s); + childrenOf.set(s.parentId, arr); } else { - orphans.push(s); + roots.push(s); } } - const result: Tag[] = []; - for (const root of roots) { - result.push(root); - const children = childrenMap.get(root.id!) ?? []; - result.push(...children); + const query = fetchedForQuery; + const result: SuggestionEntry[] = []; + + function dfs(tag: Tag, depth: number) { + result.push({ tag, depth, isDirectMatch: tag.name.toLowerCase().includes(query) }); + for (const child of childrenOf.get(tag.id!) ?? []) { + dfs(child, depth + 1); + } } - result.push(...orphans); + + for (const root of roots) { + dfs(root, 0); + } + return result; }); @@ -62,6 +67,8 @@ async function fetchSuggestions(query: string) { const currentTags = untrack(() => tags); const currentNames = new Set(currentTags.map((t) => t.name)); suggestions = data.filter((t) => !currentNames.has(t.name)); + // fetchedForQuery set after suggestions: $derived fires twice but second run is correct + fetchedForQuery = query.toLowerCase(); showSuggestions = true; } } catch (e) { @@ -97,7 +104,7 @@ function handleKeydown(e: KeyboardEvent) { if (e.key === 'Enter') { e.preventDefault(); if (activeIndex >= 0 && orderedSuggestions[activeIndex]) { - addTag(orderedSuggestions[activeIndex]); + addTag(orderedSuggestions[activeIndex].tag); } else if (allowCreation) { addTag(inputVal); } @@ -153,34 +160,53 @@ function handleKeydown(e: KeyboardEvent) { { fetchSuggestions(inputVal); onTextInput?.(inputVal); }} + oninput={() => { + fetchSuggestions(inputVal); + onTextInput?.(inputVal); + }} onkeydown={handleKeydown} onfocus={() => fetchSuggestions(inputVal)} placeholder={tags.length === 0 - ? allowCreation - ? m.comp_taginput_placeholder_create() - : m.comp_taginput_placeholder_filter() - : ''} + ? allowCreation + ? m.comp_taginput_placeholder_create() + : m.comp_taginput_placeholder_filter() + : ''} class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0" /> {#if showSuggestions && orderedSuggestions.length > 0}
    - {#each orderedSuggestions as suggestion, i (suggestion.id ?? suggestion.name)} + {#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}
  • addTag(suggestion)} - onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)} + style="padding-left: {s.depth * 16 + 12}px" + class="cursor-pointer py-3 pr-3 text-sm hover:bg-muted {i === activeIndex + ? 'bg-muted font-bold text-ink' + : s.isDirectMatch + ? 'font-medium text-ink-2' + : 'text-ink-3'}" + onclick={() => addTag(s.tag)} + onkeydown={(e) => e.key === 'Enter' && addTag(s.tag)} > - {suggestion.name} + + {#if !s.isDirectMatch && s.depth === 0} + + {/if} + {#if s.tag.color} + + {/if} + {s.tag.name}
  • {/each}
diff --git a/frontend/src/lib/components/TagInput.svelte.spec.ts b/frontend/src/lib/components/TagInput.svelte.spec.ts index 4a269a8d..8605a236 100644 --- a/frontend/src/lib/components/TagInput.svelte.spec.ts +++ b/frontend/src/lib/components/TagInput.svelte.spec.ts @@ -1,11 +1,21 @@ import { describe, expect, it, vi, afterEach } from 'vitest'; import { cleanup, render } from 'vitest-browser-svelte'; import { page, userEvent } from 'vitest/browser'; -import TagInput from './TagInput.svelte'; +import TagInput, { type Tag } from './TagInput.svelte'; -const waitForDebounce = () => new Promise((r) => setTimeout(r, 350)); +const waitForFetch = () => new Promise((r) => setTimeout(r, 50)); const tick = () => new Promise((r) => setTimeout(r, 0)); +function mockFetchWithTagObjects(tags: Tag[]) { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(tags) + }) + ); +} + function mockFetchWithTags(tagNames: string[]) { vi.stubGlobal( 'fetch', @@ -158,7 +168,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('Fa'); - await waitForDebounce(); + await waitForFetch(); await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument(); await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument(); await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' }); @@ -169,7 +179,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('F'); - await waitForDebounce(); + await waitForFetch(); expect(fetch).not.toHaveBeenCalled(); }); @@ -178,7 +188,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('Fr'); - await waitForDebounce(); + await waitForFetch(); await expect.element(page.getByRole('option', { name: 'Familie' })).not.toBeInTheDocument(); await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument(); }); @@ -188,7 +198,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('Fa'); - await waitForDebounce(); + await waitForFetch(); document.querySelector('[role="option"]')!.click(); await tick(); await expect.element(page.getByText('Familie')).toBeInTheDocument(); @@ -201,7 +211,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('__'); - await waitForDebounce(); + await waitForFetch(); await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin await userEvent.keyboard('{Enter}'); @@ -213,7 +223,7 @@ describe('TagInput – autocomplete', () => { render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('Fa'); - await waitForDebounce(); + await waitForFetch(); await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument(); document.body.click(); await tick(); @@ -221,27 +231,114 @@ describe('TagInput – autocomplete', () => { }); it('shows child suggestion after its parent when both are in results', async () => { - vi.stubGlobal( - 'fetch', - vi.fn().mockResolvedValue({ - ok: true, - json: vi.fn().mockResolvedValue([ - { id: 'p1', name: 'Eltern' }, - { id: 'c1', name: 'Kind', parentId: 'p1' } - ]) - }) - ); + mockFetchWithTagObjects([ + { id: 'p1', name: 'Eltern' }, + { id: 'c1', name: 'Kind', parentId: 'p1' } + ]); render(TagInput, { tags: [], allowCreation: true }); const input = page.getByRole('textbox'); await input.fill('El'); - await waitForDebounce(); - const options = document.querySelectorAll('[role="option"]'); - const names = Array.from(options).map((el) => el.textContent?.trim()); - const elternIdx = names.indexOf('Eltern'); - const kindIdx = names.indexOf('Kind'); - expect(elternIdx).toBeGreaterThanOrEqual(0); - expect(kindIdx).toBeGreaterThanOrEqual(0); - expect(elternIdx).toBeLessThan(kindIdx); + await waitForFetch(); + const options = Array.from(document.querySelectorAll('[role="option"]')); + const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? ''); + expect(texts.indexOf('Eltern')).toBeLessThan(texts.indexOf('Kind')); + // direct match (Eltern contains 'El') has font-medium; context child does not + const elternEl = options.find((el) => el.textContent?.replace(/›/g, '').trim() === 'Eltern'); + expect(elternEl?.classList.contains('font-medium')).toBe(true); + }); + + it('dropdown has role listbox', async () => { + mockFetchWithTags(['Familie']); + render(TagInput, { tags: [], allowCreation: true }); + const input = page.getByRole('textbox'); + await input.fill('Fa'); + await waitForFetch(); + await expect.element(page.getByRole('listbox')).toBeInTheDocument(); + }); + + it('selects a suggestion via Enter when allowCreation=false', async () => { + mockFetchWithTags(['Familie']); + render(TagInput, { tags: [], allowCreation: false }); + const input = page.getByRole('textbox'); + await input.fill('Fa'); + await waitForFetch(); + await userEvent.keyboard('{ArrowDown}'); // index 0 → Familie + await userEvent.keyboard('{Enter}'); + await expect.element(page.getByText('Familie')).toBeInTheDocument(); + await expect.element(input).toHaveValue(''); + }); +}); + +// ─── Tree-aware ordering ────────────────────────────────────────────────────── + +describe('TagInput – tree-aware ordering', () => { + it('shows children below match when root tag matches in DFS order', async () => { + mockFetchWithTagObjects([ + { id: 'r1', name: 'Briefe' }, + { id: 'c1', name: 'Familienbriefe', parentId: 'r1' }, + { id: 'g1', name: 'Weihnachtsbriefe', parentId: 'c1' } + ]); + render(TagInput, { tags: [], allowCreation: true }); + const input = page.getByRole('textbox'); + await input.fill('Brief'); + await waitForFetch(); + const options = Array.from(document.querySelectorAll('[role="option"]')); + const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? ''); + expect(texts).toContain('Briefe'); + expect(texts).toContain('Familienbriefe'); + expect(texts).toContain('Weihnachtsbriefe'); + expect(texts.indexOf('Briefe')).toBeLessThan(texts.indexOf('Familienbriefe')); + expect(texts.indexOf('Familienbriefe')).toBeLessThan(texts.indexOf('Weihnachtsbriefe')); + }); + + it('shows ancestor above match when child tag matches', async () => { + mockFetchWithTagObjects([ + { id: 'r1', name: 'Briefe' }, + { id: 'c1', name: 'Hochzeit', parentId: 'r1' } + ]); + render(TagInput, { tags: [], allowCreation: true }); + const input = page.getByRole('textbox'); + await input.fill('Hochzeit'); + await waitForFetch(); + const options = Array.from(document.querySelectorAll('[role="option"]')); + const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? ''); + expect(texts.indexOf('Briefe')).toBeLessThan(texts.indexOf('Hochzeit')); + const hochzeitEl = options.find( + (el) => el.textContent?.replace(/›/g, '').trim() === 'Hochzeit' + ); + expect(hochzeitEl?.classList.contains('font-medium')).toBe(true); + }); + + it('keyboard Enter on context node adds it as a tag', async () => { + mockFetchWithTagObjects([ + { id: 'r1', name: 'Briefe' }, + { id: 'c1', name: 'Hochzeit', parentId: 'r1' } + ]); + render(TagInput, { tags: [], allowCreation: true }); + const input = page.getByRole('textbox'); + await input.fill('Hochzeit'); + await waitForFetch(); + await userEvent.keyboard('{ArrowDown}'); // first item: Briefe (context ancestor) + await userEvent.keyboard('{Enter}'); + // successful add clears the input; if .tag unwrap is missing, addTag returns early + await expect.element(input).toHaveValue(''); + }); + + it('shows orphaned children at depth 0 when their parent is already selected', async () => { + mockFetchWithTagObjects([ + { id: 'p1', name: 'Briefe' }, + { id: 'c1', name: 'Familienbriefe', parentId: 'p1' } + ]); + render(TagInput, { tags: [{ id: 'p1', name: 'Briefe' }], allowCreation: true }); + const input = page.getByRole('textbox'); + await input.fill('Fa'); + await waitForFetch(); + await expect.element(page.getByRole('option', { name: 'Familienbriefe' })).toBeInTheDocument(); + // the already-selected parent must not appear as a suggestion + const optionNames = Array.from(document.querySelectorAll('[role="option"]')).map( + (el) => el.textContent?.replace(/›/g, '').trim() ?? '' + ); + expect(optionNames).not.toContain('Briefe'); }); }); @@ -271,7 +368,7 @@ describe('TagInput – onTextInput callback', () => { render(TagInput, { tags: [], allowCreation: false, onTextInput }); const input = page.getByRole('textbox'); await input.fill('Ka'); - await waitForDebounce(); + await waitForFetch(); const option = page.getByRole('option', { name: 'Kaufvertrag' }); await option.click(); await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);