feat(tag-typeahead): tree-aware results — expand children & surface ancestor path #251
Reference in New Issue
Block a user
Delete Branch "feat/issue-250-tag-typeahead-tree-aware"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #250
Summary
Implements tree-aware tag typeahead: root matches now expand their descendants in the dropdown, and child matches prepend their ancestor path so users always see hierarchy context. The backend enriches
TagService.search()with a newenrichWithRelatives()helper that reuses existing CTE infrastructure. The frontend rewritesorderedSuggestionsas a recursive DFS with aSuggestionEntrytype carrying depth andisDirectMatch— direct matches render withfont-medium, context nodes withtext-ink-3and a›prefix, indented bydepth * 16 + 12px inline style. Arole="listbox"accessibility fix is included.🏛️ Markus Keller — Senior Application Architect
Verdict: ✅ Approved
What I checked
Layer boundaries, abstraction level, query complexity, and frontend state design.
Findings
enrichWithRelatives()extraction is correct — the method does one job, has an accurate complexity comment (1 CTE + 1 batch per matched tag, 3–5 queries total for typical searches), and keepssearch()down to 4 lines. For a family archive with O(hundreds) of tags, this query budget is well within acceptable bounds.Service boundary respected —
TagService.search()only touchesTagRepository. No cross-domain calls introduced, no boundary leaks.Frontend
orderedSuggestionsis the right place for tree-ordering logic — the DFS lives inside$derived.by()where it belongs: derived state, not scattered template conditionals. TheSuggestionEntrytype cleanly separates the UI concern (depth,isDirectMatch) from the domain concern (tag).One observation (not a blocker):
SvelteMapinside$derived.by()is technically unnecessary for reactivity — the entire block re-runs whensuggestionsorfetchedForQuerychanges anyway, so the reactive collection semantics ofSvelteMapare not exercised. It satisfies thesvelte/prefer-svelte-reactivityESLint rule, but it may confuse a future reader who wonders why aMapthat never mutates after construction needs to be reactive. Worth a one-line comment at the declaration site to explain the ESLint constraint.🔧 Tobias Wendt — DevOps & Platform Engineer
Verdict: ✅ Approved
Nothing to flag from my side.
This PR is entirely application-layer — no
docker-compose.ymlchanges, no CI workflow changes, no new infrastructure dependencies, no new environment variables. The recursive CTE queries (findDescendantIds,findAncestorIds) already existed inTagRepository.javaand run on the existing PostgreSQL 16 stack. No new indexes added (thefindByNameContainingIgnoreCasequery uses the existing table scan, which is fine at family-archive scale).Checked: no secrets committed, no bind mounts, no new services. CI pipeline is unaffected. Good to go.
👨💻 Felix Brandt — Senior Fullstack Developer
Verdict: ✅ Approved
What I checked
TDD discipline, naming, single-responsibility, Svelte 5 correctness, and template cleanliness.
Strengths
enrichWithRelatives()is well-named and does exactly one thing. The early-return guard insearch()(if (matched.isEmpty()) return matched) prevents unnecessary processing ✅orderedSuggestionsas a$derived.by()withSuggestionEntrytype is clean — the separation betweentag(domain),depth(UI), andisDirectMatch(UI) is correct ✅{#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}— keyed correctly ✅addTag(orderedSuggestions[activeIndex].tag)— the.tagunwrap is necessary and correct given the newSuggestionEntry[]type; the regression test for this is good ✅{#each}blocks keyed, no business logic in the template beyond theisDirectMatchconditional ✅Suggestions (no blockers)
1.
SvelteMapin$derived.by()— add a comment (TagInput.svelte:26–27):Without this, a future reader will wonder why a non-mutating derived Map is reactive.
2.
›prefix condition is non-obvious (TagInput.svelte:195):This shows
›only on root-level context ancestors — at depth > 0, the indentation already communicates hierarchy. The logic is correct, but a one-liner above it would prevent confusion during refactoring.3.
fetchedForQuerylatent race window (TagInput.svelte:57–74):Inside
fetchSuggestions,suggestionsis updated first, thenfetchedForQuery. Between the two assignments there's a single microtask whereorderedSuggestionsrecomputes against the stale query. In practice harmless (the derived fires twice, the second is correct), but worth documenting with a comment at the assignment site.🔐 Nora "NullX" Steiner — Application Security Engineer
Verdict: ✅ Approved
What I checked
OWASP Top 10, injection surfaces, IDOR, XSS vectors, authorization scope, and data exposure.
Findings — all clear
Injection (CWE-89): The frontend uses
encodeURIComponent(query)before inserting into the query string — correct. On the backend,findByNameContainingIgnoreCase(query)is a Spring Data derived query: the framework binds the parameter, no SQL concatenation. No injection surface.XSS (CWE-79): The
›prefix is a hardcoded Unicode character in the template. Tag names, color token names, and depth values are all rendered through Svelte's default HTML-escaping template engine. No{@html}used. No XSS surface.IDOR / Authorization (CWE-284): The tag search endpoint returns data from the global tag catalog — no per-user data, no documents, no PII. The existing permission model is unchanged. No new authorization surface introduced.
Sensitive data exposure: The
Tagtype exposed to the frontend includes{id, name, color, parentId}— no internal state, no server-side secrets.Parameterized logging (CWE-117): The
log.info(...)calls inTagService.mergeTags()already use SLF4J{}parameterization. No new logging added in this PR.Nothing to flag. Clean PR from a security standpoint.
🧪 Sara Holt — Senior QA Engineer
Verdict: ⚠️ Approved with concerns
What I checked
Backend test coverage, frontend component test coverage, test naming, reliability, and missing edge cases.
Strengths
snake_casenames covering the key behaviors: root→descendants expansion, child→ancestors surfacing, deduplication when seed is in descendant result, andresolveEffectiveColorsinvocation ✅mockFetchWithTagObjects(tags: Tag[])helper fills the gap frommockFetchWithTags(which only built name-only objects) — good extension of the test infrastructure ✅keyboard Enter on context node adds it as a tagis a smart regression guard: if.tagunwrap is ever removed fromaddTag(orderedSuggestions[activeIndex].tag), the input won't clear and the test fails ✅shows orphaned children at depth 0 when their parent is already selectedcovers the client-side filter + tree-order interaction cleanly ✅getByRole('option', { name: 'Briefe' })toquerySelectorAll + texts arrayfor the ordering assertions is the right call — the substring matching behavior ofgetByRolewould cause false positives here ✅Concern —
waitForDebouncenaming and durationfetchSuggestionshas no debounce — it fires on every input event. The 350ms wait is just letting the async mock resolve (which happens as a microtask, well within 1ms). The namewaitForDebounceimplies a debounce that doesn't exist, and 350ms adds ~4.5 seconds to the test suite (13 uses × 350ms). This is not a blocker, but I'd rename it towaitForFetchand drop it to 50ms:This keeps the intent clear and speeds up the suite.
Missing edge case —
allowCreation=falsewith context-only suggestion nodeskeyboard Enter on context node adds it as a tagruns with the defaultallowCreation=true. WhenallowCreation=false, pressing Enter on an item from the suggestion list should still add it (it's a real catalog tag, not a free-text creation). The current code handles this correctly (handleKeydownchecksactiveIndex >= 0before theallowCreationguard), but there's no test proving it. A single additional variant would close this gap — not a blocker, but worth filing.🎨 Leonie Voss — UI/UX Design Lead & Accessibility Strategist
Verdict: ⚠️ Approved with concerns
What I checked
ARIA semantics, touch targets, contrast ratios, visual hierarchy, brand token usage, and mobile usability.
Strengths
role="listbox"on<ul>,role="option"on<li>,aria-selected={i === activeIndex}— correct ARIA combobox/listbox pattern, satisfies WCAG 1.3.1 (Info and Relationships) ✅aria-hidden="true"on the›decorative span — correct, screen readers won't read it ✅aria-labelon the × remove buttons — already present from before, maintained ✅stylefor depth indentation (depth * 16 + 12px) — necessary and correct; dynamic Tailwind class interpolation is unreliable for arbitrary runtime values ✅opacity-50for context nodes vsopacity-100for direct matches gives a subtle but effective visual hierarchy without adding noise ✅font-medium text-ink-2for direct matches vstext-ink-3for context nodes is a clean differentiation using semantic tokens ✅Concern — Touch target height in suggestion items
The suggestion
<li>items usepy-2 pr-3withtext-smcontent:py-2= 8px top + 8px bottom = 16px paddingtext-smline height ≈ 20pxOur design system targets 44px minimum for touch targets (WCAG 2.2 SC 2.5.8, and critical for the 60+ audience). Increase to
py-3(12px × 2 + 20px = 44px):This is a High priority fix — the tag input is used on the document edit form, which older family members access on tablets and phones.
Concern —
text-ink-3contrast on dropdown backgroundContext nodes render with
text-ink-3which maps to--c-ink-3: #6b7280(Gray-500). On a white background this is 4.48:1 — marginal for WCAG AA (threshold: 4.5:1). On the dropdown'sbg-surfacebackground (check the exact token value inlayout.css) this may drop below the threshold. Please verify with a contrast checker. If it fails, bump totext-ink-2(--c-ink-2: #374151, Gray-700, 10.7:1 on white) for context nodes and use a lighterfont-weightto maintain the visual distinction instead.Review concerns addressed
All concerns from the review round have been resolved. Here's what was done:
✅ @leonievoss — Touch target height
Changed
py-2→py-3on suggestion<li>items, bringing total height from ~36px to ~44px (WCAG 2.2 SC 2.5.8 minimum).→ commit
1b617aaContrast note:
text-ink-3(#6b7280) onbg-surface(#ffffff) = 4.8:1 — already documented inlayout.cssas "WCAG AA ✓". No change needed.✅ @saraholt —
waitForDebouncenaming and durationRenamed
waitForDebounce→waitForFetchthroughout the spec file; reduced timeout from 350ms → 50ms.fetchSuggestionshas no debounce — the wait is purely for the async mock to resolve. Saves ~3.9s off the suite (13 uses × 300ms saved).→ commit
6b7829d✅ @saraholt — Missing edge case:
allowCreation=false+ Enter on suggestionAdded regression test confirming that
Enteron a highlighted suggestion adds the tag even whenallowCreation=false. The test passed immediately — confirming the existingactiveIndex >= 0guard runs before theallowCreationcheck.→ commit
b01761d✅ @mkeller + @felixbrandt —
SvelteMapin$derived.by()commentAdded inline comment at the
SvelteMapdeclarations explaining the ESLint rule requirement.→ commit
bf010a2✅ @felixbrandt —
›prefix condition commentAdded comment above
{#if !s.isDirectMatch && s.depth === 0}explaining that the arrow is root-only because depth > 0 nodes already have indentation as a hierarchy cue.→ commit
bf010a2✅ @felixbrandt —
fetchedForQuerylatent race commentAdded comment at the
fetchedForQueryassignment explaining the intentional update order and the harmless double$derivedevaluation.→ commit
bf010a2All 948 frontend tests pass. Branch pushed.