feat(tag-typeahead): tree-aware results — expand children & surface ancestor path #250
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
Summary
The tag typeahead currently only returns tags whose names match the search query. Two gaps result:
Design spec
→ docs/specs/tag-typeahead-tree-aware.html
The spec covers all cases with mockups at ~55% scale and a full implementation reference table.
Behaviour
findDescendantIdsrecursive CTE).findAncestorIdsrecursive CTE).font-medium. Context nodes render intext-ink-3with a›prefix for root-level ancestors.Scope
Backend —
TagService.javaModify
search(String query)only. No new endpoint, no new DTO, no API type regeneration.Reuses:
TagRepository.findDescendantIds(),TagRepository.findAncestorIds(),TagRepository.findAllById(),TagService.resolveEffectiveColors()— all already exist.Frontend —
TagInput.svelteReplace the current 1-level-deep ordering logic with a recursive DFS that computes depth per entry:
depth * 16 + 12px (inline style — dynamic Tailwind classes not viable)›prefixTests
TagServiceTest: 3 new unit tests (children included, ancestors included, deduplication)TagInput.svelte.spec.ts: 4 new/updated tests (ancestor above match, children below match, 3-level DFS order, Enter on context node adds tag)Files to change
backend/…/service/TagService.javasearch()backend/…/service/TagServiceTest.javafrontend/src/lib/components/TagInput.svelteorderedSuggestions+ templatefrontend/src/lib/components/TagInput.svelte.spec.ts🏗️ Markus Keller — Senior Application Architect
Observations
generate:apirun. TheTagentity already carriesparentId— the frontend already has what it needs. This is the minimum invasive change.search()that documents this cost profile.findAncestorIdsdoes NOT include the seed ID — contrary to the spec note which says "Both returnList<UUID>including the seed ID." Looking at the actual query:SELECT parent_id FROM ancestors WHERE id = :tagId— it returns the parent's ID, not the queried tag's own ID.findDescendantIdsdoes include the seed. The deduplicationextraIds.removeAll(matchedIds)is still correct (it catches thefindDescendantIdscase), but it's effectively a no-op for the ancestor path. No bug — but the deduplication test should target the descendant path to be meaningful, not the ancestor path.resolveEffectiveColorsis single-level by design. It only loads immediate parents. This is correct because colors are stored only at root level (per the code comment). Verify this assumption still holds when implementing — if a multi-level intermediate tag ever gets a color set directly, the enrichment may show wrong colors on grandchildren. Not blocking, just worth a verify-before-merge check.search()lacks@Transactional— this is correct. Read methods in this codebase are deliberately non-transactional per the CLAUDE.md convention. Do not add@Transactionaltosearch()even though it now issues multiple queries.Recommendations
Extract the enrichment into a private helper to keep
search()under 20 lines and the logic independently readable:This also makes
enrichWithRelativestestable in isolation if needed later.Confirm the early-exit guard
if (!extraIds.isEmpty())is in place beforefindAllById— the spec's pseudocode shows it. IfextraIdsis empty (all matched tags are leaves with no hierarchy), skip the batch fetch entirely.No ADR required. The change stays entirely within
TagService's domain, reuses existing CTE infrastructure, and doesn't alter the API contract. Too small to warrant documentation beyond a code comment.👨💻 Felix Brandt — Senior Fullstack Developer
Observations
Backend —
TagService.javasearch()is 1 line. The proposed expansion adds ~15 lines. With Markus'senrichWithRelatives()extraction, the public method stays at 4 lines — good.findAncestorIdsseed discrepancy: The spec states both CTEs include the seed ID butfindAncestorIdsdoes not — it returnsparent_idvalues starting from the queried tag. The deduplicationextraIds.removeAll(matchedIds)is correct forfindDescendantIds(which includes the seed), but fires on an empty intersection for the ancestor path. Write the deduplication test (search_deduplicates_when_ancestor_also_matches) by mocking a root tag that also matches — the descendant CTE returns[rootId, childId], sorootIdappears in bothmatchedIdsandextraIds. That's the path where deduplication actually fires.resolveEffectiveColorsis called on the merged result list. This is a side-effectful call that drives color display. Add:search_callsResolveEffectiveColors_onAllResults()— mock a root+child result, callsearch(), verifytagRepository.findAllById(...)is invoked (which is whatresolveEffectiveColorstriggers for colorless children).Frontend —
TagInput.svelteTailwind dynamic class interpolation is a problem. The spec template uses:
Tailwind's content scanner will not detect
opacity-100/opacity-50inside a template literal interpolation like this. Use a ternary with full class names instead:This is the same reason inline style is used for
padding-left— dynamic values can't be reliably scanned. Apply the same discipline to opacity.suggestionsByIdderived map becomes dead code after the rewrite. The spec explicitly calls this out. Delete it entirely — the neworderedSuggestionsbuilds its ownbyIdmap internally.handleKeydownbreaking change:addTag(orderedSuggestions[activeIndex])must becomeaddTag(orderedSuggestions[activeIndex].tag). The existing testnavigates suggestions with ArrowDown and selects with Enterwill catch this regression if it's missed, since it verifies a tag chip is added after keyboard selection.Existing test at line 223 needs a careful review.
shows child suggestion after its parent when both are in resultsmocks{ id: 'p1', name: 'Eltern' }and{ id: 'c1', name: 'Kind', parentId: 'p1' }. The DOM-order assertion via[role="option"]will still pass (DFS produces the same order), but theaddTagcall path now receives aSuggestionEntry, not aTag. IfaddTag(suggestion)in theonclickhandler isn't updated toaddTag(s)(per the spec's template), clicking a suggestion will pass aSuggestionEntryobject toaddTag, which expects aTag. This is the key regression risk — the test checks ordering but not selection behavior.Recommendations
@Test void search_includes_children_when_root_matches(), etc.), confirm they fail, then implementenrichWithRelatives. Never touchsearch()before the tests are red.mockFetchWithTagObjects(tags: Tag[])helper in the spec file to replace the ad-hocvi.stubGlobalcalls the new tests will need — the existingmockFetchWithTags(tagNames: string[])only creates nameless objects and won't supportid/parentIdfor tree tests.SvelteMapfromorderedSuggestionsinternals in the new implementation — a plainMapis correct inside$derived.by()since it's recomputed fresh each time and doesn't need reactive mutations.🔒 Nora Steiner (@NullX) — Application Security Engineer
Observations
TagInput.svelteusesencodeURIComponent(query)before constructing the URL. No injection risk in the query parameter.findDescendantIdsandfindAncestorIdsboth includeAND a.depth < 50— a crafted deeply-nested tag tree cannot cause a runaway recursive query.resolveEffectiveColorsis safe. It works on detached in-memory entities and issues onlySELECTqueries viafindAllById. No unintended write path is opened.Setis correct. UsingSet<UUID>forextraIdsandmatchedIdsprevents the same tag appearing twice in the result without any application-layer race conditions. UUID identity is stable.GET /api/tags?query=...endpoint.Recommendations
No security changes required. This is a clean additive enrichment on an existing authenticated read path. No concerns from my angle.
🧪 Sara Holt — QA Engineer
Observations
mockFetchWithTags(tagNames: string[])is insufficient for new tests. The existing helper creates tag objects withnameonly (noid, noparentId). All 4 new frontend tests need hierarchy data. Each test currently falls back to inliningvi.stubGlobal(...)(as the existingshows child suggestion after its parenttest already does at line 223). Extract amockFetchWithTagObjects(tags: Tag[])helper to avoid 4 copies of the samevi.stubGlobalboilerplate.shows child suggestion after its parent when both are in resultstest (line 223) checks DOM ordering via[role="option"]index — this will still pass after the change. However, the spec says to "update" it, meaning it should also assertisDirectMatchstyling (e.g., the parent renders withfont-mediumand the child renders without). Consider checking computedpadding-leftor adata-testidattribute to verify depth indentation.keyboard Enter on context node adds it as a tag) is the highest-value test — it exercises thehandleKeydownbreaking change (.tagunwrap) and confirms that context ancestors are truly selectable. Write this one first; if it passes without the.tagfix, something is wrong.snake_caseconvention inTagServiceTest.java. The spec names are already in this style (search_includes_children_when_root_matches) — use them verbatim.Recommendations
matchedandfindDescendantIdsresult — that's the actual deduplication path (see Felix's note onfindAncestorIdsseed discrepancy).@ExtendWith(MockitoExtension.class)level — no@SpringBootTestneeded. The existingTagServiceTestsetup is correct and should be followed exactly.🎨 Leonie Voss — UI/UX Designer
Observations
›prefix, 50% dot opacity for context nodes,depth * 16 + 12px indentation formula — are all clearly reasoned.text-ink-3token needs verification. The spec calls fortext-ink-3for context nodes, but the existingTagInput.svelteusestext-ink-2for non-active suggestions. I don't seetext-ink-3used anywhere in the current TagInput. Verify it's defined inlayout.cssbefore implementing — if it's missing, add it as:--color-ink-3: #6b7280(matching the hex shown in the spec's impl-ref table). Using an undefined Tailwind class silently falls back to the default color, which would make context nodes visually indistinguishable from direct matches.role="listbox"is missing from the<ul>. The dropdown usesrole="option"on each<li>but the parent<ul>has norole="listbox". This violates WCAG 1.3.1 (Info and Relationships) — screen readers won't understand the list structure. This is an existing bug; any touch of this file is the right moment to fix it:aria-label, or use a generic "Suggestions" label.)›prefix glyph will be announced by screen readers. The spec renders it as a plain text character inside a<span>. Screen readers will read it as "right-pointing angle quotation mark" or similar. Addaria-hidden="true"to that span:›is a decorative visual cue, not semantic content.h-2 w-2(8×8px), a 50%-opacity dot on a white background may be hard to distinguish for users with low vision. The spec accepts this tradeoff — noted but not blocking. If it causes complaints from the senior audience (60+), consideropacity-75as a compromise.py-2class gives ~32px item height, below the 44px WCAG 2.2 minimum. This is an existing limitation of the dropdown density — acceptable for a desktop-focused input. Not introduced by this change.Recommendations
text-ink-3is inlayout.css. If not, add--color-ink-3: #6b7280and register it as a Tailwind utility.role="listbox"to the<ul>— this is a one-line fix for a real accessibility violation.aria-hidden="true"to the›prefix span — prevents screen readers from announcing decorative punctuation.comp_taginput_*keys remain applicable.🚀 Tobias Wendt — DevOps & Platform Engineer
Observations
GET /api/tagswill naturally capture any slowdown if this becomes a bottleneck at scale. No new instrumentation needed for this feature.@ExtendWith(MockitoExtension.class)) and frontend component tests (vitest-browser-svelte) both run in the existing CI jobs without changes.No concerns from my angle. Clean implementation that fits squarely within the existing deployment footprint.
✅ Implementation complete — branch
feat/issue-250-tag-typeahead-tree-awareWhat was implemented
Backend —
TagService.javaenrichWithRelatives(List<Tag> matched)helper;search()now calls it when results are non-emptyfindDescendantIds; child matches prepend ancestors viafindAncestorIdsextraIds.removeAll(matchedIds)(handlesfindDescendantIdsseed inclusion)resolveEffectiveColorscalled on the merged result listTagServiceTest:search_includes_children_when_root_matches,search_includes_ancestors_when_child_matches,search_deduplicates_when_root_also_in_descendant_result,search_callsResolveEffectiveColors_onAllResultsFrontend —
TagInput.svelteSuggestionEntrytype ({ tag, depth, isDirectMatch })orderedSuggestionswith recursive DFS that computes depth per entry;isDirectMatchderived fromfetchedForQuerystatesuggestionsByIdderiveddepth * 16 + 12pxfont-medium text-ink-2; context nodes:text-ink-3›prefix witharia-hidden="true"handleKeydownandonclickto unwrap.tagfromSuggestionEntryrole="listbox"to the<ul>(Leonie's accessibility fix)Frontend tests —
TagInput.svelte.spec.tsmockFetchWithTagObjectshelpershows child suggestion after its parentto also verifyfont-mediumon direct matchCommits
d075bf3— feat(tag-search): expand children and surface ancestor path in search results5120dd1— feat(tag-input): tree-aware DFS ordering, depth indentation, and direct-match stylingAll 1013 backend tests and 947 frontend tests pass ✅