feat(search): render removable theme chips in InterpretationChipRow
When tagsApplied is true, each resolvedTag renders as a 'Thema: Name'
chip with optional inline color style from the tag's resolved color.
Clicking × calls onRemoveChip('theme', tag.name).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api';
|
|||||||
import type { ChipType } from './chip-types.js';
|
import type { ChipType } from './chip-types.js';
|
||||||
|
|
||||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||||
|
type TagHint = components['schemas']['TagHint'];
|
||||||
|
|
||||||
let {
|
let {
|
||||||
interpretation,
|
interpretation,
|
||||||
@@ -19,7 +20,8 @@ type Chip =
|
|||||||
| { key: string; type: 'sender'; label: string }
|
| { key: string; type: 'sender'; label: string }
|
||||||
| { key: string; type: 'directional'; from: string; to: string }
|
| { key: string; type: 'directional'; from: string; to: string }
|
||||||
| { key: string; type: 'date'; label: string }
|
| { key: string; type: 'date'; label: string }
|
||||||
| { key: string; type: 'keyword'; value: string; label: string };
|
| { key: string; type: 'keyword'; value: string; label: string }
|
||||||
|
| { key: string; type: 'theme'; tag: TagHint; label: string };
|
||||||
|
|
||||||
// Locally removed chips. The parent remounts this component (via {#key}) on every
|
// Locally removed chips. The parent remounts this component (via {#key}) on every
|
||||||
// new NL search, so this set never needs an explicit reset.
|
// new NL search, so this set never needs an explicit reset.
|
||||||
@@ -36,9 +38,22 @@ function dateRangeLabel(from: string | undefined, to: string | undefined): strin
|
|||||||
return fromYear ?? toYear ?? '';
|
return fromYear ?? toYear ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tagColorStyle(color: string | undefined): string | undefined {
|
||||||
|
if (!color) return undefined;
|
||||||
|
return `background-color: var(--c-tag-${color}); border-left-color: var(--c-tag-${color})`;
|
||||||
|
}
|
||||||
|
|
||||||
const chips = $derived.by(() => {
|
const chips = $derived.by(() => {
|
||||||
const list: Chip[] = [];
|
const list: Chip[] = [];
|
||||||
const { resolvedPersons, dateFrom, dateTo, keywords, keywordsApplied } = interpretation;
|
const {
|
||||||
|
resolvedPersons,
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
keywords,
|
||||||
|
keywordsApplied,
|
||||||
|
resolvedTags,
|
||||||
|
tagsApplied
|
||||||
|
} = interpretation;
|
||||||
|
|
||||||
if (resolvedPersons.length >= 2) {
|
if (resolvedPersons.length >= 2) {
|
||||||
list.push({
|
list.push({
|
||||||
@@ -74,6 +89,17 @@ const chips = $derived.by(() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tagsApplied) {
|
||||||
|
for (const tag of resolvedTags) {
|
||||||
|
list.push({
|
||||||
|
key: 'theme:' + tag.id,
|
||||||
|
type: 'theme',
|
||||||
|
tag,
|
||||||
|
label: `${m.search_chip_theme_prefix()}: ${tag.name}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return list.filter((chip) => !removed.has(chip.key));
|
return list.filter((chip) => !removed.has(chip.key));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +109,13 @@ const showKeywordsNotApplied = $derived(
|
|||||||
|
|
||||||
function remove(chip: Chip) {
|
function remove(chip: Chip) {
|
||||||
removed.add(chip.key);
|
removed.add(chip.key);
|
||||||
onRemoveChip(chip.type, chip.type === 'keyword' ? chip.value : undefined);
|
if (chip.type === 'keyword') {
|
||||||
|
onRemoveChip(chip.type, chip.value);
|
||||||
|
} else if (chip.type === 'theme') {
|
||||||
|
onRemoveChip(chip.type, chip.tag.name);
|
||||||
|
} else {
|
||||||
|
onRemoveChip(chip.type, undefined);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
|
const nameSpan = 'sm:max-w-[12rem] max-w-[8rem] truncate';
|
||||||
@@ -113,6 +145,21 @@ const removeButton =
|
|||||||
<span aria-hidden="true">×</span>
|
<span aria-hidden="true">×</span>
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
|
{:else if chip.type === 'theme'}
|
||||||
|
<span data-chip-type="theme" class={chipWrapper} style={tagColorStyle(chip.tag.color)}>
|
||||||
|
<span>{m.search_chip_theme_prefix()}:</span>
|
||||||
|
<span class={nameSpan}>{chip.tag.name}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={removeButton}
|
||||||
|
aria-label={m.search_filter_remove_label({
|
||||||
|
label: `${m.search_chip_theme_prefix()}: ${chip.tag.name}`
|
||||||
|
})}
|
||||||
|
onclick={() => remove(chip)}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span data-chip-type={chip.type} class={chipWrapper}>
|
<span data-chip-type={chip.type} class={chipWrapper}>
|
||||||
<span class={nameSpan}>{chip.label}</span>
|
<span class={nameSpan}>{chip.label}</span>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { components } from '$lib/generated/api';
|
|||||||
|
|
||||||
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
|
||||||
type PersonHint = components['schemas']['PersonHint'];
|
type PersonHint = components['schemas']['PersonHint'];
|
||||||
|
type TagHint = components['schemas']['TagHint'];
|
||||||
|
|
||||||
afterEach(() => cleanup());
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
@@ -132,4 +133,79 @@ describe('InterpretationChipRow', () => {
|
|||||||
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
.element(page.getByRole('button', { name: new RegExp('Absender') }))
|
||||||
.toBeInTheDocument();
|
.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── theme chips ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const makeTag = (id: string, name: string, color?: string): TagHint => ({ id, name, color });
|
||||||
|
|
||||||
|
it('renders theme chips when tagsApplied is true', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||||
|
tagsApplied: true
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(1);
|
||||||
|
await expect.element(page.getByText(/Thema: Hochzeit/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no theme chips when tagsApplied is false', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||||
|
tagsApplied: false
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders exactly N theme chips for N resolved tags', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Krieg'), makeTag('t2', 'Hochzeit'), makeTag('t3', 'Familie')],
|
||||||
|
tagsApplied: true
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
expect(container.querySelectorAll('[data-chip-type="theme"]')).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRemoveChip with "theme" and tag name when × is clicked', async () => {
|
||||||
|
const onRemoveChip = vi.fn();
|
||||||
|
render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||||
|
tagsApplied: true
|
||||||
|
}),
|
||||||
|
onRemoveChip
|
||||||
|
});
|
||||||
|
await page.getByRole('button', { name: /Thema: Hochzeit/ }).click();
|
||||||
|
expect(onRemoveChip).toHaveBeenCalledWith('theme', 'Hochzeit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies inline color style for a tag with a color', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Hochzeit', 'sage')],
|
||||||
|
tagsApplied: true
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||||
|
expect(chip.style.backgroundColor).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits color style for a tag with no color', async () => {
|
||||||
|
const { container } = render(InterpretationChipRow, {
|
||||||
|
interpretation: makeInterpretation({
|
||||||
|
resolvedTags: [makeTag('t1', 'Hochzeit')],
|
||||||
|
tagsApplied: true
|
||||||
|
}),
|
||||||
|
onRemoveChip: vi.fn()
|
||||||
|
});
|
||||||
|
const chip = container.querySelector('[data-chip-type="theme"]') as HTMLElement;
|
||||||
|
expect(chip.getAttribute('style')).toBeFalsy();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user