When a parent tag matches a search query, its children appear below it. When a child tag matches, its ancestor chain appears above it as navigational context. All returned items remain selectable.
| What | How | Notes |
|---|---|---|
| File | backend/src/main/java/…/service/TagService.java |
Modify existing search(String query) method only. No new endpoint, no new DTO, no API type regeneration needed. |
| Step 1 — name search | List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query); |
Existing query, unchanged. Returns early if empty. |
| Step 2 — collect extra IDs | Set<UUID> extraIds = new HashSet<>();
for (Tag t : matched) {
if (t.getParentId() == null) {
// root match → expand descendants
extraIds.addAll(tagRepository.findDescendantIds(t.getId()));
} else {
// child match → walk up to root
extraIds.addAll(tagRepository.findAncestorIds(t.getId()));
}
}
// remove IDs we already have to avoid duplicate fetch
Set<UUID> matchedIds = matched.stream()
.map(Tag::getId).collect(Collectors.toSet());
extraIds.removeAll(matchedIds); |
findDescendantIds and findAncestorIds are recursive CTEs already in TagRepository, depth-guarded at 50 levels. Both return List<UUID> including the seed ID — removing matched IDs deduplicates cleanly. |
| Step 3 — batch fetch & merge | List<Tag> all = new ArrayList<>(matched);
if (!extraIds.isEmpty()) {
all.addAll(tagRepository.findAllById(extraIds));
}
resolveEffectiveColors(all);
return all; |
tagRepository.findAllById() is inherited from JpaRepository — one IN-clause query. resolveEffectiveColors() already exists in TagService; it populates inherited color from parent for child tags. |
| Query count | 1 (name search) + N (CTE per matched tag) + 1 (batch fetch) | For a typical query matching 1–3 tags: 3–5 queries total. CTEs are recursive but depth-guarded; negligible cost for typical tag trees of depth ≤ 4. |
| Return type | List<Tag> — unchanged |
No API contract change. Frontend Tag type ({ id, name, color?, parentId? }) already carries parentId. No generate:api run needed. |
| Unit tests to add | TagServiceTest.java |
1. search_includes_children_when_root_matches() — mock root match, mock findDescendantIds, assert children in result.2. search_includes_ancestors_when_child_matches() — mock child match with parentId, mock findAncestorIds, assert parent in result.3. search_deduplicates_when_ancestor_also_matches() — parent matches AND child matches; ancestor not duplicated in result.
|
| What | How | Notes |
|---|---|---|
| New type alias | type SuggestionEntry = {
tag: Tag;
depth: number;
isDirectMatch: boolean;
}; |
Replace the current Tag[] return type of orderedSuggestions with SuggestionEntry[]. The Tag type is already imported from the local definition at the top of the file. |
| Direct match set | const directMatchIds = $derived(
new Set(
suggestions
.filter(s => s.id &&
s.name.toLowerCase()
.includes(inputVal.toLowerCase()))
.map(s => s.id!)
)
); |
A tag is a "direct match" if its name contains the current input text (case-insensitive). Tags added by backend enrichment (ancestors/descendants) will not match, so isDirectMatch will be false for them. |
| Replace orderedSuggestions | const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
const byId = new Map(
suggestions.filter(s => s.id).map(s => [s.id!, s])
);
const childrenOf = new Map<string, Tag[]>();
for (const s of suggestions) {
if (s.parentId && byId.has(s.parentId)) {
const list = childrenOf.get(s.parentId) ?? [];
list.push(s);
childrenOf.set(s.parentId, list);
}
}
// roots = tags whose parent is not in this result set
const roots = suggestions.filter(
s => !s.parentId || !byId.has(s.parentId)
);
const result: SuggestionEntry[] = [];
function walk(tag: Tag, depth: number) {
result.push({
tag,
depth,
isDirectMatch: directMatchIds.has(tag.id!)
});
for (const child of childrenOf.get(tag.id!) ?? []) {
walk(child, depth + 1);
}
}
for (const root of roots) walk(root, 0);
return result;
}); |
Replaces the existing 3-bucket (roots/childrenMap/orphans) logic. The DFS handles unlimited depth. The "orphan" concept disappears — items whose parent is absent become roots at depth 0. |
| Update handleKeydown | // Before: addTag(orderedSuggestions[activeIndex]) // After: addTag(orderedSuggestions[activeIndex].tag) |
The activeIndex logic itself is unchanged. Only the access pattern changes: .tag to unwrap the SuggestionEntry. |
| Dropdown template item | {#each orderedSuggestions as { tag: s, depth, isDirectMatch }, i (s.id ?? s.name)}
<li
role="option"
aria-selected={i === activeIndex}
tabindex="0"
style="padding-left: {depth * 16 + 12}px"
class="cursor-pointer py-2 pr-3 text-sm border-b border-line-2
last:border-b-0
{i === activeIndex
? 'bg-muted font-bold text-ink'
: isDirectMatch
? 'text-ink font-medium'
: 'text-ink-3'}
hover:bg-muted"
onclick={() => addTag(s)}
onkeydown={(e) => e.key === 'Enter' && addTag(s)}
>
{#if !isDirectMatch && depth === 0}
<span class="mr-1 text-ink-3">›</span>
{/if}
{#if s.color}
<span
style="background-color: var(--c-tag-{s.color})"
class="inline-block h-2 w-2 rounded-full mr-1 opacity-{isDirectMatch ? '100' : '50'}"
></span>
{/if}
{s.name}
</li>
{/each} |
Indent formula: depth × 16 + 12 px (inline style — Tailwind dynamic classes not viable for variable values).Context nodes at depth 0 (ancestors without a parent in results) get a › prefix to signal "this is context, not a primary result".Color dot opacity: 100% for direct matches, 50% for context nodes. |
| Remove pl-6 class | Delete the old conditional {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''} |
Indentation is now handled entirely by the style="padding-left: …" inline style based on computed depth. The old suggestionsById derived map can also be removed as it's no longer needed. |
| Depth indentation | depth 0 → 12px · depth 1 → 28px · depth 2 → 44px | Formula: depth * 16 + 12. Maximum realistic depth in this app is 3–4 levels. No truncation needed. |
| Context node style | text-ink-3 (#6b7280) · no font-weight override |
Context nodes (not a direct match) use the muted gray. When hovered or keyboard-active, they get bg-muted font-bold text-ink — same as any other item. They are fully clickable. |
| i18n keys | None new required. | The feature is purely visual. All existing comp_taginput_* keys remain applicable. |
| Frontend tests to update | TagInput.svelte.spec.ts |
1. Update existing "shows child after parent" test — it now expects SuggestionEntry depth/match info.2. Add: ancestor shown above direct match with text-ink-3.3. Add: 3-level path renders in correct DFS order. 4. Add: keyboard Enter on context node adds it as a tag. |