refactor(frontend): remove the files-page filter bar; top bar owns filtering
The top bar's unified search box + filter panel (29d0c335) already drives
the same shared filter store, so the files-page SearchFilterBar had become
a second, redundant input. Remove the component and its mount; the shared
store, URL sync and the files page's in-place search mode all stay — the
top bar is now the only filter writer. Drop the orphaned locale keys
(filter.placeholder/keyword/clear_keyword) from all 16 locales and rework
the affected tests to drive the store directly, with a resetFilters() +
URL reset in beforeEach to keep the module-level store from leaking.
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -400,7 +400,7 @@
|
|||||||
let suggestBusy = $state(false);
|
let suggestBusy = $state(false);
|
||||||
// Live count of active filter presets — drives the toggle button's badge
|
// Live count of active filter presets — drives the toggle button's badge
|
||||||
// and its "filters applied" highlight. The shared store is also what the
|
// and its "filters applied" highlight. The shared store is also what the
|
||||||
// panel (below) and the files page's filter bar mutate.
|
// panel (below) and the /search page mutate.
|
||||||
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
|
const filterActiveCount = $derived(activeFilterCount(resourceFilters));
|
||||||
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
let suggestTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
// Stale-response guard (same family as the search page): the debounce
|
// Stale-response guard (same family as the search page): the debounce
|
||||||
@@ -420,7 +420,7 @@
|
|||||||
// variant; these params don't need reactivity anyway.
|
// variant; these params don't need reactivity anyway.
|
||||||
const parts = [`q=${encodeURIComponent(q)}`];
|
const parts = [`q=${encodeURIComponent(q)}`];
|
||||||
// Active filter presets ride along (`type=image&kind=file&…`) — the
|
// Active filter presets ride along (`type=image&kind=file&…`) — the
|
||||||
// shared store is what the top-bar panel and the files page's bar
|
// shared store is what the top-bar panel and the /search page
|
||||||
// mutate, so submitting from here carries exactly what the user sees.
|
// mutate, so submitting from here carries exactly what the user sees.
|
||||||
const filterQs = filterParamsToString(resourceFilters);
|
const filterQs = filterParamsToString(resourceFilters);
|
||||||
if (filterQs) parts.push(filterQs);
|
if (filterQs) parts.push(filterQs);
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
The filter dimensions themselves (type / size / modified / created / kind /
|
The filter dimensions themselves (type / size / modified / created / kind /
|
||||||
recursive), bound to the shared `ResourceFilterState`.
|
recursive), bound to the shared `ResourceFilterState`.
|
||||||
|
|
||||||
Extracted so the files page's filter bar and the top bar's filter panel
|
Extracted so the top bar's filter panel renders the same fields off the
|
||||||
render the same fields off the same vocabulary — the option lists used to
|
same vocabulary the /search page chips name — the option lists used to
|
||||||
live in two copies (SearchFilterBar and the /search page). The vocabularies
|
live in several copies. The vocabularies (extensions, byte bounds, date
|
||||||
(extensions, byte bounds, date presets) stay in `$lib/utils/searchFilters`;
|
presets) stay in `$lib/utils/searchFilters`; only the i18n labels live
|
||||||
only the i18n labels live here.
|
here.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
import { t } from '$lib/i18n/index.svelte';
|
||||||
|
|||||||
@@ -1,266 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import FilterFields from '$lib/components/FilterFields.svelte';
|
|
||||||
import Icon from '$lib/icons/Icon.svelte';
|
|
||||||
import { t } from '$lib/i18n/index.svelte';
|
|
||||||
import {
|
|
||||||
activeFilterCount,
|
|
||||||
defaultFilterState,
|
|
||||||
type ResourceFilterState
|
|
||||||
} from '$lib/utils/searchFilters';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
/**
|
|
||||||
* Full filter state (keyword + toggles + presets), mutated in place —
|
|
||||||
* in practice the shared store proxy from `$lib/stores/filter.svelte`.
|
|
||||||
* Deliberately NOT `$bindable`: there is exactly one state object and
|
|
||||||
* nobody reassigns it, so a two-way binding contract would be noise.
|
|
||||||
*/
|
|
||||||
value?: ResourceFilterState;
|
|
||||||
/** Advanced section (presets + recursive) expanded. */
|
|
||||||
expanded?: boolean;
|
|
||||||
placeholder?: string;
|
|
||||||
/** Debounce for the keyword input (ms). */
|
|
||||||
debounceMs?: number;
|
|
||||||
/** Hide the recursive toggle (a surface where scope is owned elsewhere). */
|
|
||||||
hideRecursive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let {
|
|
||||||
value = defaultFilterState(),
|
|
||||||
expanded = $bindable(false),
|
|
||||||
placeholder = t('filter.placeholder', 'Search this folder and subfolders…'),
|
|
||||||
debounceMs = 300,
|
|
||||||
hideRecursive = false
|
|
||||||
}: Props = $props();
|
|
||||||
|
|
||||||
// Keyword buffer: typing updates the buffer immediately (responsive input)
|
|
||||||
// and pushes into `value.query` debounced, so a keystroke doesn't fire a
|
|
||||||
// backend search per character. `lastPushed` disambiguates our own pushes
|
|
||||||
// from external writes (e.g. the page's clear-filter Escape path, or a
|
|
||||||
// URL hydration of a deep link like /files/…?q=abc), which flow back into
|
|
||||||
// the buffer via the sync effect below — including on first mount, which
|
|
||||||
// is why both start empty instead of capturing `value.query` here.
|
|
||||||
let keyword = $state('');
|
|
||||||
let lastPushed = '';
|
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// External `value.query` change → adopt it into the input buffer.
|
|
||||||
const external = value.query;
|
|
||||||
if (external !== lastPushed) {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
keyword = external;
|
|
||||||
lastPushed = external;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
// No reactive deps — teardown-only, clearing a pending debounce on destroy.
|
|
||||||
return () => {
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function pushKeyword(v: string) {
|
|
||||||
if (timer) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = null;
|
|
||||||
}
|
|
||||||
value.query = v;
|
|
||||||
lastPushed = v;
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleInput(e: Event) {
|
|
||||||
const v = (e.target as HTMLInputElement).value;
|
|
||||||
keyword = v;
|
|
||||||
if (timer) clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
timer = null;
|
|
||||||
pushKeyword(v);
|
|
||||||
}, debounceMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onInputKeydown(e: KeyboardEvent) {
|
|
||||||
// Escape clears the keyword locally and never reaches the page-level
|
|
||||||
// handler (which would otherwise also clear the selection / filters).
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
e.stopPropagation();
|
|
||||||
clearKeyword();
|
|
||||||
} else if (e.key === 'Enter') {
|
|
||||||
// Enter flushes the debounce for an immediate search.
|
|
||||||
e.preventDefault();
|
|
||||||
pushKeyword(keyword);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearKeyword() {
|
|
||||||
keyword = '';
|
|
||||||
pushKeyword('');
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeCount = $derived(activeFilterCount(value));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="sfb" data-testid="search-filter-bar">
|
|
||||||
<div class="sfb__row">
|
|
||||||
<div class="sfb__input-wrap">
|
|
||||||
<span class="sfb__magnifier"><Icon name="search" /></span>
|
|
||||||
<input
|
|
||||||
class="sfb__input"
|
|
||||||
type="search"
|
|
||||||
{placeholder}
|
|
||||||
aria-label={t('filter.keyword', 'Keyword')}
|
|
||||||
data-testid="filter-keyword-input"
|
|
||||||
value={keyword}
|
|
||||||
oninput={handleInput}
|
|
||||||
onkeydown={onInputKeydown}
|
|
||||||
/>
|
|
||||||
{#if keyword.length > 0}
|
|
||||||
<button
|
|
||||||
class="sfb__clear"
|
|
||||||
type="button"
|
|
||||||
aria-label={t('filter.clear_keyword', 'Clear search')}
|
|
||||||
data-testid="filter-clear-keyword-btn"
|
|
||||||
onclick={clearKeyword}
|
|
||||||
>
|
|
||||||
<Icon name="times" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
class="sfb__toggle"
|
|
||||||
class:sfb__toggle--active={activeCount > 0}
|
|
||||||
type="button"
|
|
||||||
aria-expanded={expanded}
|
|
||||||
aria-label={t('filter.advanced', 'Filters')}
|
|
||||||
title={t('filter.advanced', 'Filters')}
|
|
||||||
data-testid="filter-advanced-toggle-btn"
|
|
||||||
onclick={() => (expanded = !expanded)}
|
|
||||||
>
|
|
||||||
<Icon name="sliders-h" />
|
|
||||||
{#if activeCount > 0}
|
|
||||||
<span class="sfb__badge">{activeCount}</span>
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if expanded}
|
|
||||||
<div data-testid="filter-advanced-row">
|
|
||||||
<FilterFields bind:value {hideRecursive} />
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sfb {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__input-wrap {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__magnifier {
|
|
||||||
position: absolute;
|
|
||||||
left: 0.6rem;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.45rem 2rem 0.45rem 2.1rem;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__input::-webkit-search-cancel-button {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__clear {
|
|
||||||
position: absolute;
|
|
||||||
right: 0.4rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 1.4rem;
|
|
||||||
height: 1.4rem;
|
|
||||||
padding: 0;
|
|
||||||
border: none;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: none;
|
|
||||||
color: var(--color-text-secondary);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__clear:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
color: var(--color-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__toggle {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 2.2rem;
|
|
||||||
height: 2.2rem;
|
|
||||||
padding: 0;
|
|
||||||
border: 1px solid var(--color-border);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--color-bg-surface);
|
|
||||||
color: var(--color-text);
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__toggle:hover {
|
|
||||||
background: var(--color-bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__toggle--active {
|
|
||||||
border-color: var(--color-accent);
|
|
||||||
color: var(--color-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sfb__badge {
|
|
||||||
position: absolute;
|
|
||||||
top: -0.4rem;
|
|
||||||
right: -0.4rem;
|
|
||||||
min-width: 1rem;
|
|
||||||
height: 1rem;
|
|
||||||
padding: 0 0.2rem;
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
background: var(--color-accent);
|
|
||||||
color: var(--color-on-accent);
|
|
||||||
font-size: var(--text-xs, 0.7rem);
|
|
||||||
line-height: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* Shared resource-filter state for the search-backed surfaces.
|
* Shared resource-filter state for the search-backed surfaces.
|
||||||
*
|
*
|
||||||
* One module-level `$state` object is THE filter state: the files page's
|
* One module-level `$state` object is THE filter state: the top bar's
|
||||||
* `SearchFilterBar`, the top bar's filter panel, and the /search chips all
|
* search box and filter panel, and the /search chips all read and mutate
|
||||||
* read and mutate this same proxy, and each surface projects it into its URL
|
* this same proxy, and each surface projects it into its URL
|
||||||
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
|
* (see `useFilterUrlSync`). Exported as a `const` — callers mutate fields,
|
||||||
* never reassign, which is also what keeps every holder looking at the same
|
* never reassign, which is also what keeps every holder looking at the same
|
||||||
* proxy.
|
* proxy.
|
||||||
|
|||||||
@@ -75,7 +75,6 @@
|
|||||||
batchZipName,
|
batchZipName,
|
||||||
type ActionTarget
|
type ActionTarget
|
||||||
} from '$lib/composables/useResourceActions.svelte';
|
} from '$lib/composables/useResourceActions.svelte';
|
||||||
import SearchFilterBar from '$lib/components/SearchFilterBar.svelte';
|
|
||||||
|
|
||||||
// Message-bus logger. Users can tune with
|
// Message-bus logger. Users can tune with
|
||||||
// oxi.setLogLevel('oxi:message-bus', 'debug')
|
// oxi.setLogLevel('oxi:message-bus', 'debug')
|
||||||
@@ -451,7 +450,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Filter / search mode ─────────────────────────────────────────────────
|
// ── Filter / search mode ─────────────────────────────────────────────────
|
||||||
// While any SearchFilterBar dimension is active the listing switches from
|
// While any filter dimension is active the listing switches from
|
||||||
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
|
// the folder page (`fetchFolderPage`) to a scoped search (`searchResources`
|
||||||
// with folder_id = currentId). The two data paths keep independent cursors
|
// with folder_id = currentId). The two data paths keep independent cursors
|
||||||
// and stale guards; entering/leaving the mode neutralizes the other path's
|
// and stale guards; entering/leaving the mode neutralizes the other path's
|
||||||
@@ -2012,15 +2011,8 @@
|
|||||||
<ReadOnlyBanner driveName={currentDrive.name} />
|
<ReadOnlyBanner driveName={currentDrive.name} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Fuzzy filter bar: scoped keyword + type/size/date presets over the
|
<!-- Filtering is driven from the top bar (search box + filter panel),
|
||||||
current folder (recursive toggle inside). While any dimension is
|
which mutates the shared store this page's search mode reads. -->
|
||||||
active the listing below switches from the folder page to the
|
|
||||||
search results; clearing it returns to the plain folder view. The
|
|
||||||
bar mutates the shared store proxy in place — no bind, the state
|
|
||||||
lives in `$lib/stores/filter.svelte`. -->
|
|
||||||
<div class="files-filter-row">
|
|
||||||
<SearchFilterBar value={filter} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
|
<!-- Hidden upload inputs stay mounted even while the batch bar is shown.
|
||||||
Kept OUTSIDE ResourceList so the split-button dropdown in the
|
Kept OUTSIDE ResourceList so the split-button dropdown in the
|
||||||
@@ -2545,9 +2537,4 @@
|
|||||||
rendered near-invisible here). Mirrors the user-menu logout red. */
|
rendered near-invisible here). Mirrors the user-menu logout red. */
|
||||||
color: var(--color-danger-alt);
|
color: var(--color-danger-alt);
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-filter-row {
|
|
||||||
padding: 0 var(--space-2);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUplo
|
|||||||
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
|
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
|
||||||
import { searchResources } from '$lib/api/endpoints/search';
|
import { searchResources } from '$lib/api/endpoints/search';
|
||||||
import { files as filesStore } from '$lib/stores/files.svelte';
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
||||||
|
import { resourceFilters, resetFilters } from '$lib/stores/filter.svelte';
|
||||||
import FilesPage from './[...path]/+page.svelte';
|
import FilesPage from './[...path]/+page.svelte';
|
||||||
|
|
||||||
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
||||||
@@ -151,6 +152,12 @@ beforeEach(() => {
|
|||||||
// `/files/<drive-root>` via goto (see the external-user test), so the
|
// `/files/<drive-root>` via goto (see the external-user test), so the
|
||||||
// listing-oriented tests target a folder directly.
|
// listing-oriented tests target a folder directly.
|
||||||
pageState.params.path = 'home';
|
pageState.params.path = 'home';
|
||||||
|
// Same for the URL: the filter store hydrates from it, so a deep-link
|
||||||
|
// URL left over from an earlier test would leak presets forward.
|
||||||
|
pageState.url = new URL('http://localhost/files');
|
||||||
|
// The filter store is module-level shared state — clear whatever a
|
||||||
|
// previous test's keyword/preset mutation or URL hydration left there.
|
||||||
|
resetFilters();
|
||||||
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
||||||
filesStore.viewMode = 'list';
|
filesStore.viewMode = 'list';
|
||||||
});
|
});
|
||||||
@@ -291,15 +298,14 @@ it('runs a scoped recursive search when the filter keyword is set', async () =>
|
|||||||
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
||||||
render(FilesPage);
|
render(FilesPage);
|
||||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||||
vi.useFakeTimers();
|
// The keyword arrives through the shared filter store — the top-bar
|
||||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
// search box and filter panel are its writers on this page.
|
||||||
target: { value: 'hello' }
|
resourceFilters.query = 'hello';
|
||||||
});
|
await waitFor(() =>
|
||||||
await vi.advanceTimersByTimeAsync(400);
|
expect(searchResources).toHaveBeenCalledWith(
|
||||||
vi.useRealTimers();
|
'hello',
|
||||||
expect(searchResources).toHaveBeenCalledWith(
|
expect.objectContaining({ folderId: 'home', recursive: true })
|
||||||
'hello',
|
)
|
||||||
expect.objectContaining({ folderId: 'home', recursive: true })
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -312,14 +318,15 @@ it('shows the search result count while the filter is active', async () => {
|
|||||||
});
|
});
|
||||||
render(FilesPage);
|
render(FilesPage);
|
||||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||||
vi.useFakeTimers();
|
resourceFilters.query = 'found';
|
||||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
// Sync on the search having fired (not just the store write): from this
|
||||||
target: { value: 'found' }
|
// point the mocked response is already on the microtask queue, so the
|
||||||
});
|
// text wait below observes the post-search DOM deterministically.
|
||||||
await vi.advanceTimersByTimeAsync(400);
|
await waitFor(() => expect(searchResources).toHaveBeenCalled());
|
||||||
vi.useRealTimers();
|
|
||||||
const stat = await screen.findByTestId('files-folder-stat');
|
const stat = await screen.findByTestId('files-folder-stat');
|
||||||
expect(stat.textContent).toContain('1 results');
|
// The stat element exists in plain folder mode too (it shows the folder
|
||||||
|
// counts there), so wait for the search landing to swap its text.
|
||||||
|
await waitFor(() => expect(stat.textContent).toContain('1 results'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
|
it('hydrates filter presets from a deep-linked URL and runs the scoped search', async () => {
|
||||||
@@ -334,9 +341,6 @@ it('hydrates filter presets from a deep-linked URL and runs the scoped search',
|
|||||||
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
|
fileTypes: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', 'heic', 'avif', 'tiff'],
|
||||||
resourceTypes: ['folder']
|
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 () => {
|
it('writes an edited filter back to the URL exactly once (no sync loop)', async () => {
|
||||||
@@ -345,17 +349,17 @@ it('writes an edited filter back to the URL exactly once (no sync loop)', async
|
|||||||
render(FilesPage);
|
render(FilesPage);
|
||||||
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
||||||
m(goto).mockClear();
|
m(goto).mockClear();
|
||||||
vi.useFakeTimers();
|
resourceFilters.query = 'hello';
|
||||||
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
// One replaceState write for the keyword — and nothing else: the sync
|
||||||
target: { value: 'hello' }
|
// effect must recognize its own write and settle, not ping-pong.
|
||||||
});
|
await waitFor(() => expect(goto).toHaveBeenCalledTimes(1));
|
||||||
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
|
// The sync composable passes a path/search/hash string (a URL instance
|
||||||
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
|
// would trip svelte/prefer-svelte-reactivity in the .svelte.ts module).
|
||||||
const written = m(goto).mock.calls[0][0] as string;
|
const written = m(goto).mock.calls[0][0] as string;
|
||||||
expect(written).toContain('q=hello');
|
expect(written).toContain('q=hello');
|
||||||
|
// Give the effect a tick to misbehave before asserting it settled —
|
||||||
|
// a second write would mean the URL→store adoption re-triggered the
|
||||||
|
// store→URL direction.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
expect(goto).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "فشل إرسال الإشعار"
|
"shared_notificationFailed": "فشل إرسال الإشعار"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "ابحث في هذا المجلد والمجلدات الفرعية…",
|
|
||||||
"keyword": "الكلمة المفتاحية",
|
|
||||||
"advanced": "المرشحات",
|
"advanced": "المرشحات",
|
||||||
"recursive": "تضمين المجلدات الفرعية",
|
"recursive": "تضمين المجلدات الفرعية",
|
||||||
"results_count": "{{n}} نتائج",
|
"results_count": "{{n}} نتائج",
|
||||||
"clear_keyword": "مسح البحث",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
|
"shared_notificationFailed": "Benachrichtigung konnte nicht gesendet werden"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Diesen Ordner und Unterordner durchsuchen…",
|
|
||||||
"keyword": "Suchbegriff",
|
|
||||||
"advanced": "Filter",
|
"advanced": "Filter",
|
||||||
"recursive": "Unterordner einbeziehen",
|
"recursive": "Unterordner einbeziehen",
|
||||||
"results_count": "{{n}} Ergebnisse",
|
"results_count": "{{n}} Ergebnisse",
|
||||||
"clear_keyword": "Suche löschen",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -452,12 +452,9 @@
|
|||||||
"shared_notificationFailed": "Failed to send notification"
|
"shared_notificationFailed": "Failed to send notification"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Search this folder and subfolders…",
|
|
||||||
"keyword": "Keyword",
|
|
||||||
"advanced": "Filters",
|
"advanced": "Filters",
|
||||||
"recursive": "Include subfolders",
|
"recursive": "Include subfolders",
|
||||||
"results_count": "{{n}} results",
|
"results_count": "{{n}} results",
|
||||||
"clear_keyword": "Clear search",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"title": "Menú de usuario"
|
"title": "Menú de usuario"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Buscar en esta carpeta y subcarpetas…",
|
|
||||||
"keyword": "Palabra clave",
|
|
||||||
"advanced": "Filtros",
|
"advanced": "Filtros",
|
||||||
"recursive": "Incluir subcarpetas",
|
"recursive": "Incluir subcarpetas",
|
||||||
"results_count": "{{n}} resultados",
|
"results_count": "{{n}} resultados",
|
||||||
"clear_keyword": "Borrar búsqueda",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "ارسال آگاهسازی ناموفق بود"
|
"shared_notificationFailed": "ارسال آگاهسازی ناموفق بود"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "جستجو در این پوشه و زیرپوشهها…",
|
|
||||||
"keyword": "کلیدواژه",
|
|
||||||
"advanced": "فیلترها",
|
"advanced": "فیلترها",
|
||||||
"recursive": "شامل زیرپوشهها",
|
"recursive": "شامل زیرپوشهها",
|
||||||
"results_count": "{{n}} نتیجه",
|
"results_count": "{{n}} نتیجه",
|
||||||
"clear_keyword": "پاک کردن جستجو",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Erreur lors de l'envoi de la notification"
|
"shared_notificationFailed": "Erreur lors de l'envoi de la notification"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Rechercher dans ce dossier et ses sous-dossiers…",
|
|
||||||
"keyword": "Mot-clé",
|
|
||||||
"advanced": "Filtres",
|
"advanced": "Filtres",
|
||||||
"recursive": "Inclure les sous-dossiers",
|
"recursive": "Inclure les sous-dossiers",
|
||||||
"results_count": "{{n}} résultats",
|
"results_count": "{{n}} résultats",
|
||||||
"clear_keyword": "Effacer la recherche",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "सूचना भेजने में विफल"
|
"shared_notificationFailed": "सूचना भेजने में विफल"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "इस फ़ोल्डर और सबफ़ोल्डर में खोजें…",
|
|
||||||
"keyword": "कीवर्ड",
|
|
||||||
"advanced": "फ़िल्टर",
|
"advanced": "फ़िल्टर",
|
||||||
"recursive": "सबफ़ोल्डर शामिल करें",
|
"recursive": "सबफ़ोल्डर शामिल करें",
|
||||||
"results_count": "{{n}} परिणाम",
|
"results_count": "{{n}} परिणाम",
|
||||||
"clear_keyword": "खोज साफ़ करें",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Impossibile inviare la notifica"
|
"shared_notificationFailed": "Impossibile inviare la notifica"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Cerca in questa cartella e nelle sottocartelle…",
|
|
||||||
"keyword": "Parola chiave",
|
|
||||||
"advanced": "Filtri",
|
"advanced": "Filtri",
|
||||||
"recursive": "Includi sottocartelle",
|
"recursive": "Includi sottocartelle",
|
||||||
"results_count": "{{n}} risultati",
|
"results_count": "{{n}} risultati",
|
||||||
"clear_keyword": "Cancella ricerca",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "通知の送信に失敗しました"
|
"shared_notificationFailed": "通知の送信に失敗しました"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "このフォルダとサブフォルダを検索…",
|
|
||||||
"keyword": "キーワード",
|
|
||||||
"advanced": "フィルター",
|
"advanced": "フィルター",
|
||||||
"recursive": "サブフォルダを含める",
|
"recursive": "サブフォルダを含める",
|
||||||
"results_count": "{{n}} 件の結果",
|
"results_count": "{{n}} 件の結果",
|
||||||
"clear_keyword": "検索をクリア",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "알림 전송에 실패했습니다"
|
"shared_notificationFailed": "알림 전송에 실패했습니다"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "이 폴더와 하위 폴더 검색…",
|
|
||||||
"keyword": "키워드",
|
|
||||||
"advanced": "필터",
|
"advanced": "필터",
|
||||||
"recursive": "하위 폴더 포함",
|
"recursive": "하위 폴더 포함",
|
||||||
"results_count": "{{n}}개 결과",
|
"results_count": "{{n}}개 결과",
|
||||||
"clear_keyword": "검색 지우기",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Notificatie verzenden mislukt"
|
"shared_notificationFailed": "Notificatie verzenden mislukt"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Deze map en submappen doorzoeken…",
|
|
||||||
"keyword": "Trefwoord",
|
|
||||||
"advanced": "Filters",
|
"advanced": "Filters",
|
||||||
"recursive": "Submappen opnemen",
|
"recursive": "Submappen opnemen",
|
||||||
"results_count": "{{n}} resultaten",
|
"results_count": "{{n}} resultaten",
|
||||||
"clear_keyword": "Zoekopdracht wissen",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Nie udało się wysłać powiadomienia"
|
"shared_notificationFailed": "Nie udało się wysłać powiadomienia"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Szukaj w tym folderze i podfolderach…",
|
|
||||||
"keyword": "Słowo kluczowe",
|
|
||||||
"advanced": "Filtry",
|
"advanced": "Filtry",
|
||||||
"recursive": "Uwzględnij podfoldery",
|
"recursive": "Uwzględnij podfoldery",
|
||||||
"results_count": "Wyniki: {{n}}",
|
"results_count": "Wyniki: {{n}}",
|
||||||
"clear_keyword": "Wyczyść wyszukiwanie",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Falha ao enviar a notificação"
|
"shared_notificationFailed": "Falha ao enviar a notificação"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Pesquisar nesta pasta e subpastas…",
|
|
||||||
"keyword": "Palavra-chave",
|
|
||||||
"advanced": "Filtros",
|
"advanced": "Filtros",
|
||||||
"recursive": "Incluir subpastas",
|
"recursive": "Incluir subpastas",
|
||||||
"results_count": "{{n}} resultados",
|
"results_count": "{{n}} resultados",
|
||||||
"clear_keyword": "Limpar pesquisa",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_notificationFailed": "Не удалось отправить уведомление"
|
"shared_notificationFailed": "Не удалось отправить уведомление"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "Поиск в этой папке и подпапках…",
|
|
||||||
"keyword": "Ключевое слово",
|
|
||||||
"advanced": "Фильтры",
|
"advanced": "Фильтры",
|
||||||
"recursive": "Включая подпапки",
|
"recursive": "Включая подпапки",
|
||||||
"results_count": "Результатов: {{n}}",
|
"results_count": "Результатов: {{n}}",
|
||||||
"clear_keyword": "Очистить поиск",
|
|
||||||
"modified_label": "Modified",
|
"modified_label": "Modified",
|
||||||
"created_label": "Created",
|
"created_label": "Created",
|
||||||
"kind_label": "Items",
|
"kind_label": "Items",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_typeFolder": "資料夾"
|
"shared_typeFolder": "資料夾"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "搜尋此資料夾及子資料夾…",
|
|
||||||
"keyword": "關鍵字",
|
|
||||||
"advanced": "篩選",
|
"advanced": "篩選",
|
||||||
"recursive": "包含子資料夾",
|
"recursive": "包含子資料夾",
|
||||||
"results_count": "{{n}} 個結果",
|
"results_count": "{{n}} 個結果",
|
||||||
"clear_keyword": "清除搜尋",
|
|
||||||
"modified_label": "修改時間",
|
"modified_label": "修改時間",
|
||||||
"created_label": "建立時間",
|
"created_label": "建立時間",
|
||||||
"kind_label": "項目",
|
"kind_label": "項目",
|
||||||
|
|||||||
@@ -417,12 +417,9 @@
|
|||||||
"shared_typeFolder": "文件夹"
|
"shared_typeFolder": "文件夹"
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
"placeholder": "搜索此文件夹及子文件夹…",
|
|
||||||
"keyword": "关键词",
|
|
||||||
"advanced": "筛选",
|
"advanced": "筛选",
|
||||||
"recursive": "包含子文件夹",
|
"recursive": "包含子文件夹",
|
||||||
"results_count": "{{n}} 个结果",
|
"results_count": "{{n}} 个结果",
|
||||||
"clear_keyword": "清除搜索",
|
|
||||||
"modified_label": "修改时间",
|
"modified_label": "修改时间",
|
||||||
"created_label": "创建时间",
|
"created_label": "创建时间",
|
||||||
"kind_label": "条目",
|
"kind_label": "条目",
|
||||||
|
|||||||
@@ -9,6 +9,27 @@
|
|||||||
|
|
||||||
## 已完成
|
## 已完成
|
||||||
|
|
||||||
|
### [2026-10-01] 移除文件页搜索过滤栏(顶栏统一入口)
|
||||||
|
- **状态**: 已完成(`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败)
|
||||||
|
- **计划**: 顶栏搜索框与筛选面板(`29d0c335`)已提供同一套筛选能力后,文件页自己的
|
||||||
|
`SearchFilterBar` 成为重复的第二个输入框,按用户决定移除。共享 store、URL 同步、
|
||||||
|
文件页的就地搜索模式**全部保留**——顶栏(搜索框 + 面板)现在是唯一的筛选写入方。
|
||||||
|
- **移除后的能力分布**: 预设筛选(类型/大小/修改/创建/种类/递归)在顶栏面板改共享 store →
|
||||||
|
文件页就地过滤;关键词在顶栏搜索框提交 → 跳 `/search?q=…&in=<当前文件夹>`(范围跟随);
|
||||||
|
`?q=` 深链仍直接在文件页水合生效。
|
||||||
|
- **改动文件**:
|
||||||
|
- 删除(仅本地): `frontend/src/lib/components/SearchFilterBar.svelte`
|
||||||
|
- `frontend/src/routes/files/[...path]/+page.svelte` — 删组件导入与挂载(`.files-filter-row`),
|
||||||
|
搜索模式/store/URL 同步不动;仅注释措辞更新("SearchFilterBar dimension"→"filter dimension")
|
||||||
|
- `frontend/src/routes/files/page.test.ts` — 4 个测试改为直接写共享 store 驱动(`resourceFilters.query = …`,
|
||||||
|
与顶栏同一写入路径),不再依赖已删除组件的 testid;beforeEach 补 `resetFilters()` + URL 复位防串扰
|
||||||
|
- `frontend/static/locales/*.json`(16 个)— 删孤儿 key `filter.placeholder`/`filter.keyword`/`filter.clear_keyword`
|
||||||
|
(其余 `filter.*` 被顶栏面板/FilterFields/FilterChips/文件页结果计数继续使用,保留)
|
||||||
|
- 注释清理: `filter.svelte.ts`、`FilterFields.svelte`、`AppShell.svelte`(2 处)去掉对已删组件的引用
|
||||||
|
- **仅本地文件**: 无新增(`status.md` 本身)
|
||||||
|
- **上游冲突风险**: 低 — 净效果是 -323 行;SearchFilterBar 本就是仅本地组件,删除后与上游的冲突面
|
||||||
|
比之前更小。locales 各文件仅 3 行删除。
|
||||||
|
|
||||||
### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
|
### [2026-09-21] 顶栏搜索框 × 筛选栏合并(并补齐筛选维度)
|
||||||
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定)
|
- **状态**: 已完成(commit `29d0c335`;`npm run check` 全绿:svelte-check 0 错 0 警 + eslint + stylelint + prettier;`vitest run` 492 通过 0 失败,连续两轮稳定)
|
||||||
- **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。
|
- **计划**: 把上游顶栏搜索框与本地 `SearchFilterBar` 合并成一套筛选能力:顶栏搜索框内加筛选按钮 + 下拉面板(不动 70px 高度);文件页筛选栏保留,两处共享同一份筛选状态并同步到 URL(就地筛选行为不丢);`/search` 改为 URL 驱动、移除三个页内 select(改为可撤销 chips);同时补上后端已支持但前端未暴露的 `resource_types`(仅文件/仅文件夹)与创建时间维度。后端零改动。
|
||||||
|
|||||||
Reference in New Issue
Block a user