From 098c2c9defb4659416e07c015df8027ebaa57bb3 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 27 May 2026 18:53:44 +0200 Subject: [PATCH] feat(documents): add a "Nur undatierte" filter toggle wired to the URL SearchFilterBar gains an aria-pressed "Nur undatierte" toggle in the advanced row (min-h-[44px] touch target, labels the state not the colour). The documents page threads `undated` through the filter snapshot so it is a shareable URL param picked up by both filter-change nav and pagination, and flows into the bulk-edit "select all" /ids request. Toggling resets to page 0 via the existing implicit page-drop. Refs #668 --- frontend/src/routes/SearchFilterBar.svelte | 29 +++++++++++++++ .../src/routes/SearchFilterBar.svelte.spec.ts | 36 +++++++++++++++++++ frontend/src/routes/documents/+page.svelte | 19 ++++++++-- 3 files changed, 81 insertions(+), 3 deletions(-) diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index 9ad11f39..344d6f26 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -15,6 +15,7 @@ let { tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]), tagQ = $bindable(''), tagOperator = $bindable<'AND' | 'OR'>('AND'), + undated = $bindable(false), sort = $bindable('DATE'), dir = $bindable('desc'), showAdvanced = $bindable(false), @@ -35,6 +36,7 @@ let { tagNames?: { name: string; id?: string; color?: string; parentId?: string }[]; tagQ?: string; tagOperator?: 'AND' | 'OR'; + undated?: boolean; sort?: string; dir?: string; showAdvanced?: boolean; @@ -248,6 +250,33 @@ $effect(() => { /> + + +
+ +
{/if} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index 26d1d333..11f4c32d 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -128,6 +128,42 @@ describe('SearchFilterBar – AND/OR tag operator toggle', () => { }); }); +describe('SearchFilterBar – undated-only toggle (#668)', () => { + async function openAdvanced() { + const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); + await filterBtn.click(); + } + + it('renders the "Nur undatierte" toggle in the advanced row', async () => { + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc' }); + await openAdvanced(); + await expect.element(page.getByTestId('undated-only-toggle')).toBeInTheDocument(); + }); + + it('reflects the active undated state via aria-pressed', async () => { + render(SearchFilterBar, { ...defaultProps, sort: 'DATE', dir: 'desc', undated: true }); + await openAdvanced(); + await expect + .element(page.getByTestId('undated-only-toggle')) + .toHaveAttribute('aria-pressed', 'true'); + }); + + it('calls onSearchImmediate when the undated toggle is clicked', async () => { + const onSearch = vi.fn(); + const onSearchImmediate = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + onSearch, + onSearchImmediate, + sort: 'DATE', + dir: 'desc' + }); + await openAdvanced(); + await page.getByTestId('undated-only-toggle').click(); + await expect.poll(() => onSearchImmediate.mock.calls.length).toBeGreaterThan(0); + }); +}); + describe('SearchFilterBar – tagQ live filter', () => { it('calls onSearch when tag text changes in TagInput', async () => { vi.stubGlobal( diff --git a/frontend/src/routes/documents/+page.svelte b/frontend/src/routes/documents/+page.svelte index f938334a..eabfea8c 100644 --- a/frontend/src/routes/documents/+page.svelte +++ b/frontend/src/routes/documents/+page.svelte @@ -32,10 +32,16 @@ let sort = $state(untrack(() => data.sort || 'DATE')); let dir = $state(untrack(() => data.dir || 'desc')); let tagQ = $state(untrack(() => data.tagQ || '')); let tagOperator = $state<'AND' | 'OR'>(untrack(() => (data.tagOp as 'AND' | 'OR') || 'AND')); +let undated = $state(untrack(() => data.undated ?? false)); function hasAdvancedFilters() { return ( - (data.tags?.length ?? 0) > 0 || !!data.senderId || !!data.receiverId || !!data.from || !!data.to + (data.tags?.length ?? 0) > 0 || + !!data.senderId || + !!data.receiverId || + !!data.from || + !!data.to || + !!data.undated ); } @@ -54,6 +60,7 @@ type FilterSnapshot = { dir: string; tagQ: string; tagOp: 'AND' | 'OR'; + undated: boolean; zoomFrom?: string | null; zoomTo?: string | null; }; @@ -77,6 +84,7 @@ function buildSearchParams(filters: FilterSnapshot, targetPage?: number): Svelte if (filters.dir) params.set('dir', filters.dir); if (filters.tagQ) params.set('tagQ', filters.tagQ); if (filters.tagOp === 'OR') params.set('tagOp', 'OR'); + if (filters.undated) params.set('undated', 'true'); if (filters.zoomFrom) params.set('zoomFrom', filters.zoomFrom); if (filters.zoomTo) params.set('zoomTo', filters.zoomTo); if (targetPage !== undefined && targetPage > 0) params.set('page', String(targetPage)); @@ -112,6 +120,7 @@ function navigateWithZoom(zoomFrom: string | null, zoomTo: string | null) { dir, tagQ, tagOp: tagOperator, + undated, zoomFrom, zoomTo }); @@ -136,7 +145,8 @@ function buildPageHref(targetPage: number): string { sort: data.sort || '', dir: data.dir || '', tagQ: data.tagQ || '', - tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND', + undated: data.undated ?? false }, targetPage ); @@ -188,7 +198,8 @@ async function editAllMatching() { sort: '', dir: '', tagQ: data.tagQ || '', - tagOp: (data.tagOp as 'AND' | 'OR') || 'AND' + tagOp: (data.tagOp as 'AND' | 'OR') || 'AND', + undated: data.undated ?? false }); params.delete('sort'); params.delete('dir'); @@ -226,6 +237,7 @@ $effect(() => { dir = data.dir || 'desc'; tagQ = data.tagQ || ''; tagOperator = (data.tagOp as 'AND' | 'OR') || 'AND'; + undated = data.undated ?? false; if (hasAdvancedFilters()) showAdvanced = true; }); @@ -255,6 +267,7 @@ $effect(() => { bind:dir={dir} bind:tagQ={tagQ} bind:tagOperator={tagOperator} + bind:undated={undated} initialSenderName={initialSenderName} initialReceiverName={initialReceiverName} navKey={navKey}