diff --git a/frontend/e2e/nl-search.spec.ts b/frontend/e2e/nl-search.spec.ts index 65ede99f..210ad173 100644 --- a/frontend/e2e/nl-search.spec.ts +++ b/frontend/e2e/nl-search.spec.ts @@ -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=/); + }); }); diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index 5481e729..7e836964 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -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); } diff --git a/frontend/src/routes/documents/theme-chip-removal.spec.ts b/frontend/src/routes/documents/theme-chip-removal.spec.ts new file mode 100644 index 00000000..f39edec0 --- /dev/null +++ b/frontend/src/routes/documents/theme-chip-removal.spec.ts @@ -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 { + 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']); + }); +}); diff --git a/frontend/src/routes/documents/theme-chip-removal.ts b/frontend/src/routes/documents/theme-chip-removal.ts new file mode 100644 index 00000000..21e7a511 --- /dev/null +++ b/frontend/src/routes/documents/theme-chip-removal.ts @@ -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'; +}