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:
@@ -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=/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
85
frontend/src/routes/documents/theme-chip-removal.spec.ts
Normal file
85
frontend/src/routes/documents/theme-chip-removal.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
26
frontend/src/routes/documents/theme-chip-removal.ts
Normal file
26
frontend/src/routes/documents/theme-chip-removal.ts
Normal 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';
|
||||
}
|
||||
Reference in New Issue
Block a user