diff --git a/frontend/src/routes/SearchFilterBar.svelte b/frontend/src/routes/SearchFilterBar.svelte index f9441d41..9786bec6 100644 --- a/frontend/src/routes/SearchFilterBar.svelte +++ b/frontend/src/routes/SearchFilterBar.svelte @@ -14,6 +14,7 @@ let { receiverId = $bindable(''), tagNames = $bindable<{ name: string; id?: string; color?: string; parentId?: string }[]>([]), tagQ = $bindable(''), + tagOperator = $bindable<'AND' | 'OR'>('AND'), sort = $bindable('DATE'), dir = $bindable('desc'), showAdvanced = $bindable(false), @@ -31,6 +32,7 @@ let { receiverId?: string; tagNames?: { name: string; id?: string; color?: string; parentId?: string }[]; tagQ?: string; + tagOperator?: 'AND' | 'OR'; sort?: string; dir?: string; showAdvanced?: boolean; @@ -153,6 +155,26 @@ $effect(() => { onSearch(); }} /> + {#if tagNames.length >= 2} +
+ + +
+ {/if} diff --git a/frontend/src/routes/SearchFilterBar.svelte.spec.ts b/frontend/src/routes/SearchFilterBar.svelte.spec.ts index c3f8a454..86075e09 100644 --- a/frontend/src/routes/SearchFilterBar.svelte.spec.ts +++ b/frontend/src/routes/SearchFilterBar.svelte.spec.ts @@ -1,8 +1,10 @@ -import { describe, expect, it, vi } from 'vitest'; -import { render } from 'vitest-browser-svelte'; +import { describe, expect, it, vi, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import SearchFilterBar from './SearchFilterBar.svelte'; +afterEach(() => cleanup()); + const defaultProps = { onSearch: vi.fn() }; @@ -41,6 +43,68 @@ describe('SearchFilterBar – loading spinner', () => { }); }); +describe('SearchFilterBar – AND/OR tag operator toggle', () => { + async function openAdvanced() { + const filterBtn = page.getByRole('button', { name: 'Filter', exact: true }); + await filterBtn.click(); + } + + it('hides AND/OR toggle when fewer than 2 tags are selected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }] + }); + await openAdvanced(); + await expect.element(page.getByRole('button', { name: 'AND' })).not.toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + + it('shows AND/OR toggle when 2+ tags are selected', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + render(SearchFilterBar, { + ...defaultProps, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }] + }); + await openAdvanced(); + const toggle = page.getByTestId('and-or-toggle'); + await expect.element(toggle).toBeInTheDocument(); + await expect.element(toggle.getByRole('button', { name: 'AND' })).toBeInTheDocument(); + await expect.element(toggle.getByRole('button', { name: 'OR' })).toBeInTheDocument(); + vi.unstubAllGlobals(); + }); + + it('calls onSearch when operator is toggled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) }) + ); + const onSearch = vi.fn(); + render(SearchFilterBar, { + ...defaultProps, + onSearch, + sort: 'DATE', + dir: 'desc', + tagNames: [{ name: 'Tag1' }, { name: 'Tag2' }] + }); + await openAdvanced(); + const toggle = page.getByTestId('and-or-toggle'); + await toggle.getByRole('button', { name: 'OR' }).click(); + await expect.poll(() => onSearch.mock.calls.length).toBeGreaterThan(0); + vi.unstubAllGlobals(); + }); +}); + describe('SearchFilterBar – tagQ live filter', () => { it('calls onSearch when tag text changes in TagInput', async () => { vi.stubGlobal(