feat(search): wire theme chip removal to URL navigation in +page.svelte

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-06-06 23:40:33 +02:00
committed by marcel
parent 87fd0f39bb
commit 2c909f49a8
4 changed files with 146 additions and 1 deletions

View File

@@ -58,9 +58,10 @@ test.describe('NL (smart) search — happy path', () => {
// Loading panel announced to screen readers.
await expect(page.getByText(/Archiv wird befragt/)).toBeVisible();
// Directional chip (Walter → Emma) + keyword chip render once the fixture resolves.
// Directional chip (Walter → Emma) + keyword chip + theme chip render once the fixture resolves.
await expect(page.getByText('→')).toBeVisible();
await expect(page.getByText('Stichwort: krieg')).toBeVisible();
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
// Accessibility — light mode.
const lightScan = await new AxeBuilder({ page })
@@ -82,4 +83,31 @@ test.describe('NL (smart) search — happy path', () => {
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
await expect(page).toHaveURL(/receiverId=22222222-2222-2222-2222-222222222222/);
});
test('removing the last theme chip drops tag/tagOp but keeps person params', async ({ page }) => {
await page.route('**/api/search/nl', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(nlResponse)
});
});
await page.goto('/documents');
await page.waitForSelector('[data-hydrated]');
await page.getByRole('button', { name: /Text/ }).click();
const input = page.getByPlaceholder('Titel, Personen, Tags durchsuchen…');
await input.fill('Was hat Walter an Emma im Krieg geschrieben?');
await input.press('Enter');
await expect(page.getByText(/Thema:.*Weltkrieg/)).toBeVisible();
// Remove the single theme chip — URL must carry sender UUID but no tag/tagOp.
await page.getByRole('button', { name: 'Filter entfernen: Thema: Weltkrieg' }).click();
await page.waitForURL(/senderId=11111111-1111-1111-1111-111111111111/);
const url = page.url();
expect(url).not.toMatch(/tag=/);
expect(url).not.toMatch(/tagOp=/);
});
});

View File

@@ -11,6 +11,7 @@ import TimelineDensityFilter from '$lib/document/TimelineDensityFilter.svelte';
import SmartSearchStatus from '../search/SmartSearchStatus.svelte';
import InterpretationChipRow from '../search/InterpretationChipRow.svelte';
import type { ChipType } from '../search/chip-types.js';
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
import DisambiguationPicker from '../search/DisambiguationPicker.svelte';
import { bulkSelectionStore } from '$lib/document/bulkSelection.svelte';
import { getErrorMessage, parseBackendError } from '$lib/shared/errors';
@@ -284,6 +285,11 @@ function removeChip(type: ChipType, value?: string) {
} else if (type === 'keyword' && value) {
const remaining = nlInterpretation.keywords.filter((keyword) => keyword !== value);
p.q = remaining.join(' ');
} else if (type === 'theme' && value) {
const url = buildThemeRemovalUrl(nlInterpretation, value);
resetNlState();
goto(url, { keepFocus: true, noScroll: true });
return;
}
applyResolvedAndSearch(p);
}

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { buildThemeRemovalUrl } from './theme-chip-removal.js';
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
function makeInterp(overrides: Partial<NlQueryInterpretation> = {}): NlQueryInterpretation {
return {
resolvedPersons: [],
ambiguousPersons: [],
keywords: [],
resolvedTags: [],
rawQuery: '',
keywordsApplied: false,
tagsApplied: true,
...overrides
};
}
function makeTag(id: string, name: string, color?: string) {
return color ? { id, name, color } : { id, name };
}
describe('buildThemeRemovalUrl', () => {
it('N remaining tags → N tag params + tagOp=OR', () => {
const interp = makeInterp({
resolvedTags: [
makeTag('aaa', 'Hochzeit'),
makeTag('bbb', 'Weltkrieg'),
makeTag('ccc', 'Familie')
]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual(['Weltkrieg', 'Familie']);
expect(params.get('tagOp')).toBe('OR');
});
it('last tag removed → no tag or tagOp params in URL', () => {
const interp = makeInterp({
resolvedTags: [makeTag('aaa', 'Hochzeit')]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual([]);
expect(params.get('tagOp')).toBeNull();
});
it('last tag removed with resolved sender person → sender param intact', () => {
const interp = makeInterp({
resolvedPersons: [{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' }],
resolvedTags: [makeTag('aaa', 'Hochzeit')]
});
const url = buildThemeRemovalUrl(interp, 'Hochzeit');
const params = new URL(url, 'http://x').searchParams;
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
expect(params.getAll('tag')).toEqual([]);
expect(params.get('tagOp')).toBeNull();
});
it('null-color tag → tag name emitted correctly; color does not affect params', () => {
const interp = makeInterp({
resolvedTags: [makeTag('aaa', 'Erbschaft'), makeTag('bbb', 'Migration')]
});
const url = buildThemeRemovalUrl(interp, 'Erbschaft');
const params = new URL(url, 'http://x').searchParams;
expect(params.getAll('tag')).toEqual(['Migration']);
expect(params.get('tagOp')).toBe('OR');
});
it('directional pair → senderId and receiverId both emitted', () => {
const interp = makeInterp({
resolvedPersons: [
{ id: '11111111-1111-1111-1111-111111111111', displayName: 'Walter' },
{ id: '22222222-2222-2222-2222-222222222222', displayName: 'Emma' }
],
resolvedTags: [makeTag('aaa', 'Krieg'), makeTag('bbb', 'Heimat')]
});
const url = buildThemeRemovalUrl(interp, 'Krieg');
const params = new URL(url, 'http://x').searchParams;
expect(params.get('senderId')).toBe('11111111-1111-1111-1111-111111111111');
expect(params.get('receiverId')).toBe('22222222-2222-2222-2222-222222222222');
expect(params.getAll('tag')).toEqual(['Heimat']);
});
});

View File

@@ -0,0 +1,26 @@
import type { components } from '$lib/generated/api';
type NlQueryInterpretation = components['schemas']['NlQueryInterpretation'];
export function buildThemeRemovalUrl(
interp: NlQueryInterpretation,
removedTagName: string
): string {
const remaining = interp.resolvedTags.filter((t) => t.name !== removedTagName);
const params = new URLSearchParams();
const resolved = interp.resolvedPersons;
if (resolved.length >= 1) params.set('senderId', resolved[0].id);
if (resolved.length >= 2) params.set('receiverId', resolved[1].id);
if (interp.dateFrom) params.set('from', interp.dateFrom);
if (interp.dateTo) params.set('to', interp.dateTo);
if (interp.keywordsApplied && interp.keywords.length > 0) {
params.set('q', interp.keywords.join(' '));
}
remaining.forEach((tag) => params.append('tag', tag.name));
if (remaining.length > 0) params.set('tagOp', 'OR');
const qs = params.toString();
return qs ? `/documents?${qs}` : '/documents';
}