feat(frontend): merge top-bar search with the filter bar, expose kind/created filters
Unify the upstream top-bar search box and the local SearchFilterBar into one filtering capability sharing a single state store, with the URL as its projection: - lib/stores/filter.svelte.ts: module-level $state store, the single source of truth; every surface mutates the same proxy in place - lib/composables/useFilterUrlSync.svelte.ts: one $effect serves both directions (URL -> store adoption, store -> URL replaceState write) with echo suppression so the loop settles in one round-trip - FilterFields.svelte: the five selects (type/size/modified/created/ kind) + recursive switch; word lists and i18n labels live in one place; SearchFilterBar reuses it instead of its own copies - TopBarFilterPanel.svelte: dropdown panel anchored inside the top-bar search input (in-field toggle button with active-count badge); Escape/outside-click close, no overlay stacking with suggestions - FilterChips.svelte: dismissible chips on /search, which is now URL-driven and loses its three inline <select>s - New dimensions the backend already supported: kind (file/folder, resource_types) and created (created_after); URL keys q/type/size/ date/created/kind/recursive, non-defaults only, unknown values degrade to defaults - /files keeps in-place filtering (no navigation); folderTarget carries filter params through folder entry; ?file= and in/scope survive filter writes - topbar.css: input right padding reserves the three in-field controls Backend unchanged. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -321,3 +321,41 @@ it('shows the search result count while the filter is active', async () => {
|
||||
const stat = await screen.findByTestId('files-folder-stat');
|
||||
expect(stat.textContent).toContain('1 results');
|
||||
});
|
||||
|
||||
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
|
||||
withListing();
|
||||
pageState.url = new URL('http://localhost/files/home?type=image&kind=folder');
|
||||
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||
expect(m(searchResources).mock.calls[0][1]).toMatchObject({
|
||||
folderId: 'home',
|
||||
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
|
||||
resourceTypes: ['folder']
|
||||
});
|
||||
// The bar's own select reflects the hydrated state (expand the row first).
|
||||
await fireEvent.click(screen.getByTestId('filter-advanced-toggle-btn'));
|
||||
expect((screen.getByTestId('filter-type-select') as HTMLSelectElement).value).toBe('image');
|
||||
});
|
||||
|
||||
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
|
||||
withListing();
|
||||
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
||||
render(FilesPage);
|
||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||
m(goto).mockClear();
|
||||
vi.useFakeTimers();
|
||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
||||
target: { value: 'hello' }
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
vi.useRealTimers();
|
||||
// One replaceState write for the debounced keyword — and nothing else:
|
||||
// the effect must recognize its own write and settle, not ping-pong.
|
||||
expect(goto).toHaveBeenCalledTimes(1);
|
||||
// The sync composable passes a path/search/hash string (a URL instance
|
||||
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
|
||||
const written = m(goto).mock.calls[0][0] as string;
|
||||
expect(written).toContain('q=hello');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user