mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-24 11:26:34 +00:00
fix(attachments): reload storage tab on workspace change (TASK-2418)
Claude-Session: https://claude.ai/code/session_01LmbFxQFDjcYKBLcTnor6DC
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import { api, PadApiError } from '$lib/api/client';
|
||||
import { announceAttachmentDeleted } from '$lib/attachments/events';
|
||||
@@ -159,13 +159,56 @@
|
||||
attachments.find((a) => a.item_id === filterItemId)?.item_title ?? ''
|
||||
);
|
||||
|
||||
// Re-seed when the deep-link changes under a MOUNTED tab (same route, new
|
||||
// `?attachment_item=`). Without it, the second "View all" link a user
|
||||
// follows from the same settings page silently keeps the first item's
|
||||
// scope (Codex round 3).
|
||||
// The workspace whose view is currently loaded. A plain `let` (not `$state`)
|
||||
// so writing it from the effect below can't re-trigger it.
|
||||
let loadedWsSlug: string | null = null;
|
||||
|
||||
/**
|
||||
* The tab's only load trigger. It owns BOTH transitions, in one effect and
|
||||
* one dependency set, because they can arrive in the same flush and the
|
||||
* order matters:
|
||||
*
|
||||
* - **Workspace change.** `/{user}/{ws}/settings` is one SvelteKit route,
|
||||
* so switching workspaces changes `wsSlug` under a MOUNTED tab. Without
|
||||
* a reactive reload the previous workspace's rows and usage figure stay
|
||||
* on screen, and a list request that was in flight across the switch is
|
||||
* dropped by `loadList`'s workspace fence — which, with no successor
|
||||
* request, would strand `listLoading` at true forever (final review P1).
|
||||
* Since this fires on mount too, it REPLACES the old `onMount` load
|
||||
* rather than sitting alongside it — an effect plus `onMount` would
|
||||
* fetch the first page twice.
|
||||
* - **Deep-link change.** A new `?attachment_item=` on the same route
|
||||
* (a second "View all" followed from an already-open settings page)
|
||||
* retargets the scope instead of silently keeping the first item's
|
||||
* (Codex round 3).
|
||||
*
|
||||
* A workspace change subsumes the deep-link branch: it re-seeds the scope
|
||||
* from the incoming link itself, so a single load covers both and the
|
||||
* deep-link branch can't fire a second one behind it.
|
||||
*/
|
||||
$effect(() => {
|
||||
const ws = wsSlug;
|
||||
const incoming = initialItemId;
|
||||
untrack(() => {
|
||||
if (ws !== loadedWsSlug) {
|
||||
loadedWsSlug = ws;
|
||||
// Everything workspace-scoped is meaningless in the new
|
||||
// workspace and must not survive the switch: the rows and
|
||||
// usage figure obviously, but also the two workspace-scoped
|
||||
// filter values — an item uuid and a collection uuid from the
|
||||
// old workspace would silently filter the new list down to
|
||||
// nothing. Category / attached / sort / page size are plain
|
||||
// preferences with no workspace identity, so they carry over.
|
||||
seededItemId = incoming;
|
||||
filterItemId = incoming;
|
||||
filterCollection = '';
|
||||
offset = 0;
|
||||
attachments = [];
|
||||
total = 0;
|
||||
usage = null;
|
||||
void loadWorkspaceView();
|
||||
return;
|
||||
}
|
||||
if (incoming === seededItemId) return;
|
||||
seededItemId = incoming;
|
||||
filterItemId = incoming;
|
||||
@@ -196,10 +239,24 @@
|
||||
retargetScope();
|
||||
}
|
||||
|
||||
let usageGen = 0;
|
||||
|
||||
async function loadUsage() {
|
||||
// Both halves of the list load's fence, for the same reasons. The
|
||||
// workspace check: this tab stays mounted across a workspace change, so a
|
||||
// figure fetched for the workspace the user just left must not paint over
|
||||
// the one they switched to (nor raise its error toast). The generation
|
||||
// check: on an A→B→A round trip the workspace matches again, so only the
|
||||
// generation can tell the first A request from the current one
|
||||
// (Codex round 1).
|
||||
const gen = ++usageGen;
|
||||
const reqWsSlug = wsSlug;
|
||||
try {
|
||||
usage = await api.attachments.storageUsage(wsSlug);
|
||||
const next = await api.attachments.storageUsage(reqWsSlug);
|
||||
if (gen !== usageGen || reqWsSlug !== wsSlug) return;
|
||||
usage = next;
|
||||
} catch (err) {
|
||||
if (gen !== usageGen || reqWsSlug !== wsSlug) return;
|
||||
const msg = err instanceof Error ? err.message : 'Failed to load storage usage';
|
||||
toastStore.show(msg, 'error');
|
||||
}
|
||||
@@ -267,13 +324,28 @@
|
||||
await Promise.all([loadList(), loadUsage()]);
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
/**
|
||||
* Load (or reload) everything the tab shows for the current workspace, behind
|
||||
* the whole-tab `loading` gate. Used on mount and on every workspace change.
|
||||
*/
|
||||
let viewGen = 0;
|
||||
|
||||
async function loadWorkspaceView() {
|
||||
const gen = ++viewGen;
|
||||
const reqWsSlug = wsSlug;
|
||||
loading = true;
|
||||
try {
|
||||
await Promise.all([loadList(), loadUsage()]);
|
||||
} finally {
|
||||
loading = false;
|
||||
// Only the NEWEST load lowers the gate; a superseded one doing it
|
||||
// would reveal the next workspace's half-loaded view. The workspace
|
||||
// check alone isn't enough — on an A→B→A round trip the first A load
|
||||
// finishes with `wsSlug` back at A while A's current load is still in
|
||||
// flight (Codex round 1). It can't strand: whatever supersedes a load
|
||||
// is itself a load, and the newest one's `finally` always runs.
|
||||
if (gen === viewGen && reqWsSlug === wsSlug) loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Filter / sort changes reset to the first page and refetch. Using an
|
||||
// explicit handler instead of an $effect keeps the side-effect tied to
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { flushSync, mount, unmount } from 'svelte';
|
||||
import type {
|
||||
AttachmentListItem,
|
||||
AttachmentListResponse,
|
||||
WorkspaceStorageInfo,
|
||||
} from '$lib/types';
|
||||
|
||||
// TASK-2418. The Storage tab lives at `/{user}/{ws}/settings` — ONE SvelteKit
|
||||
// route — so switching workspaces changes `wsSlug` under a MOUNTED component.
|
||||
// These tests drive that switch and pin the reactive reload: fresh rows, fresh
|
||||
// usage, no stale scope, and a loading state that can't strand when the switch
|
||||
// happens mid-flight.
|
||||
|
||||
const listMock =
|
||||
vi.fn<(ws: string, filters: Record<string, unknown>) => Promise<AttachmentListResponse>>();
|
||||
const usageMock = vi.fn<(ws: string) => Promise<WorkspaceStorageInfo>>();
|
||||
const toastMock = vi.fn<(message: string, kind?: string) => void>();
|
||||
|
||||
class FakeApiError extends Error {
|
||||
code: string;
|
||||
constructor(code: string) {
|
||||
super(code);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
PadApiError: FakeApiError,
|
||||
api: {
|
||||
attachments: {
|
||||
list: (ws: string, filters: Record<string, unknown>) => listMock(ws, filters),
|
||||
storageUsage: (ws: string) => usageMock(ws),
|
||||
downloadUrl: (ws: string, id: string, variant?: string) =>
|
||||
`/api/v1/workspaces/${ws}/attachments/${id}${variant ? `?variant=${variant}` : ''}`,
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('$app/state', () => ({
|
||||
page: { params: { username: 'dave', workspace: 'ws-a' }, url: new URL('http://x/') },
|
||||
}));
|
||||
|
||||
vi.mock('$lib/attachments/events', () => ({
|
||||
announceAttachmentDeleted: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/components/editor/attachment-metadata', () => ({
|
||||
invalidateAttachmentMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/toast.svelte', () => ({
|
||||
toastStore: { show: (message: string, kind?: string) => toastMock(message, kind) },
|
||||
}));
|
||||
|
||||
const { default: StorageTab } = await import('./StorageTab.svelte');
|
||||
|
||||
function att(overrides: Partial<AttachmentListItem> & { id: string }): AttachmentListItem {
|
||||
return {
|
||||
workspace_id: 'ws-1',
|
||||
uploaded_by: 'u-1',
|
||||
storage_key: `key/${overrides.id}`,
|
||||
content_hash: `hash-${overrides.id}`,
|
||||
mime_type: 'application/pdf',
|
||||
size_bytes: 2048,
|
||||
filename: `${overrides.id}.pdf`,
|
||||
created_at: '2026-08-01T00:00:00Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function response(attachments: AttachmentListItem[]): AttachmentListResponse {
|
||||
return { attachments, total: attachments.length, limit: 50, offset: 0 };
|
||||
}
|
||||
|
||||
function usage(used: number): WorkspaceStorageInfo {
|
||||
return { used_bytes: used, limit_bytes: 1000, override_active: false } as WorkspaceStorageInfo;
|
||||
}
|
||||
|
||||
/** A promise plus its resolver, so a test can control when a fetch lands. */
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
// Reactive props object so a test can flip `wsSlug` the way the settings route
|
||||
// does when the user switches workspaces while this tab is open.
|
||||
const props = $state<{ wsSlug: string; collections: []; initialItemId: string }>({
|
||||
wsSlug: 'ws-a',
|
||||
collections: [],
|
||||
initialItemId: '',
|
||||
});
|
||||
|
||||
describe('StorageTab workspace switching', () => {
|
||||
let target: HTMLElement;
|
||||
let instance: ReturnType<typeof mount> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
listMock.mockReset();
|
||||
listMock.mockResolvedValue(response([]));
|
||||
usageMock.mockReset();
|
||||
usageMock.mockResolvedValue(usage(1));
|
||||
toastMock.mockReset();
|
||||
props.wsSlug = 'ws-a';
|
||||
props.initialItemId = '';
|
||||
target = document.body.appendChild(document.createElement('div'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (instance) unmount(instance);
|
||||
instance = undefined;
|
||||
target.remove();
|
||||
});
|
||||
|
||||
function mountTab() {
|
||||
instance = mount(StorageTab, { target, props });
|
||||
flushSync();
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
flushSync();
|
||||
}
|
||||
|
||||
function rows(): HTMLElement[] {
|
||||
return Array.from(target.querySelectorAll<HTMLElement>('.att-row'));
|
||||
}
|
||||
|
||||
function text(): string {
|
||||
return target.textContent ?? '';
|
||||
}
|
||||
|
||||
it('loads exactly once on mount', async () => {
|
||||
mountTab();
|
||||
await settle();
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(1);
|
||||
expect(listMock).toHaveBeenCalledWith('ws-a', expect.anything());
|
||||
expect(usageMock).toHaveBeenCalledTimes(1);
|
||||
expect(usageMock).toHaveBeenCalledWith('ws-a');
|
||||
});
|
||||
|
||||
it('reloads the list and the usage figure when the workspace changes', async () => {
|
||||
listMock.mockResolvedValueOnce(response([att({ id: 'a1', filename: 'from-a.pdf' })]));
|
||||
usageMock.mockResolvedValueOnce(usage(111));
|
||||
mountTab();
|
||||
await settle();
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(text()).toContain('from-a.pdf');
|
||||
expect(text()).toContain('111 B');
|
||||
|
||||
listMock.mockResolvedValueOnce(response([att({ id: 'b1', filename: 'from-b.pdf' })]));
|
||||
usageMock.mockResolvedValueOnce(usage(222));
|
||||
props.wsSlug = 'ws-b';
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
expect(listMock).toHaveBeenLastCalledWith('ws-b', expect.anything());
|
||||
expect(usageMock).toHaveBeenLastCalledWith('ws-b');
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(text()).toContain('from-b.pdf');
|
||||
expect(text()).not.toContain('from-a.pdf');
|
||||
expect(text()).toContain('222 B');
|
||||
});
|
||||
|
||||
it('drops the old workspace item scope when the workspace changes', async () => {
|
||||
props.initialItemId = 'item-in-a';
|
||||
mountTab();
|
||||
await settle();
|
||||
expect(listMock).toHaveBeenLastCalledWith(
|
||||
'ws-a',
|
||||
expect.objectContaining({ item_id: 'item-in-a' })
|
||||
);
|
||||
expect(target.querySelector('.item-scope')).not.toBeNull();
|
||||
|
||||
// A workspace switch without a deep link: the scope named an item in the
|
||||
// workspace the user just left, so it must not narrow the new list.
|
||||
props.wsSlug = 'ws-b';
|
||||
props.initialItemId = '';
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
expect(listMock).toHaveBeenCalledTimes(2);
|
||||
expect(listMock).toHaveBeenLastCalledWith('ws-b', expect.not.objectContaining({ item_id: 'item-in-a' }));
|
||||
expect(target.querySelector('.item-scope')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not strand the loading state when the switch happens mid-flight', async () => {
|
||||
const slowA = deferred<AttachmentListResponse>();
|
||||
listMock.mockReturnValueOnce(slowA.promise);
|
||||
mountTab();
|
||||
await settle();
|
||||
expect(text()).toContain('Loading');
|
||||
|
||||
listMock.mockResolvedValueOnce(response([att({ id: 'b1', filename: 'from-b.pdf' })]));
|
||||
props.wsSlug = 'ws-b';
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
// B landed; the tab is showing B even though A is still outstanding.
|
||||
expect(text()).toContain('from-b.pdf');
|
||||
expect(text()).not.toContain('Loading attachments');
|
||||
|
||||
// A's superseded response resolves late and must change nothing — in
|
||||
// particular it must not re-raise the loading gate or paint its rows.
|
||||
slowA.resolve(response([att({ id: 'a1', filename: 'from-a.pdf' })]));
|
||||
await settle();
|
||||
expect(text()).toContain('from-b.pdf');
|
||||
expect(text()).not.toContain('from-a.pdf');
|
||||
expect(text()).not.toContain('Loading attachments');
|
||||
});
|
||||
|
||||
it('ignores the first load of an A→B→A round trip, where the workspace matches again', async () => {
|
||||
// The workspace fence can't see this one: by the time A's FIRST response
|
||||
// lands, `wsSlug` is back at 'ws-a'. Only the generation distinguishes it
|
||||
// from the load that is actually current (Codex round 1).
|
||||
const staleA = deferred<AttachmentListResponse>();
|
||||
const staleUsageA = deferred<WorkspaceStorageInfo>();
|
||||
listMock.mockReturnValueOnce(staleA.promise);
|
||||
usageMock.mockReturnValueOnce(staleUsageA.promise);
|
||||
mountTab();
|
||||
await settle();
|
||||
|
||||
props.wsSlug = 'ws-b';
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
const freshA = deferred<AttachmentListResponse>();
|
||||
listMock.mockReturnValueOnce(freshA.promise);
|
||||
usageMock.mockResolvedValueOnce(usage(333));
|
||||
props.wsSlug = 'ws-a';
|
||||
flushSync();
|
||||
await settle();
|
||||
|
||||
// The first A load resolves late. It must not lower the whole-tab gate
|
||||
// (the current A load is still pending) nor paint its rows or usage.
|
||||
staleA.resolve(response([att({ id: 'stale', filename: 'stale-a.pdf' })]));
|
||||
staleUsageA.resolve(usage(999));
|
||||
await settle();
|
||||
expect(text()).toContain('Loading storage…');
|
||||
expect(text()).not.toContain('stale-a.pdf');
|
||||
expect(text()).not.toContain('999 B');
|
||||
|
||||
// The current one does.
|
||||
freshA.resolve(response([att({ id: 'fresh', filename: 'fresh-a.pdf' })]));
|
||||
await settle();
|
||||
expect(text()).not.toContain('Loading storage…');
|
||||
expect(text()).toContain('fresh-a.pdf');
|
||||
expect(text()).not.toContain('stale-a.pdf');
|
||||
});
|
||||
|
||||
it('ignores a superseded usage response and its error toast', async () => {
|
||||
const slowA = deferred<WorkspaceStorageInfo>();
|
||||
usageMock.mockReturnValueOnce(slowA.promise);
|
||||
mountTab();
|
||||
await settle();
|
||||
|
||||
usageMock.mockResolvedValueOnce(usage(222));
|
||||
props.wsSlug = 'ws-b';
|
||||
flushSync();
|
||||
await settle();
|
||||
expect(text()).toContain('222 B');
|
||||
|
||||
slowA.reject(new Error('workspace a is gone'));
|
||||
await settle();
|
||||
expect(text()).toContain('222 B');
|
||||
expect(toastMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
// Test-only stand-in for SvelteKit's `$app/state`.
|
||||
//
|
||||
// Same reason as `app-environment.ts`: the jsdom vitest project runs without
|
||||
// the SvelteKit vite plugin, so `$app/state` has no provider and any component
|
||||
// importing it fails to RESOLVE — before `vi.mock` ever gets a chance to
|
||||
// substitute it. vitest.config.ts aliases the import here so such components
|
||||
// are mountable at all; a test that cares about the values either mutates the
|
||||
// exported objects or `vi.mock`s the specifier itself.
|
||||
//
|
||||
// Plain mutable objects, not runes: components read `page.params.x` in
|
||||
// `$derived`, which works fine against a static object for tests that don't
|
||||
// need the read to be reactive.
|
||||
export const page = {
|
||||
params: {} as Record<string, string>,
|
||||
url: new URL('http://localhost/'),
|
||||
route: { id: null as string | null },
|
||||
status: 200,
|
||||
error: null as unknown,
|
||||
data: {} as Record<string, unknown>,
|
||||
form: null as unknown,
|
||||
state: {} as Record<string, unknown>,
|
||||
};
|
||||
|
||||
export const navigating = { from: null, to: null, type: null, complete: null };
|
||||
export const updated = { current: false, check: async () => false };
|
||||
@@ -35,6 +35,7 @@ function canResolve(id: string): boolean {
|
||||
const projectRoot = fileURLToPath(new URL('.', import.meta.url));
|
||||
const $lib = fileURLToPath(new URL('./src/lib', import.meta.url));
|
||||
const appEnvironmentMock = fileURLToPath(new URL('./src/test/mocks/app-environment.ts', import.meta.url));
|
||||
const appStateMock = fileURLToPath(new URL('./src/test/mocks/app-state.ts', import.meta.url));
|
||||
|
||||
// Agent worktrees symlink `web/node_modules` to the main checkout's
|
||||
// node_modules rather than `npm install`-ing a copy (installing clobbers a
|
||||
@@ -90,8 +91,13 @@ export default defineConfig(async () => {
|
||||
resolve: {
|
||||
alias: {
|
||||
$lib,
|
||||
// No SvelteKit plugin in this project, so provide `$app/environment`.
|
||||
// No SvelteKit plugin in this project, so provide `$app/environment`
|
||||
// and `$app/state` — without a provider these don't just come back
|
||||
// undefined, they fail to RESOLVE, which is a load-time error for
|
||||
// any component that imports them (and one `vi.mock` can't rescue,
|
||||
// since resolution happens first).
|
||||
'$app/environment': appEnvironmentMock,
|
||||
'$app/state': appStateMock,
|
||||
},
|
||||
},
|
||||
server: nodeModulesRealPath
|
||||
|
||||
Reference in New Issue
Block a user