refactor(search): adapt UI to use ResourceList
This commit is contained in:
@@ -2,16 +2,16 @@ import { it, expect, vi, beforeEach } from 'vitest';
|
||||
vi.mock('$lib/api/client', () => ({ apiFetch: vi.fn(), apiJson: vi.fn() }));
|
||||
vi.mock('$lib/api/csrf', () => ({ getCsrfHeaders: () => ({}) }));
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { searchFiles, searchSuggest, clearSearchCache } from './search';
|
||||
import { searchResources, searchSuggest, clearSearchCache } from './search';
|
||||
const f = apiFetch as unknown as ReturnType<typeof vi.fn>;
|
||||
const j = apiJson as unknown as ReturnType<typeof vi.fn>;
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
f.mockResolvedValue({ ok: true, status: 200, json: async () => ({}) });
|
||||
j.mockResolvedValue({ files: [], folders: [] });
|
||||
j.mockResolvedValue({ items: [], query_time_ms: 0 });
|
||||
});
|
||||
it('builds search requests including filters', async () => {
|
||||
await searchFiles('q', {
|
||||
await searchResources('q', {
|
||||
recursive: true,
|
||||
fileTypes: ['mp3', 'wav'],
|
||||
minSize: 1,
|
||||
@@ -19,7 +19,22 @@ it('builds search requests including filters', async () => {
|
||||
sortBy: 'date'
|
||||
}).catch(() => {});
|
||||
expect(j).toHaveBeenCalledWith(expect.stringContaining('type=mp3%2Cwav'), expect.anything());
|
||||
// Sort dimension is sent on the wire as `order_by`, matching the
|
||||
// backend's `SearchResourcesQuery` (post-normalization).
|
||||
expect(j).toHaveBeenCalledWith(expect.stringContaining('order_by=date'), expect.anything());
|
||||
await searchSuggest('q').catch(() => {});
|
||||
await clearSearchCache().catch(() => {});
|
||||
expect(f.mock.calls.length + j.mock.calls.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('forwards cursor pagination and resource-type filter', async () => {
|
||||
await searchResources('q', {
|
||||
cursor: 'abc',
|
||||
resourceTypes: ['file'],
|
||||
limit: 25
|
||||
}).catch(() => {});
|
||||
const call = j.mock.calls.at(-1)?.[0] as string;
|
||||
expect(call).toContain('cursor=abc');
|
||||
expect(call).toContain('resource_types=file');
|
||||
expect(call).toContain('limit=25');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Search endpoint — ported from features/files/search.js. */
|
||||
// Search endpoint — hits the normalized `/*/resources` envelope shape.
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import type { SearchResults, SortBy } from '$lib/api/types';
|
||||
import type { ItemType, SearchResourcesResponse, SortBy } from '$lib/api/types';
|
||||
|
||||
export interface SearchOptions {
|
||||
folderId?: string;
|
||||
@@ -16,14 +16,31 @@ export interface SearchOptions {
|
||||
modifiedAfter?: number;
|
||||
/** Unix-seconds upper bound on modified time. */
|
||||
modifiedBefore?: number;
|
||||
/** Page size (1–200 server-side; default 50). */
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
/**
|
||||
* Cursor from a previous response's `next_cursor`. Absent → first page.
|
||||
* The wire uses cursor pagination now; the old `offset` param is gone.
|
||||
*/
|
||||
cursor?: string;
|
||||
/** Sort dimension; maps to backend `order_by`. */
|
||||
sortBy?: SortBy;
|
||||
/** Restrict to files, folders, or both (default). */
|
||||
resourceTypes?: ItemType[];
|
||||
/** Abort the request when a newer search supersedes it. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export function searchFiles(query: string, opts: SearchOptions = {}): Promise<SearchResults> {
|
||||
/**
|
||||
* Cursor-paginated search. Returns the shared envelope
|
||||
* `{ items[], next_cursor?, query_time_ms, total? }` — same shape as
|
||||
* favorites / recent / trash / folder listings so `ResourceList`
|
||||
* consumes the items without a demux step.
|
||||
*/
|
||||
export function searchResources(
|
||||
query: string,
|
||||
opts: SearchOptions = {}
|
||||
): Promise<SearchResourcesResponse> {
|
||||
const params = new URLSearchParams();
|
||||
params.append('query', query);
|
||||
if (opts.folderId) params.append('folder_id', opts.folderId);
|
||||
@@ -37,10 +54,11 @@ export function searchFiles(query: string, opts: SearchOptions = {}): Promise<Se
|
||||
if (opts.createdBefore != null) params.append('created_before', String(opts.createdBefore));
|
||||
if (opts.modifiedAfter != null) params.append('modified_after', String(opts.modifiedAfter));
|
||||
if (opts.modifiedBefore != null) params.append('modified_before', String(opts.modifiedBefore));
|
||||
params.append('limit', String(opts.limit ?? 100));
|
||||
params.append('offset', String(opts.offset ?? 0));
|
||||
params.append('sort_by', opts.sortBy ?? 'relevance');
|
||||
return apiJson<SearchResults>(`/api/search?${params.toString()}`, {
|
||||
if (opts.resourceTypes?.length) params.append('resource_types', opts.resourceTypes.join(','));
|
||||
if (opts.limit != null) params.append('limit', String(opts.limit));
|
||||
if (opts.cursor) params.append('cursor', opts.cursor);
|
||||
if (opts.sortBy) params.append('order_by', opts.sortBy);
|
||||
return apiJson<SearchResourcesResponse>(`/api/search?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
signal: opts.signal
|
||||
});
|
||||
|
||||
@@ -257,31 +257,43 @@ export type SortBy =
|
||||
| 'size'
|
||||
| 'size_desc';
|
||||
|
||||
export interface SearchCriteria {
|
||||
sort_by: SortBy;
|
||||
recursive: boolean;
|
||||
limit: number;
|
||||
offset: number;
|
||||
name_contains?: string;
|
||||
file_types?: string[];
|
||||
folder_id?: string;
|
||||
min_size?: number;
|
||||
max_size?: number;
|
||||
created_before?: number;
|
||||
created_after?: number;
|
||||
modified_before?: number;
|
||||
modified_after?: number;
|
||||
/**
|
||||
* Per-item search metadata inline on every hit in the normalized
|
||||
* `/api/search` envelope. Mirrors backend `SearchMeta` — see
|
||||
* `application/dtos/search_dto.rs`.
|
||||
*/
|
||||
export interface SearchMeta {
|
||||
/** Relevance in [0, 1]; the higher the better. */
|
||||
score: number;
|
||||
/** Optional HTML-safe excerpt when the match fired via content index. */
|
||||
snippet?: string;
|
||||
/** Where the match fired. */
|
||||
via?: 'name' | 'content' | 'path';
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
files: FileItem[];
|
||||
folders: FolderItem[];
|
||||
total_count: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
has_more: boolean;
|
||||
/**
|
||||
* Single hit in the `/api/search` envelope. `resource_type` disambiguates
|
||||
* `resource`'s union so the shared `ResourceList` component can render it
|
||||
* exactly like a folders/favorites/recent/trash row.
|
||||
*/
|
||||
export interface SearchResourceItem {
|
||||
resource_type: ItemType;
|
||||
resource: FileItem | FolderItem;
|
||||
meta: SearchMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire response of `GET /api/search`. Same envelope shape as the other
|
||||
* "resources" listing endpoints (`items[]` + optional `next_cursor`),
|
||||
* plus two search-specific top-level fields: `query_time_ms` (health
|
||||
* signal for admins, "Found N in Xms" for users) and `total` (approximate,
|
||||
* caller-visible; never leaks a count for rows the caller can't see).
|
||||
*/
|
||||
export interface SearchResourcesResponse {
|
||||
items: SearchResourceItem[];
|
||||
next_cursor?: string;
|
||||
query_time_ms: number;
|
||||
sort_by: string;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
export type DriveKind = 'personal' | 'shared';
|
||||
|
||||
Reference in New Issue
Block a user