fix(frontend): migrate folder listing from removed /listing to /resources
The legacy-frontend removal dropped the deprecated /api/folders/{id}/listing
route, but folders.ts still called it, so every folder view 404'd
("listing failed: 404"). Complete the migration: fetchFolderListing now pages
through the cursor-paginated /api/folders/{id}/resources feed and rebuilds the
combined {folders, files} listing the views expect.
- Pages through next_cursor (limit 200) and splits mixed resource items by
resource_type. 403 still throws; the 304/ETag fast-path is gone (that feed has
no whole-listing ETag) so the in-memory folderCache is the only revalidation.
- Favorite/share badge sets aren't carried by /resources, so they come back
empty for now (no star / share badge until wired from /favorites + /shares).
- folders.test.ts updated to the paginated shape.
npm run check: 0 errors. 58 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,19 +15,15 @@ import {
|
||||
type FolderListing
|
||||
} from './folders';
|
||||
|
||||
type RawListing = {
|
||||
folders?: unknown[];
|
||||
files?: unknown[];
|
||||
favorite_ids?: string[];
|
||||
shared_ids?: string[];
|
||||
};
|
||||
type ResourceItem = { resource_type: 'file' | 'folder'; resource: { id: string; name?: string } };
|
||||
type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
|
||||
|
||||
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response {
|
||||
function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
|
||||
return {
|
||||
status: opts.status,
|
||||
ok: opts.status >= 200 && opts.status < 300,
|
||||
json: async () => opts.body ?? {},
|
||||
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) }
|
||||
headers: { get: () => null }
|
||||
} as unknown as Response;
|
||||
}
|
||||
|
||||
@@ -38,39 +34,50 @@ const emptyListing = (): FolderListing => ({
|
||||
sharedIds: []
|
||||
});
|
||||
|
||||
const initHeaders = (call: number): Record<string, string> =>
|
||||
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
invalidateFolderCache();
|
||||
});
|
||||
|
||||
describe('fetchFolderListing (conditional)', () => {
|
||||
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => {
|
||||
describe('fetchFolderListing (cursor-paginated /resources)', () => {
|
||||
it('splits one page of resources into folders + files', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(
|
||||
fakeRes({
|
||||
status: 200,
|
||||
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] },
|
||||
etag: '"v1"'
|
||||
body: {
|
||||
items: [
|
||||
{ resource_type: 'folder', resource: { id: 'd1', name: 'Docs' } },
|
||||
{ resource_type: 'file', resource: { id: 'x1', name: 'a.txt' } }
|
||||
]
|
||||
}
|
||||
})
|
||||
);
|
||||
const r = await fetchFolderListing('f1');
|
||||
expect(r.status).toBe(200);
|
||||
expect(r.etag).toBe('"v1"');
|
||||
expect(r.listing?.favoriteIds).toEqual(['a']);
|
||||
expect(r.listing?.sharedIds).toEqual(['b']);
|
||||
expect(initHeaders(0)['If-None-Match']).toBeUndefined();
|
||||
// No cache-busting query param — the URL must be stable for revalidation.
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toBe('/api/folders/f1/listing');
|
||||
expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
|
||||
expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
|
||||
expect(r.listing?.favoriteIds).toEqual([]);
|
||||
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
|
||||
});
|
||||
|
||||
it('sends If-None-Match and surfaces a 304 with no body', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 }));
|
||||
const r = await fetchFolderListing('f1', { etag: '"v1"' });
|
||||
expect(r.status).toBe(304);
|
||||
expect(r.listing).toBeUndefined();
|
||||
expect(initHeaders(0)['If-None-Match']).toBe('"v1"');
|
||||
it('follows next_cursor across pages', async () => {
|
||||
vi.mocked(apiFetch)
|
||||
.mockResolvedValueOnce(
|
||||
fakeRes({
|
||||
status: 200,
|
||||
body: { items: [{ resource_type: 'file', resource: { id: 'p1' } }], next_cursor: 'c2' }
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
fakeRes({
|
||||
status: 200,
|
||||
body: { items: [{ resource_type: 'file', resource: { id: 'p2' } }] }
|
||||
})
|
||||
);
|
||||
const r = await fetchFolderListing('f1');
|
||||
expect(r.listing?.files.map((f) => f.id)).toEqual(['p1', 'p2']);
|
||||
expect(vi.mocked(apiFetch)).toHaveBeenCalledTimes(2);
|
||||
expect(vi.mocked(apiFetch).mock.calls[1][0]).toContain('cursor=c2');
|
||||
});
|
||||
|
||||
it('throws a 403 carrying its status', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
|
||||
import { apiFetch, apiJson } from '$lib/api/client';
|
||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
||||
import type { FileItem, FolderItem } from '$lib/api/types';
|
||||
import type { FileItem, FolderItem, ItemType } from '$lib/api/types';
|
||||
|
||||
const JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||
const NO_CACHE: RequestInit = {
|
||||
@@ -88,21 +88,6 @@ export function getFolderName(id: string): string | undefined {
|
||||
return folderNames.get(id);
|
||||
}
|
||||
|
||||
function parseListing(raw: unknown): FolderListing {
|
||||
const o = (raw ?? {}) as {
|
||||
folders?: FolderItem[];
|
||||
files?: FileItem[];
|
||||
favorite_ids?: string[];
|
||||
shared_ids?: string[];
|
||||
};
|
||||
return {
|
||||
folders: Array.isArray(o.folders) ? o.folders : [],
|
||||
files: Array.isArray(o.files) ? o.files : [],
|
||||
favoriteIds: Array.isArray(o.favorite_ids) ? o.favorite_ids : [],
|
||||
sharedIds: Array.isArray(o.shared_ids) ? o.shared_ids : []
|
||||
};
|
||||
}
|
||||
|
||||
export async function getFolder(id: string): Promise<FolderItem> {
|
||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||
rememberFolderName(folder.id, folder.name);
|
||||
@@ -110,32 +95,46 @@ export async function getFolder(id: string): Promise<FolderItem> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a folder listing, optionally conditionally. With `etag` set it sends
|
||||
* `If-None-Match`; the server replies 304 (empty body) when nothing changed —
|
||||
* the ETag covers folders + files + favorite/share badges — so the caller can
|
||||
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of
|
||||
* the way; revalidation is driven entirely by our own ETag.
|
||||
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||
* `/listing` route was removed. We page through to the end (folders sort first
|
||||
* under `order_by=name`) and split the mixed resource items back into
|
||||
* `folders` / `files`.
|
||||
*
|
||||
* That feed carries no whole-listing ETag, so the 304 conditional fast-path is
|
||||
* gone: `opts.etag` is accepted for call-site compatibility but ignored, and the
|
||||
* in-memory `folderCache` is what the views revalidate against. Favorite/share
|
||||
* badge sets aren't part of this feed either, so they come back empty for now.
|
||||
*/
|
||||
export async function fetchFolderListing(
|
||||
folderId: string,
|
||||
opts: { etag?: string; forceRefresh?: boolean } = {}
|
||||
): Promise<FolderListingResult> {
|
||||
const headers: Record<string, string> = {};
|
||||
if (opts.etag) headers['If-None-Match'] = opts.etag;
|
||||
let url = `/api/folders/${folderId}/listing`;
|
||||
if (opts.forceRefresh) {
|
||||
url += '?force_refresh=true';
|
||||
headers['X-Force-Refresh'] = 'true';
|
||||
}
|
||||
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
|
||||
if (res.status === 304) return { status: 304 };
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
return {
|
||||
status: 200,
|
||||
listing: parseListing(await res.json()),
|
||||
etag: res.headers.get('ETag') ?? undefined
|
||||
};
|
||||
const folders: FolderItem[] = [];
|
||||
const files: FileItem[] = [];
|
||||
let cursor: string | undefined;
|
||||
do {
|
||||
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||
const page = (await res.json()) as {
|
||||
items?: { resource_type: ItemType; resource: FolderItem | FileItem }[];
|
||||
next_cursor?: string;
|
||||
};
|
||||
for (const it of page.items ?? []) {
|
||||
if (it.resource_type === 'folder') folders.push(it.resource as FolderItem);
|
||||
else files.push(it.resource as FileItem);
|
||||
}
|
||||
cursor = page.next_cursor;
|
||||
} while (cursor);
|
||||
|
||||
return { status: 200, listing: { folders, files, favoriteIds: [], sharedIds: [] } };
|
||||
}
|
||||
|
||||
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
|
||||
|
||||
Reference in New Issue
Block a user