feat(documents): the vault surface

The page becomes one wide, calm filing-cabinet view: PageHeader with an
Upload action, a drop-target upload zone whose accept list and per-file
cap come from the usage endpoint (quota bar only above 80 %), a sticky
filter rail (200 ms debounced search focused by '/', nine type chips,
condition chips from linked episodes, a year segmenter, one-tap clear),
and the virtualized timeline below. Filter state lives in the URL so
every view is shareable and back-button-safe; uploads filtered to an
episode arrive pre-linked. Hovering a card prefetches detail metadata —
never the blob. Designed states: a skeleton grid that matches the loaded
layout, a teaching empty state, a distinct no-matches state, and a
retryable error card. /documents joins the shell's wide-container list;
the shell stays the single owner of the page frame. Locale bundles gain
the vault vocabulary in all six languages and drop the retired
extraction-review strings.

Plan-Task: W2-T1, W2-T4 (zone), W2-T5 (prefetch), W2-T6
This commit is contained in:
Marc-André Bombeck committed 2026-07-07 12:26:06 +02:00
1 parent 4014d77a21
commit b8009d2e73
12 files changed
+1268 -754

No files matched your search

@@ -0,0 +1,97 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
/**
* The vault page's designed states, pinned via single-pass static renders:
*
* - first load paints the skeleton grid (`documents-loading`) — a shape
* that matches the loaded card grid, never a bare spinner;
* - an empty unfiltered corpus paints the TEACHING empty state (what
* belongs here + an upload affordance), not the error copy;
* - an empty FILTERED view paints the no-matches state with a
* clear-filters affordance — a distinct state, per the standards a
* failed query may never fall through to either.
*/
let mockSearch = "";
const pushMock = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: pushMock, replace: vi.fn() }),
usePathname: () => "/documents",
useSearchParams: () => new URLSearchParams(mockSearch),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { id: "u1", modules: { inboundDocuments: true } },
isLoading: false,
isAuthenticated: true,
}),
}));
vi.mock("@/lib/api/api-fetch", async () => {
const actual = await vi.importActual<typeof import("@/lib/api/api-fetch")>(
"@/lib/api/api-fetch",
);
return {
...actual,
// Never resolves within the synchronous SSR render → queries whose
// cache is not seeded stay pending.
apiGet: () => new Promise(() => {}),
apiPatch: vi.fn(),
apiPost: vi.fn(),
apiDelete: vi.fn(),
};
});
import { I18nProvider } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { DocumentsView } from "../documents-view";
function render(search: string, seedList?: { kinds?: never } | object) {
mockSearch = search;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
if (seedList !== undefined) {
queryClient.setQueryData(queryKeys.inboundDocumentList(seedList), {
pages: [{ documents: [], nextCursor: null }],
pageParams: [null],
});
}
return renderToStaticMarkup(
<QueryClientProvider client={queryClient}>
<I18nProvider initialLocale="en">
<DocumentsView />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<DocumentsView> states", () => {
it("first load: skeleton grid matching the card layout, no spinner", () => {
const html = render("");
expect(html).toContain('data-slot="documents-loading"');
expect(html).toContain('data-slot="skeleton"');
expect(html).toContain('data-slot="document-filter-bar"');
expect(html).toContain('data-slot="document-upload-zone"');
// A pending list must never read as an empty vault.
expect(html).not.toContain("Your document vault is empty");
});
it("empty unfiltered corpus: the teaching empty state with an upload CTA", () => {
const html = render("", {});
expect(html).toContain('data-slot="empty-state"');
expect(html).toContain("Your document vault is empty");
expect(html).toContain("Upload a first document");
expect(html).not.toContain("No matching documents");
});
it("empty filtered view: the distinct no-matches state with clear-filters", () => {
const html = render("kind=IMAGING", { kinds: ["IMAGING"] });
expect(html).toContain("No matching documents");
expect(html).toContain("Clear filters");
expect(html).not.toContain("Your document vault is empty");
});
});