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
|
type FolderListing
|
||||||
} from './folders';
|
} from './folders';
|
||||||
|
|
||||||
type RawListing = {
|
type ResourceItem = { resource_type: 'file' | 'folder'; resource: { id: string; name?: string } };
|
||||||
folders?: unknown[];
|
type ResourcePage = { items?: ResourceItem[]; next_cursor?: string };
|
||||||
files?: unknown[];
|
|
||||||
favorite_ids?: string[];
|
|
||||||
shared_ids?: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
function fakeRes(opts: { status: number; body?: RawListing; etag?: string }): Response {
|
function fakeRes(opts: { status: number; body?: ResourcePage }): Response {
|
||||||
return {
|
return {
|
||||||
status: opts.status,
|
status: opts.status,
|
||||||
ok: opts.status >= 200 && opts.status < 300,
|
ok: opts.status >= 200 && opts.status < 300,
|
||||||
json: async () => opts.body ?? {},
|
json: async () => opts.body ?? {},
|
||||||
headers: { get: (k: string) => (k.toLowerCase() === 'etag' ? (opts.etag ?? null) : null) }
|
headers: { get: () => null }
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,39 +34,50 @@ const emptyListing = (): FolderListing => ({
|
|||||||
sharedIds: []
|
sharedIds: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const initHeaders = (call: number): Record<string, string> =>
|
|
||||||
(vi.mocked(apiFetch).mock.calls[call][1]?.headers ?? {}) as Record<string, string>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
invalidateFolderCache();
|
invalidateFolderCache();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('fetchFolderListing (conditional)', () => {
|
describe('fetchFolderListing (cursor-paginated /resources)', () => {
|
||||||
it('parses a 200, returns the ETag, and sends no If-None-Match without one', async () => {
|
it('splits one page of resources into folders + files', async () => {
|
||||||
vi.mocked(apiFetch).mockResolvedValue(
|
vi.mocked(apiFetch).mockResolvedValue(
|
||||||
fakeRes({
|
fakeRes({
|
||||||
status: 200,
|
status: 200,
|
||||||
body: { folders: [], files: [], favorite_ids: ['a'], shared_ids: ['b'] },
|
body: {
|
||||||
etag: '"v1"'
|
items: [
|
||||||
|
{ resource_type: 'folder', resource: { id: 'd1', name: 'Docs' } },
|
||||||
|
{ resource_type: 'file', resource: { id: 'x1', name: 'a.txt' } }
|
||||||
|
]
|
||||||
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const r = await fetchFolderListing('f1');
|
const r = await fetchFolderListing('f1');
|
||||||
expect(r.status).toBe(200);
|
expect(r.status).toBe(200);
|
||||||
expect(r.etag).toBe('"v1"');
|
expect(r.listing?.folders.map((f) => f.id)).toEqual(['d1']);
|
||||||
expect(r.listing?.favoriteIds).toEqual(['a']);
|
expect(r.listing?.files.map((f) => f.id)).toEqual(['x1']);
|
||||||
expect(r.listing?.sharedIds).toEqual(['b']);
|
expect(r.listing?.favoriteIds).toEqual([]);
|
||||||
expect(initHeaders(0)['If-None-Match']).toBeUndefined();
|
expect(vi.mocked(apiFetch).mock.calls[0][0]).toContain('/api/folders/f1/resources');
|
||||||
// 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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('sends If-None-Match and surfaces a 304 with no body', async () => {
|
it('follows next_cursor across pages', async () => {
|
||||||
vi.mocked(apiFetch).mockResolvedValue(fakeRes({ status: 304 }));
|
vi.mocked(apiFetch)
|
||||||
const r = await fetchFolderListing('f1', { etag: '"v1"' });
|
.mockResolvedValueOnce(
|
||||||
expect(r.status).toBe(304);
|
fakeRes({
|
||||||
expect(r.listing).toBeUndefined();
|
status: 200,
|
||||||
expect(initHeaders(0)['If-None-Match']).toBe('"v1"');
|
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 () => {
|
it('throws a 403 carrying its status', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
|
/** Folder endpoints — ported from filesModel.js + fileOperations.js. */
|
||||||
import { apiFetch, apiJson } from '$lib/api/client';
|
import { apiFetch, apiJson } from '$lib/api/client';
|
||||||
import { getCsrfHeaders } from '$lib/api/csrf';
|
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 JSON_HEADERS = { 'Content-Type': 'application/json' };
|
||||||
const NO_CACHE: RequestInit = {
|
const NO_CACHE: RequestInit = {
|
||||||
@@ -88,21 +88,6 @@ export function getFolderName(id: string): string | undefined {
|
|||||||
return folderNames.get(id);
|
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> {
|
export async function getFolder(id: string): Promise<FolderItem> {
|
||||||
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
const folder = await apiJson<FolderItem>(`/api/folders/${id}`, NO_CACHE);
|
||||||
rememberFolderName(folder.id, folder.name);
|
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
|
* Fetch a folder's complete listing (sub-folders + files), rebuilt from the
|
||||||
* `If-None-Match`; the server replies 304 (empty body) when nothing changed —
|
* cursor-paginated `/api/folders/{id}/resources` feed — the old combined
|
||||||
* the ETag covers folders + files + favorite/share badges — so the caller can
|
* `/listing` route was removed. We page through to the end (folders sort first
|
||||||
* keep its cached copy. `cache: 'no-store'` keeps the browser HTTP cache out of
|
* under `order_by=name`) and split the mixed resource items back into
|
||||||
* the way; revalidation is driven entirely by our own ETag.
|
* `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(
|
export async function fetchFolderListing(
|
||||||
folderId: string,
|
folderId: string,
|
||||||
opts: { etag?: string; forceRefresh?: boolean } = {}
|
opts: { etag?: string; forceRefresh?: boolean } = {}
|
||||||
): Promise<FolderListingResult> {
|
): Promise<FolderListingResult> {
|
||||||
const headers: Record<string, string> = {};
|
const folders: FolderItem[] = [];
|
||||||
if (opts.etag) headers['If-None-Match'] = opts.etag;
|
const files: FileItem[] = [];
|
||||||
let url = `/api/folders/${folderId}/listing`;
|
let cursor: string | undefined;
|
||||||
if (opts.forceRefresh) {
|
do {
|
||||||
url += '?force_refresh=true';
|
const params = new URLSearchParams({ order_by: 'name', limit: '200' });
|
||||||
headers['X-Force-Refresh'] = 'true';
|
if (opts.forceRefresh) params.set('force_refresh', 'true');
|
||||||
}
|
if (cursor) params.set('cursor', cursor);
|
||||||
const res = await apiFetch(url, { credentials: 'same-origin', cache: 'no-store', headers });
|
const res = await apiFetch(`/api/folders/${folderId}/resources?${params.toString()}`, {
|
||||||
if (res.status === 304) return { status: 304 };
|
credentials: 'same-origin',
|
||||||
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
cache: 'no-store'
|
||||||
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
});
|
||||||
return {
|
if (res.status === 403) throw Object.assign(new Error('Forbidden'), { status: 403 });
|
||||||
status: 200,
|
if (!res.ok) throw new Error(`listing failed: ${res.status}`);
|
||||||
listing: parseListing(await res.json()),
|
const page = (await res.json()) as {
|
||||||
etag: res.headers.get('ETag') ?? undefined
|
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). */
|
/** Non-conditional listing fetch (e.g. the move-dialog folder tree). */
|
||||||
|
|||||||
Reference in New Issue
Block a user