29d0c33558
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>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
import { it, expect, vi, beforeEach } from 'vitest';
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
|
|
|
|
const { goto, pageState, session, ui, confirmDialog, promptDialog } = vi.hoisted(() => ({
|
|
goto: vi.fn(),
|
|
pageState: { params: { path: '' }, url: new URL('http://localhost/files') } as {
|
|
params: { path: string };
|
|
url: URL;
|
|
},
|
|
session: {
|
|
user: { id: 'me', username: 'admin', is_external: false },
|
|
isExternalUser: false,
|
|
loadHomeFolder: vi.fn(async () => 'home'),
|
|
refresh: vi.fn(async () => {})
|
|
},
|
|
ui: {
|
|
notify: vi.fn(),
|
|
startProgress: vi.fn(() => 1),
|
|
updateProgress: vi.fn(),
|
|
finishProgress: vi.fn()
|
|
},
|
|
confirmDialog: vi.fn(),
|
|
promptDialog: vi.fn()
|
|
}));
|
|
vi.mock('$app/navigation', () => ({ goto }));
|
|
vi.mock('$app/state', () => ({ page: pageState }));
|
|
vi.mock('$lib/stores/session.svelte', () => ({ session }));
|
|
vi.mock('$lib/stores/ui.svelte', () => ({ ui }));
|
|
vi.mock('$lib/stores/dialogs.svelte', () => ({ confirmDialog, promptDialog }));
|
|
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
|
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
|
vi.mock('$lib/api/endpoints/search', () => ({ searchResources: vi.fn() }));
|
|
vi.mock('$lib/api/endpoints/deltaUpload', () => ({
|
|
instantUploadOwned: vi.fn(),
|
|
resolveOwnedHashes: vi.fn(),
|
|
tryDeltaUpload: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/favorites', () => ({
|
|
addFavorite: vi.fn(),
|
|
removeFavorite: vi.fn(),
|
|
addFavoritesBatch: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/wopi', () => ({
|
|
canEditWithWopi: () => false,
|
|
getEditorUrlWithFallback: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/music', () => ({
|
|
addTracks: vi.fn(),
|
|
createPlaylist: vi.fn(),
|
|
listPlaylists: vi.fn(async () => [])
|
|
}));
|
|
vi.mock('$lib/api/endpoints/files', () => ({
|
|
deleteFile: vi.fn(),
|
|
fileDownloadUrl: () => '/dl',
|
|
fileThumbnailUrl: () => '/thumb',
|
|
thumbSizeForView: () => 'preview' as const,
|
|
moveFile: vi.fn(),
|
|
renameFile: vi.fn(),
|
|
uploadFile: vi.fn(),
|
|
uploadFileWithProgress: vi.fn()
|
|
}));
|
|
vi.mock('$lib/api/endpoints/folders', () => ({
|
|
createFolder: vi.fn(),
|
|
deleteFolder: vi.fn(),
|
|
fetchFolderPage: vi.fn(),
|
|
folderZipUrl: () => '/zip',
|
|
getFolder: vi.fn(async (id: string) => ({ id, name: id })),
|
|
getFolderName: () => undefined,
|
|
// Consumed by the new shared `<FolderBreadcrumb>` component that
|
|
// `/files` mounts. Return an empty chain so the breadcrumb renders
|
|
// nothing — tests here don't assert on breadcrumb content.
|
|
getFolderAncestors: vi.fn(async (id: string) => ({
|
|
ancestors: [{ id, name: id, parent_id: null, drive_id: 'test-drive' }],
|
|
access_source: { kind: 'drive' as const }
|
|
})),
|
|
invalidateFolderCache: vi.fn(),
|
|
moveFolder: vi.fn(),
|
|
rememberFolderName: vi.fn(),
|
|
renameFolder: vi.fn()
|
|
}));
|
|
|
|
import { fetchFolderPage, createFolder, deleteFolder } from '$lib/api/endpoints/folders';
|
|
import { deleteFile, uploadFileWithProgress } from '$lib/api/endpoints/files';
|
|
import { resolveOwnedHashes, tryDeltaUpload } from '$lib/api/endpoints/deltaUpload';
|
|
import { addFavoritesBatch } from '$lib/api/endpoints/favorites';
|
|
import { searchResources } from '$lib/api/endpoints/search';
|
|
import { files as filesStore } from '$lib/stores/files.svelte';
|
|
import FilesPage from './[...path]/+page.svelte';
|
|
|
|
const m = (fn: unknown) => fn as ReturnType<typeof vi.fn>;
|
|
|
|
function withListing() {
|
|
// `fetchFolderPage` returns ONE page with the accumulator shape (items in
|
|
// server order + folders/files splits). With `nextCursor` omitted the
|
|
// caller treats it as the last page — the page's items become the whole
|
|
// on-screen listing without triggering `loadMore`.
|
|
const folder = folderItem('sub1', 'Sub');
|
|
const file = fileItem('f1', 'hello.txt');
|
|
m(fetchFolderPage).mockResolvedValue({
|
|
items: [folder, file],
|
|
folders: [folder],
|
|
files: [file],
|
|
nextCursor: undefined
|
|
});
|
|
}
|
|
|
|
function fileItem(id: string, name: string) {
|
|
return {
|
|
category: 'Document',
|
|
created_at: 0,
|
|
icon_class: 'fa-file',
|
|
icon_special_class: '',
|
|
id,
|
|
mime_type: 'text/plain',
|
|
modified_at: 0,
|
|
name,
|
|
created_by: 'me',
|
|
updated_by: 'me',
|
|
folder_id: 'home',
|
|
path: '/' + name,
|
|
size: 4,
|
|
size_formatted: '4 B',
|
|
sort_date: 0,
|
|
etag: 'e',
|
|
content_hash: 'h'
|
|
};
|
|
}
|
|
function folderItem(id: string, name: string) {
|
|
return {
|
|
category: 'Folder',
|
|
created_at: 0,
|
|
icon_class: 'fa-folder',
|
|
icon_special_class: '',
|
|
id,
|
|
is_root: false,
|
|
modified_at: 0,
|
|
name,
|
|
created_by: 'me',
|
|
updated_by: 'me',
|
|
parent_id: 'home',
|
|
path: '/' + name,
|
|
etag: 'e'
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
m(resolveOwnedHashes).mockResolvedValue(new Map());
|
|
m(tryDeltaUpload).mockResolvedValue(null);
|
|
// A concrete folder in the path: bare `/files` now canonicalizes to
|
|
// `/files/<drive-root>` via goto (see the external-user test), so the
|
|
// listing-oriented tests target a folder directly.
|
|
pageState.params.path = 'home';
|
|
// List view renders the select-all header + per-row checkboxes; grid hides them.
|
|
filesStore.viewMode = 'list';
|
|
});
|
|
|
|
it('keeps aggregate upload progress exact when one file restarts', async () => {
|
|
withListing();
|
|
const pending = new Map<string, { report: (fraction: number) => void; resolve: () => void }>();
|
|
m(uploadFileWithProgress).mockImplementation(
|
|
(_folderId: string | null, file: File, report: (fraction: number) => void) =>
|
|
new Promise<void>((resolve) => pending.set(file.name, { report, resolve }))
|
|
);
|
|
render(FilesPage);
|
|
const input = await screen.findByTestId('files-upload-file-input');
|
|
// The cold-navigation upload guard (`guardUploadFolderReady` in
|
|
// `+page.svelte`) refuses uploads while `currentId` is null — which is
|
|
// the initial state before `load()` runs. `load()` sets `currentId =
|
|
// folderId` BEFORE it calls `fetchFolderPage`, so waiting on the fetch
|
|
// mock is a stable "load() has progressed past the assignment" signal.
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
|
const uploads = [new File(['a'], 'a.txt'), new File(['b'], 'b.txt')];
|
|
Object.defineProperty(input, 'files', { configurable: true, value: uploads });
|
|
|
|
const changed = fireEvent.change(input);
|
|
await waitFor(() => expect(pending.size).toBe(2));
|
|
|
|
pending.get('a.txt')!.report(0.5);
|
|
pending.get('b.txt')!.report(0.25);
|
|
pending.get('a.txt')!.report(0);
|
|
pending.get('a.txt')!.report(0.75);
|
|
expect(ui.updateProgress.mock.calls.map((call) => call[1])).toEqual([25, 38, 13, 50]);
|
|
|
|
pending.get('a.txt')!.resolve();
|
|
await waitFor(() =>
|
|
expect(ui.updateProgress.mock.calls.map((call) => call[1])).toEqual([25, 38, 13, 50, 63])
|
|
);
|
|
pending.get('b.txt')!.resolve();
|
|
await changed;
|
|
await waitFor(() =>
|
|
expect(ui.updateProgress).toHaveBeenLastCalledWith(1, 100, expect.any(String))
|
|
);
|
|
});
|
|
|
|
it('loads the home folder listing on mount and renders its contents', async () => {
|
|
withListing();
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledWith('home', expect.anything()));
|
|
// VirtualList windows rows by viewport height (0 in jsdom), so assert the
|
|
// surrounding chrome rendered rather than the windowed rows themselves.
|
|
await screen.findByTestId('files-new-folder-btn');
|
|
});
|
|
|
|
it('shows the folder item count next to the breadcrumb', async () => {
|
|
withListing(); // 1 folder + 1 file, `nextCursor` undefined → last page
|
|
render(FilesPage);
|
|
const stat = await screen.findByTestId('files-folder-stat');
|
|
expect(stat.textContent).toContain('1 folders · 1 files');
|
|
// No "+" suffix — the listing is complete, the count is exact.
|
|
expect(stat.textContent!.trim().endsWith('+')).toBe(false);
|
|
});
|
|
|
|
it('marks the folder count as partial while more pages exist', async () => {
|
|
const folder = folderItem('sub1', 'Sub');
|
|
const file = fileItem('f1', 'hello.txt');
|
|
m(fetchFolderPage).mockResolvedValue({
|
|
items: [folder, file],
|
|
folders: [folder],
|
|
files: [file],
|
|
nextCursor: 'page-2'
|
|
});
|
|
render(FilesPage);
|
|
const stat = await screen.findByTestId('files-folder-stat');
|
|
// Counts reflect the pages loaded so far; the trailing "+" says more
|
|
// are on the way via infinite scroll instead of claiming exactness.
|
|
expect(stat.textContent).toContain('1 folders · 1 files');
|
|
expect(stat.textContent!.trim().endsWith('+')).toBe(true);
|
|
});
|
|
|
|
it('reloads the listing when the refresh button is clicked', async () => {
|
|
withListing();
|
|
render(FilesPage);
|
|
await screen.findByTestId('files-refresh-btn');
|
|
// Initial mount load = 1 call; the click resets pagination and refetches.
|
|
await fireEvent.click(screen.getByTestId('files-refresh-btn'));
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalledTimes(2));
|
|
});
|
|
|
|
it('shows an error when the listing fails with no cache', async () => {
|
|
m(fetchFolderPage).mockRejectedValue(Object.assign(new Error('nope'), { status: 500 }));
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
|
});
|
|
|
|
it('redirects external users away from the home folder', async () => {
|
|
session.isExternalUser = true;
|
|
pageState.params.path = '';
|
|
render(FilesPage);
|
|
await waitFor(() => expect(goto).toHaveBeenCalledWith('/shared-with-me', { replaceState: true }));
|
|
session.isExternalUser = false;
|
|
});
|
|
|
|
it('creates a new folder in the current directory', async () => {
|
|
withListing();
|
|
promptDialog.mockResolvedValue('Reports');
|
|
m(createFolder).mockResolvedValue({ id: 'new', name: 'Reports' });
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('files-new-folder-btn'));
|
|
await waitFor(() => expect(createFolder).toHaveBeenCalledWith('Reports', 'home'));
|
|
});
|
|
|
|
it('batch-deletes the whole selection after confirmation', async () => {
|
|
withListing();
|
|
confirmDialog.mockResolvedValue(true);
|
|
m(deleteFolder).mockResolvedValue(undefined);
|
|
m(deleteFile).mockResolvedValue(undefined);
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
|
|
await fireEvent.click(await screen.findByTestId('files-batch-delete-btn'));
|
|
await waitFor(() => expect(deleteFolder).toHaveBeenCalledWith('sub1'));
|
|
await waitFor(() => expect(deleteFile).toHaveBeenCalledWith('f1'));
|
|
});
|
|
|
|
it('batch-favorites the selection via the favorites batch endpoint', async () => {
|
|
withListing();
|
|
m(addFavoritesBatch).mockResolvedValue(undefined);
|
|
render(FilesPage);
|
|
await fireEvent.click(await screen.findByTestId('resource-list-select-all-checkbox'));
|
|
await fireEvent.click(await screen.findByTestId('files-batch-favorite-btn'));
|
|
await waitFor(() =>
|
|
expect(addFavoritesBatch).toHaveBeenCalledWith([
|
|
{ item_id: 'sub1', item_type: 'folder' },
|
|
{ item_id: 'f1', item_type: 'file' }
|
|
])
|
|
);
|
|
});
|
|
|
|
it('runs a scoped recursive search when the filter keyword is set', async () => {
|
|
withListing();
|
|
m(searchResources).mockResolvedValue({ items: [], query_time_ms: 1 });
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
|
vi.useFakeTimers();
|
|
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
|
target: { value: 'hello' }
|
|
});
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
vi.useRealTimers();
|
|
expect(searchResources).toHaveBeenCalledWith(
|
|
'hello',
|
|
expect.objectContaining({ folderId: 'home', recursive: true })
|
|
);
|
|
});
|
|
|
|
it('shows the search result count while the filter is active', async () => {
|
|
withListing();
|
|
const hit = fileItem('s1', 'found.txt');
|
|
m(searchResources).mockResolvedValue({
|
|
items: [{ resource_type: 'file', resource: hit, meta: { score: 50 } }],
|
|
query_time_ms: 1
|
|
});
|
|
render(FilesPage);
|
|
await waitFor(() => expect(fetchFolderPage).toHaveBeenCalled());
|
|
vi.useFakeTimers();
|
|
await fireEvent.input(screen.getByTestId('filter-keyword-input'), {
|
|
target: { value: 'found' }
|
|
});
|
|
await vi.advanceTimersByTimeAsync(400);
|
|
vi.useRealTimers();
|
|
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');
|
|
});
|