feat(tag-typeahead): tree-aware results — expand children & surface ancestor path #251
@@ -39,7 +39,9 @@ public class TagService {
|
|||||||
private final TagRepository tagRepository;
|
private final TagRepository tagRepository;
|
||||||
|
|
||||||
public List<Tag> search(String query) {
|
public List<Tag> search(String query) {
|
||||||
return tagRepository.findByNameContainingIgnoreCase(query);
|
List<Tag> matched = tagRepository.findByNameContainingIgnoreCase(query);
|
||||||
|
if (matched.isEmpty()) return matched;
|
||||||
|
return enrichWithRelatives(matched);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tag getById(UUID id) {
|
public Tag getById(UUID id) {
|
||||||
@@ -161,6 +163,29 @@ public class TagService {
|
|||||||
|
|
||||||
// ─── private helpers ─────────────────────────────────────────────────────
|
// ─── 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<Tag> enrichWithRelatives(List<Tag> matched) {
|
||||||
|
Set<UUID> matchedIds = matched.stream().map(Tag::getId).collect(Collectors.toSet());
|
||||||
|
Set<UUID> 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<Tag> result = new ArrayList<>(matched);
|
||||||
|
if (!extraIds.isEmpty()) {
|
||||||
|
result.addAll(tagRepository.findAllById(extraIds));
|
||||||
|
}
|
||||||
|
resolveEffectiveColors(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private void validateNotSelf(UUID sourceId, UUID targetId) {
|
private void validateNotSelf(UUID sourceId, UUID targetId) {
|
||||||
if (sourceId.equals(targetId)) {
|
if (sourceId.equals(targetId)) {
|
||||||
throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF,
|
throw DomainException.badRequest(ErrorCode.TAG_MERGE_SELF,
|
||||||
|
|||||||
@@ -496,4 +496,71 @@ class TagServiceTest {
|
|||||||
.extracting(e -> ((DomainException) e).getCode())
|
.extracting(e -> ((DomainException) e).getCode())
|
||||||
.isEqualTo(ErrorCode.TAG_NOT_FOUND);
|
.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<Tag> 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<Tag> 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<Tag> 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { clickOutside } from '$lib/actions/clickOutside';
|
|||||||
|
|
||||||
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
|
export type Tag = { id?: string; name: string; color?: string; parentId?: string };
|
||||||
|
|
||||||
|
type SuggestionEntry = { tag: Tag; depth: number; isDirectMatch: boolean };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tags?: Tag[];
|
tags?: Tag[];
|
||||||
allowCreation?: boolean;
|
allowCreation?: boolean;
|
||||||
@@ -16,37 +18,40 @@ let { tags = $bindable([]), allowCreation = true, onTextInput }: Props = $props(
|
|||||||
|
|
||||||
let inputVal = $state('');
|
let inputVal = $state('');
|
||||||
let suggestions: Tag[] = $state([]);
|
let suggestions: Tag[] = $state([]);
|
||||||
|
let fetchedForQuery = $state('');
|
||||||
let activeIndex = $state(-1);
|
let activeIndex = $state(-1);
|
||||||
let showSuggestions = $state(false);
|
let showSuggestions = $state(false);
|
||||||
|
|
||||||
const suggestionsById = $derived(
|
const orderedSuggestions = $derived.by((): SuggestionEntry[] => {
|
||||||
new SvelteMap(suggestions.filter((s) => s.id).map((s) => [s.id!, s]))
|
// 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<string, Tag[]>();
|
||||||
const orderedSuggestions = $derived.by(() => {
|
|
||||||
const roots: Tag[] = [];
|
const roots: Tag[] = [];
|
||||||
const childrenMap = new SvelteMap<string, Tag[]>();
|
|
||||||
const orphans: Tag[] = [];
|
|
||||||
|
|
||||||
for (const s of suggestions) {
|
for (const s of suggestions) {
|
||||||
if (!s.parentId) {
|
if (s.parentId && byId.has(s.parentId)) {
|
||||||
roots.push(s);
|
const arr = childrenOf.get(s.parentId) ?? [];
|
||||||
} else if (suggestionsById.has(s.parentId)) {
|
arr.push(s);
|
||||||
const children = childrenMap.get(s.parentId) ?? [];
|
childrenOf.set(s.parentId, arr);
|
||||||
children.push(s);
|
|
||||||
childrenMap.set(s.parentId, children);
|
|
||||||
} else {
|
} else {
|
||||||
orphans.push(s);
|
roots.push(s);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result: Tag[] = [];
|
const query = fetchedForQuery;
|
||||||
for (const root of roots) {
|
const result: SuggestionEntry[] = [];
|
||||||
result.push(root);
|
|
||||||
const children = childrenMap.get(root.id!) ?? [];
|
function dfs(tag: Tag, depth: number) {
|
||||||
result.push(...children);
|
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;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,6 +67,8 @@ async function fetchSuggestions(query: string) {
|
|||||||
const currentTags = untrack(() => tags);
|
const currentTags = untrack(() => tags);
|
||||||
const currentNames = new Set(currentTags.map((t) => t.name));
|
const currentNames = new Set(currentTags.map((t) => t.name));
|
||||||
suggestions = data.filter((t) => !currentNames.has(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;
|
showSuggestions = true;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -97,7 +104,7 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
|
if (activeIndex >= 0 && orderedSuggestions[activeIndex]) {
|
||||||
addTag(orderedSuggestions[activeIndex]);
|
addTag(orderedSuggestions[activeIndex].tag);
|
||||||
} else if (allowCreation) {
|
} else if (allowCreation) {
|
||||||
addTag(inputVal);
|
addTag(inputVal);
|
||||||
}
|
}
|
||||||
@@ -153,34 +160,53 @@ function handleKeydown(e: KeyboardEvent) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={inputVal}
|
bind:value={inputVal}
|
||||||
oninput={() => { fetchSuggestions(inputVal); onTextInput?.(inputVal); }}
|
oninput={() => {
|
||||||
|
fetchSuggestions(inputVal);
|
||||||
|
onTextInput?.(inputVal);
|
||||||
|
}}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
onfocus={() => fetchSuggestions(inputVal)}
|
onfocus={() => fetchSuggestions(inputVal)}
|
||||||
placeholder={tags.length === 0
|
placeholder={tags.length === 0
|
||||||
? allowCreation
|
? allowCreation
|
||||||
? m.comp_taginput_placeholder_create()
|
? m.comp_taginput_placeholder_create()
|
||||||
: m.comp_taginput_placeholder_filter()
|
: m.comp_taginput_placeholder_filter()
|
||||||
: ''}
|
: ''}
|
||||||
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
class="h-full w-full border-none bg-transparent p-1 text-sm outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Typeahead Dropdown -->
|
<!-- Typeahead Dropdown -->
|
||||||
{#if showSuggestions && orderedSuggestions.length > 0}
|
{#if showSuggestions && orderedSuggestions.length > 0}
|
||||||
<ul
|
<ul
|
||||||
|
role="listbox"
|
||||||
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
class="absolute top-full left-0 z-50 mt-1 max-h-48 w-full overflow-y-auto rounded border border-line bg-surface shadow-lg"
|
||||||
>
|
>
|
||||||
{#each orderedSuggestions as suggestion, i (suggestion.id ?? suggestion.name)}
|
{#each orderedSuggestions as s, i (s.tag.id ?? s.tag.name)}
|
||||||
<li
|
<li
|
||||||
role="option"
|
role="option"
|
||||||
aria-selected={i === activeIndex}
|
aria-selected={i === activeIndex}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="cursor-pointer px-3 py-2 text-sm hover:bg-muted {i === activeIndex
|
style="padding-left: {s.depth * 16 + 12}px"
|
||||||
? 'bg-muted font-bold text-ink'
|
class="cursor-pointer py-3 pr-3 text-sm hover:bg-muted {i === activeIndex
|
||||||
: 'text-ink-2'} {suggestion.parentId && suggestionsById.has(suggestion.parentId) ? 'pl-6' : ''}"
|
? 'bg-muted font-bold text-ink'
|
||||||
onclick={() => addTag(suggestion)}
|
: s.isDirectMatch
|
||||||
onkeydown={(e) => e.key === 'Enter' && addTag(suggestion)}
|
? 'font-medium text-ink-2'
|
||||||
|
: 'text-ink-3'}"
|
||||||
|
onclick={() => addTag(s.tag)}
|
||||||
|
onkeydown={(e) => e.key === 'Enter' && addTag(s.tag)}
|
||||||
>
|
>
|
||||||
{suggestion.name}
|
<!-- › only on root context ancestors; depth>0 nodes rely on indentation instead -->
|
||||||
|
{#if !s.isDirectMatch && s.depth === 0}
|
||||||
|
<span class="mr-1" aria-hidden="true">›</span>
|
||||||
|
{/if}
|
||||||
|
{#if s.tag.color}
|
||||||
|
<span
|
||||||
|
style="background-color: var(--c-tag-{s.tag.color})"
|
||||||
|
class="mr-1 inline-block h-2 w-2 flex-shrink-0 rounded-full {s.isDirectMatch
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-50'}"
|
||||||
|
></span>
|
||||||
|
{/if}
|
||||||
|
{s.tag.name}
|
||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||||
import { cleanup, render } from 'vitest-browser-svelte';
|
import { cleanup, render } from 'vitest-browser-svelte';
|
||||||
import { page, userEvent } from 'vitest/browser';
|
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));
|
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[]) {
|
function mockFetchWithTags(tagNames: string[]) {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
@@ -158,7 +168,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fa');
|
await input.fill('Fa');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||||
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
|
await page.screenshot({ path: 'test-results/screenshots/tag-input-suggestions.png' });
|
||||||
@@ -169,7 +179,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('F');
|
await input.fill('F');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
expect(fetch).not.toHaveBeenCalled();
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,7 +188,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
render(TagInput, { tags: [{ name: 'Familie' }], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fr');
|
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: 'Familie' })).not.toBeInTheDocument();
|
||||||
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Freunde' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -188,7 +198,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fa');
|
await input.fill('Fa');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
document.querySelector<HTMLElement>('[role="option"]')!.click();
|
||||||
await tick();
|
await tick();
|
||||||
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
await expect.element(page.getByText('Familie')).toBeInTheDocument();
|
||||||
@@ -201,7 +211,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('__');
|
await input.fill('__');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
|
await userEvent.keyboard('{ArrowDown}'); // index 0 → Aachen
|
||||||
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
|
await userEvent.keyboard('{ArrowDown}'); // index 1 → Berlin
|
||||||
await userEvent.keyboard('{Enter}');
|
await userEvent.keyboard('{Enter}');
|
||||||
@@ -213,7 +223,7 @@ describe('TagInput – autocomplete', () => {
|
|||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Fa');
|
await input.fill('Fa');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
await expect.element(page.getByRole('option', { name: 'Familie' })).toBeInTheDocument();
|
||||||
document.body.click();
|
document.body.click();
|
||||||
await tick();
|
await tick();
|
||||||
@@ -221,27 +231,114 @@ describe('TagInput – autocomplete', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('shows child suggestion after its parent when both are in results', async () => {
|
it('shows child suggestion after its parent when both are in results', async () => {
|
||||||
vi.stubGlobal(
|
mockFetchWithTagObjects([
|
||||||
'fetch',
|
{ id: 'p1', name: 'Eltern' },
|
||||||
vi.fn().mockResolvedValue({
|
{ id: 'c1', name: 'Kind', parentId: 'p1' }
|
||||||
ok: true,
|
]);
|
||||||
json: vi.fn().mockResolvedValue([
|
|
||||||
{ id: 'p1', name: 'Eltern' },
|
|
||||||
{ id: 'c1', name: 'Kind', parentId: 'p1' }
|
|
||||||
])
|
|
||||||
})
|
|
||||||
);
|
|
||||||
render(TagInput, { tags: [], allowCreation: true });
|
render(TagInput, { tags: [], allowCreation: true });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('El');
|
await input.fill('El');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
const options = document.querySelectorAll('[role="option"]');
|
const options = Array.from(document.querySelectorAll('[role="option"]'));
|
||||||
const names = Array.from(options).map((el) => el.textContent?.trim());
|
const texts = options.map((el) => el.textContent?.replace(/›/g, '').trim() ?? '');
|
||||||
const elternIdx = names.indexOf('Eltern');
|
expect(texts.indexOf('Eltern')).toBeLessThan(texts.indexOf('Kind'));
|
||||||
const kindIdx = names.indexOf('Kind');
|
// direct match (Eltern contains 'El') has font-medium; context child does not
|
||||||
expect(elternIdx).toBeGreaterThanOrEqual(0);
|
const elternEl = options.find((el) => el.textContent?.replace(/›/g, '').trim() === 'Eltern');
|
||||||
expect(kindIdx).toBeGreaterThanOrEqual(0);
|
expect(elternEl?.classList.contains('font-medium')).toBe(true);
|
||||||
expect(elternIdx).toBeLessThan(kindIdx);
|
});
|
||||||
|
|
||||||
|
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 });
|
render(TagInput, { tags: [], allowCreation: false, onTextInput });
|
||||||
const input = page.getByRole('textbox');
|
const input = page.getByRole('textbox');
|
||||||
await input.fill('Ka');
|
await input.fill('Ka');
|
||||||
await waitForDebounce();
|
await waitForFetch();
|
||||||
const option = page.getByRole('option', { name: 'Kaufvertrag' });
|
const option = page.getByRole('option', { name: 'Kaufvertrag' });
|
||||||
await option.click();
|
await option.click();
|
||||||
await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);
|
await expect.poll(() => onTextInput.mock.calls.at(-1)).toEqual(['']);
|
||||||
|
|||||||
Reference in New Issue
Block a user