From 7ddf6967ceb4151a09bf6ffe8a492dec6f17fb09 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 20:37:11 +0200 Subject: [PATCH 1/5] =?UTF-8?q?docs(specs):=20split=20queries.ts=20?= =?UTF-8?q?=E2=80=94=20errors=20+=20key=20factory=20+=20domain=20modules?= =?UTF-8?q?=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../specs/2026-06-08-split-queries-design.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-split-queries-design.md diff --git a/docs/superpowers/specs/2026-06-08-split-queries-design.md b/docs/superpowers/specs/2026-06-08-split-queries-design.md new file mode 100644 index 0000000..caafdc4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-split-queries-design.md @@ -0,0 +1,146 @@ +# Split queries.ts: Errors + Query-Key Factory + Domain Modules — Design + +**Date:** 2026-06-08 +**Status:** Approved (brainstorming) — ready for implementation planning. +**Issue:** #65 (split the 584-line `queries.ts`, extract the error classes, add a query-key factory, decide the search-invalidation gap). + +## Context + +`web/src/api/queries.ts` is 584 lines spanning 8 domains (error classes, auth, objects, fields-on-object, +field-definitions, vocab, terms, authorities, search). No hook crosses a domain boundary, so a split is +low-risk. Three concrete problems: + +- **Toast path drags in the hook module.** After #63, `query-client.ts` imports `errorMessageKey` from + `error-message.ts`, which imports `{ HttpError, InUseError }` from `./queries` — so the toast wiring + transitively loads all the hooks. Extracting the error classes breaks that chain. +- **No query-key factory.** ~20 key literals (`["objects", params]`, `["object", id]`, + `["terms", vocabularyId]`, …) plus `config-provider.tsx`'s `["config"]` are hand-written and + typo-prone. +- **Object writes don't invalidate `["search"]`.** `useUpdateObject`/`useDeleteObject`/`useSetVisibility` + invalidate `["objects"]`/`["object", id]` but never the search index, so the search panel keeps stale + hits until the user re-searches. **Decision (brainstorming): invalidate** — conventional + eventually-consistent behaviour; the query is `enabled` only with a term, so it's a no-op when not + searching. + +**Constraint:** ~30 files import from `../api/queries`. The public import path must stay stable (a +barrel), so the refactor touches the data layer only — feature files don't change. + +## Components + +### `api/errors.ts` (new) — canonical home for the 4 error classes +Move `HttpError`, `FieldRejection`, `InUseError`, `VisibilityError` here verbatim (no behaviour change). +`error-message.ts` changes its import from `./queries` to `./errors` — this is the decoupling: the toast +path (`query-client → error-message → errors`) no longer loads the hook module. The barrel (below) +**re-exports** the error classes (`export * from "../errors"`), so the existing importers that pull them +from `../api/queries` — `object-edit-form.tsx`, `object-new-page.tsx`, `publish-control.tsx`, +`search-panel.tsx`, and the tests `mutation-error.test.tsx` / `labelled-record-row.test.tsx` / +`error-message.test.ts` — keep working unchanged. + +### `api/query-keys.ts` (new) — one key factory +```ts +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, // family prefix (invalidation) + objectsPage: (p: ObjectListParams) => ["objects", p] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string) => ["terms", vocabularyId] as const, + authorities: (kind: string) => ["authorities", kind] as const, + search: () => ["search"] as const, // family prefix (invalidation) + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; +``` +- Every `queryKey:` / `invalidateQueries` / `setQueryData` site in the query modules **and** + `config-provider.tsx`'s `["config"]` routes through `keys.*` — producing the identical arrays, so + cache behaviour is unchanged. (`keys.objects()` prefix-matches `keys.objectsPage(p)` exactly as + `["objects"]` matches `["objects", params]` today.) +- `ObjectListParams` lives here (it *is* part of the key); `objects.ts` imports it from here — one + direction, no import cycle. (It moves out of `queries.ts`'s objects section.) + +### `api/queries/` (new directory) — split by domain + barrel +``` +api/ + client.ts (unchanged) + errors.ts (new) + query-keys.ts (new) + query-client.ts (unchanged — already imports only error-message) + error-message.ts (one-line change: import errors from ./errors) + queries/ + index.ts barrel: `export * from "./auth"; … ; export * from "../errors";` + auth.ts useMe, useLogin, useLogout + objects.ts useObjectsPage, useObject, useCreateObject, useUpdateObject, useSetFields, useDeleteObject, useSetVisibility + field-defs.ts useFieldDefinitions, useCreateFieldDefinition, useUpdateFieldDefinition, useDeleteFieldDefinition + vocab.ts useVocabularies, useCreateVocabulary, useRenameVocabulary, useDeleteVocabulary, useTerms, useAddTerm, useUpdateTerm, useDeleteTerm + authorities.ts useAuthorities, useCreateAuthority, useUpdateAuthority, useDeleteAuthority + search.ts useSearch, SEARCH_PAGE +``` +`api/queries.ts` is deleted; `import { … } from "../api/queries"` resolves to `api/queries/index.ts`. +Each module imports `api` from `../client`, types from `../schema`, error classes from `../errors`, and +`keys` from `../query-keys`. The hook bodies are moved verbatim (only their key literals → `keys.*`). + +### `api/query-client.ts` +No change needed — after #63 it imports only `errorMessageKey`. (Once `error-message.ts` points at +`./errors`, the `query-client → error-message → queries` hook dependency is gone.) + +### Search invalidation (`objects.ts`) +`useUpdateObject`, `useDeleteObject`, `useSetVisibility` add +`void qc.invalidateQueries({ queryKey: keys.search() })` alongside their existing object invalidations. + +## Data flow / behaviour +Identical to today except: after an object update/delete/visibility-change, an active search query +refetches (eventually-consistent with the Meilisearch reindex). All keys, hooks, error classes, and +endpoints are otherwise unchanged. + +## Error handling / edges +- The barrel re-exporting errors means the classes are importable from both `../api/errors` (canonical) + and `../api/queries` (compat) — intentional, to avoid churning ~6 importers + tests. +- `as const` keys are readonly tuples; TanStack Query accepts readonly arrays as keys — no type issue. +- No import cycle: `query-keys.ts` exports `ObjectListParams` + `keys` and imports nothing from + `queries/`; `objects.ts` imports both from `query-keys.ts`. +- The search query stays `enabled: term.length > 0`, so invalidating `["search"]` is a no-op when no + search is active. + +## Testing +- **Behavior guard (must pass unchanged):** `queries.test.ts`, `queries.authoring.test.tsx`, + `queries.fields.test.tsx`, `queries.search.test.tsx`, `queries.visibility.test.tsx`, + `queries.vocab.test.tsx`, `mutation-feedback.test.tsx`, `error-message.test.ts`, + `mutation-error.test.tsx`, `labelled-record-row.test.tsx` — all import from `../api/queries` (the + barrel) and from the re-exported error classes; they must not need edits. The full app suite is the + integration guard. +- **New `query-keys.test.ts`:** assert the factory arrays — e.g. `keys.objects()` → `["objects"]`, + `keys.objectsPage(p)` → `["objects", p]`, `keys.object("x")` → `["object", "x"]`, + `keys.searchResults("q", null)` → `["search", "q", null]`. +- **Search-invalidation assertion:** extend `queries.visibility.test.tsx` (or `.authoring`) to seed an + active `["search", …]` query in the cache, run an object update/delete/set-visibility, and assert the + search query is invalidated (e.g. its `isInvalidated`/refetch fires). +- **Gate:** `typecheck`/`lint`/`test`/`build`/`check:size`/`check:colors` green; no new dependency; no new + i18n keys; no codename; `check:size` unchanged (pure reorg + one invalidate call). + +## Acceptance criteria +1. `api/errors.ts` holds the 4 error classes; `error-message.ts` imports them from `./errors`; the barrel + re-exports them so every existing importer (components + tests) still resolves. +2. `queries.ts` is split into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` + + `index.ts`; `api/queries.ts` is deleted; the `../api/queries` import path is unchanged for all ~30 + consumers. +3. A `keys` factory in `api/query-keys.ts` is used by every `queryKey`/invalidate/setQueryData site, + including `config-provider.tsx`. +4. `useUpdateObject`/`useDeleteObject`/`useSetVisibility` invalidate `keys.search()`. +5. All existing tests pass unchanged; `typecheck`/`lint`/`build`/`check:colors` green; `check:size` + unchanged; no new dependency; no new i18n keys; no codename. + +## Out of scope → follow-ups +- No hook signature/behaviour changes beyond the search invalidation; no endpoint changes. +- `staleTime` / `keepPreviousData` choices stay as-is (intentional per #63's note). +- Repointing the 4 component error-importers from the barrel to `../api/errors` (the re-export keeps them + working; a later cosmetic cleanup could do it). From a21ab85576e5200ce09a3092db977223c67ae149 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 20:46:29 +0200 Subject: [PATCH 2/5] =?UTF-8?q?docs(plans):=20split=20queries.ts=20?= =?UTF-8?q?=E2=80=94=203-task=20plan=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../plans/2026-06-08-split-queries.md | 344 ++++++++++++++++++ 1 file changed, 344 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-split-queries.md diff --git a/docs/superpowers/plans/2026-06-08-split-queries.md b/docs/superpowers/plans/2026-06-08-split-queries.md new file mode 100644 index 0000000..7e644b7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-split-queries.md @@ -0,0 +1,344 @@ +# Split queries.ts — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract the 4 error classes to `api/errors.ts`, add a `keys` query-key factory in `api/query-keys.ts` (and invalidate `["search"]` on object writes), then split `queries.ts` into `api/queries/{auth,objects,field-defs,vocab,authorities,search}.ts` behind a stable `api/queries/index.ts` barrel — behavior-preserving except the search invalidation. + +**Architecture:** Three ordered, individually-green tasks. Task 1 extracts errors (queries.ts re-exports them). Task 2 adds the key factory + search invalidation (still monolithic). Task 3 moves the now-final hook bodies into domain modules behind a barrel that keeps `../api/queries` stable for all ~30 consumers. + +**Tech Stack:** React 19 + TS + pnpm, TanStack Query v5, openapi-fetch, Vitest 4 (jsdom) + RTL + MSW. + +**Conventions:** pnpm; **no `any`/`eslint-disable`/`@ts-ignore`**; no codename; double-quote+semicolon. Run a single test pass per task. Behavior-preserving except the one search-invalidation change. + +**Spec:** `docs/superpowers/specs/2026-06-08-split-queries-design.md` + +**Key facts:** +- `web/src/api/queries.ts` (584 lines) currently defines 4 error classes (`HttpError` :6, `FieldRejection` :13, `InUseError` :20, `VisibilityError` :394) + `type ObjectListParams` (:46) + all hooks. +- Error-class importers (non-test): `api/error-message.ts` (`HttpError, InUseError`), `objects/object-edit-form.tsx` + `objects/object-new-page.tsx` (`FieldRejection`), `objects/publish-control.tsx` (`VisibilityError`), `search/search-panel.tsx` (`HttpError`) — all via `../api/queries`. Tests: `mutation-error.test.tsx`, `labelled-record-row.test.tsx`, `error-message.test.ts` import error classes from `../api/queries`. +- ~30 files import hooks from `../api/queries`. Query-layer test suites: `queries.test.ts`, `queries.authoring.test.tsx`, `queries.fields.test.tsx`, `queries.search.test.tsx`, `queries.visibility.test.tsx`, `queries.vocab.test.tsx`, `mutation-feedback.test.tsx`. +- Key literals: `["me"]`, `["config"]` (in `config/config-provider.tsx:10`), `["objects", params]`, `["objects"]`, `["object", id]`, `["field-definitions"]`, `["terms", vocabularyId]`, `["authorities", kind]`, `["vocabularies"]`, `["search", term, visibility]`. +- `useTerms`/`useAuthorities` key on a `string | null | undefined` arg (enabled-gated), so `keys.terms`/`keys.authorities` must accept that union. + +--- + +# Task 1: Extract error classes → `api/errors.ts` + +**Files:** Create `web/src/api/errors.ts`; Modify `web/src/api/queries.ts`, `web/src/api/error-message.ts`. + +- [ ] **Step 1: Create `web/src/api/errors.ts`** (move the 4 classes verbatim): +```ts +export class HttpError extends Error { + constructor(public readonly status: number) { + super(`HTTP ${status}`); + this.name = "HttpError"; + } +} + +export class FieldRejection extends Error { + constructor(public readonly field: string, public readonly code: string) { + super(`field rejected: ${field}`); + this.name = "FieldRejection"; + } +} + +export class InUseError extends Error { + constructor(public readonly count: number) { + super(`in use: ${count}`); + this.name = "InUseError"; + } +} + +/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ +export class VisibilityError extends Error { + constructor(public status: number) { + super(`visibility change failed (${status})`); + this.name = "VisibilityError"; + } +} +``` + +- [ ] **Step 2: Update `web/src/api/queries.ts`.** DELETE the 4 class definitions (lines ~6-25 `HttpError`/`FieldRejection`/`InUseError`, and ~393-399 `VisibilityError`). At the top of the file (after the existing `import` lines), add an import for use + a re-export for compatibility: +```ts +import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; + +export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; +``` +(The `import` binds them for the throw sites in this file; the `export … from` re-exports them so every consumer importing from `../api/queries` keeps working. Everything else in `queries.ts` is unchanged.) + +- [ ] **Step 3: Repoint `web/src/api/error-message.ts`.** Change `import { HttpError, InUseError } from "./queries";` to `import { HttpError, InUseError } from "./errors";`. (This is the decoupling: the toast path no longer transitively loads the hook module.) + +- [ ] **Step 4: Verify (vitest ONCE for the affected suites), typecheck, lint:** +```bash +cd web && pnpm vitest run src/api/error-message.test.ts src/api/mutation-feedback.test.tsx src/api/queries.test.ts src/components/mutation-error.test.tsx src/components/labelled-record-row.test.tsx && pnpm typecheck && pnpm lint +``` +Expected: green. The error classes are now sourced from `errors.ts` but re-exported, so all importers resolve. If typecheck flags an unused import in `queries.ts`, ensure each of the 4 classes is actually thrown somewhere in the file (they are: `HttpError` many sites, `FieldRejection` in `useSetFields`, `InUseError` in the delete mutations, `VisibilityError` in `useSetVisibility`) — keep all four in the import. + +- [ ] **Step 5: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/errors.ts web/src/api/queries.ts web/src/api/error-message.ts +git commit -m "refactor(web): extract API error classes to api/errors.ts (#65)" +``` + +--- + +# Task 2: Query-key factory + search invalidation + +**Files:** Create `web/src/api/query-keys.ts`, `web/src/api/query-keys.test.ts`, `web/src/api/search-invalidation.test.tsx`; Modify `web/src/api/queries.ts`, `web/src/config/config-provider.tsx`. + +- [ ] **Step 1: Create `web/src/api/query-keys.ts`:** +```ts +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +/** Central query-key factory — the single source of truth for cache keys, so + * query/invalidate/setQueryData sites can't drift. */ +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, + objectsPage: (params: ObjectListParams) => ["objects", params] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string | null | undefined) => ["terms", vocabularyId] as const, + authorities: (kind: string | null | undefined) => ["authorities", kind] as const, + search: () => ["search"] as const, + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; +``` + +- [ ] **Step 2: Create `web/src/api/query-keys.test.ts`** (write + run): +```ts +import { expect, test } from "vitest"; + +import { keys } from "./query-keys"; + +test("the key factory produces the expected arrays", () => { + expect(keys.me()).toEqual(["me"]); + expect(keys.config()).toEqual(["config"]); + expect(keys.objects()).toEqual(["objects"]); + const p = { limit: 50, offset: 0 }; + expect(keys.objectsPage(p)).toEqual(["objects", p]); + expect(keys.object("x")).toEqual(["object", "x"]); + expect(keys.fieldDefinitions()).toEqual(["field-definitions"]); + expect(keys.vocabularies()).toEqual(["vocabularies"]); + expect(keys.terms("v1")).toEqual(["terms", "v1"]); + expect(keys.authorities("person")).toEqual(["authorities", "person"]); + expect(keys.search()).toEqual(["search"]); + expect(keys.searchResults("q", null)).toEqual(["search", "q", null]); +}); + +test("objects() is a prefix of objectsPage() so invalidation matches", () => { + const prefix = keys.objects(); + const full = keys.objectsPage({ limit: 50, offset: 0 }); + expect(full.slice(0, prefix.length)).toEqual(prefix); +}); +``` +Run: `cd web && pnpm vitest run src/api/query-keys.test.ts`. + +- [ ] **Step 3: Replace every key literal in `web/src/api/queries.ts` with `keys.*`.** Add `import { keys, type ObjectListParams } from "./query-keys";` and DELETE the local `export type ObjectListParams = {…};` block (now imported). Substitutions (every occurrence): + - `queryKey: ["me"]` → `queryKey: keys.me()`; `qc.invalidateQueries({ queryKey: ["me"] })` → `keys.me()`; `qc.setQueryData(["me"], null)` → `qc.setQueryData(keys.me(), null)` + - `queryKey: ["objects", params]` → `keys.objectsPage(params)` + - `["objects"]` (invalidations) → `keys.objects()` + - `["object", id]` → `keys.object(id)` + - `["field-definitions"]` → `keys.fieldDefinitions()` + - `["terms", vocabularyId]` → `keys.terms(vocabularyId)` + - `["authorities", kind]` → `keys.authorities(kind)` + - `["vocabularies"]` → `keys.vocabularies()` + - `queryKey: ["search", term, visibility]` → `keys.searchResults(term, visibility)` + Re-export the type so consumers importing `ObjectListParams` from `../api/queries` keep working: add `export type { ObjectListParams } from "./query-keys";` near the top. + +- [ ] **Step 4: Add search invalidation (`web/src/api/queries.ts`).** In each of these `onSuccess` handlers add `void qc.invalidateQueries({ queryKey: keys.search() });`: + - `useUpdateObject` onSuccess (after the `objects`/`object` invalidations) + - `useDeleteObject` onSuccess (alongside the `objects` invalidation — convert it to a block: `onSuccess: () => { void qc.invalidateQueries({ queryKey: keys.objects() }); void qc.invalidateQueries({ queryKey: keys.search() }); }`) + - `useSetVisibility` onSuccess (after the `object`/`objects` invalidations) + +- [ ] **Step 5: Update `web/src/config/config-provider.tsx`.** Add `import { keys } from "../api/query-keys";` and change `queryKey: ["config"]` → `queryKey: keys.config()`. + +- [ ] **Step 6: Create `web/src/api/search-invalidation.test.tsx`** (write + run) — proves the new behavior: +```tsx +import { expect, test } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; + +import { server } from "../test/server"; +import { useSetVisibility } from "./queries"; +import { keys } from "./query-keys"; + +test("changing an object's visibility invalidates the active search query", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(keys.searchResults("amphora", null), { pages: [], pageParams: [] }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + + await result.current.mutateAsync({ id: "o1", visibility: "public" }); + + await waitFor(() => + expect(qc.getQueryState(keys.searchResults("amphora", null))?.isInvalidated).toBe(true), + ); +}); +``` +Run: `cd web && pnpm vitest run src/api/search-invalidation.test.tsx`. (If `isInvalidated` is flaky, assert `qc.getQueryState(keys.searchResults("amphora", null))` exists and was marked stale via `isInvalidated`; the mutation's `onSuccess` runs the invalidation synchronously after the 204.) + +- [ ] **Step 7: Verify (vitest ONCE for the query suites), typecheck, lint:** +```bash +cd web && pnpm vitest run src/api/query-keys.test.ts src/api/search-invalidation.test.tsx src/api/queries.test.ts src/api/queries.authoring.test.tsx src/api/queries.fields.test.tsx src/api/queries.search.test.tsx src/api/queries.visibility.test.tsx src/api/queries.vocab.test.tsx src/config && pnpm typecheck && pnpm lint +``` +Expected: green. The key arrays are identical to before, so all existing query tests pass unchanged. + +- [ ] **Step 8: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/query-keys.ts web/src/api/query-keys.test.ts web/src/api/search-invalidation.test.tsx web/src/api/queries.ts web/src/config/config-provider.tsx +git commit -m "refactor(web): central query-key factory + invalidate search on object writes (#65)" +``` + +--- + +# Task 3: Split queries.ts into `api/queries/` domain modules + +**Files:** Create `web/src/api/queries/{index,auth,objects,field-defs,vocab,authorities,search}.ts`; Delete `web/src/api/queries.ts`. + +**Approach:** Move each hook (and its local `type X = components[...]` aliases) VERBATIM from the current `queries.ts` into its domain module — the bodies already use `keys.*` and the `errors.ts` classes after Tasks 1-2. Only the relative import paths change (`./client`→`../client`, `./schema`→`../schema`, `./errors`, `./query-keys`). Then add the barrel and delete `queries.ts`. + +- [ ] **Step 1: `web/src/api/queries/auth.ts`** — header + move `useMe`, `useLogin`, `useLogout`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { keys } from "../query-keys"; + +type UserView = components["schemas"]["UserView"]; +type LoginRequest = components["schemas"]["LoginRequest"]; +``` +(These three throw only plain `Error` — no `errors.ts` import needed here.) + +- [ ] **Step 2: `web/src/api/queries/objects.ts`** — header + move `useObjectsPage`, `useObject`, `useCreateObject`, `useUpdateObject`, `useSetFields`, `useDeleteObject`, `useSetVisibility`: +```ts +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, FieldRejection, VisibilityError } from "../errors"; +import { keys, type ObjectListParams } from "../query-keys"; + +type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; +type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; +type Visibility = "draft" | "internal" | "public"; +``` +(`ObjectListParams` now comes from `query-keys`. `useObjectsPage`/`useObject` query fns throw plain `Error`; the mutations use the imported error classes.) + +- [ ] **Step 3: `web/src/api/queries/field-defs.ts`** — header + move `useFieldDefinitions`, `useCreateFieldDefinition`, `useUpdateFieldDefinition`, `useDeleteFieldDefinition`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 4: `web/src/api/queries/vocab.ts`** — header + move `useVocabularies`, `useCreateVocabulary`, `useRenameVocabulary`, `useDeleteVocabulary`, `useTerms`, `useAddTerm`, `useUpdateTerm`, `useDeleteTerm`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 5: `web/src/api/queries/authorities.ts`** — header + move `useAuthorities`, `useCreateAuthority`, `useUpdateAuthority`, `useDeleteAuthority`: +```ts +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type LabelInput = components["schemas"]["LabelInput"]; +``` + +- [ ] **Step 6: `web/src/api/queries/search.ts`** — header + move the `SEARCH_PAGE` const and `useSearch`: +```ts +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; + +import { api } from "../client"; +import { HttpError } from "../errors"; +import { keys } from "../query-keys"; + +const SEARCH_PAGE = 20; +``` + +- [ ] **Step 7: `web/src/api/queries/index.ts`** (barrel): +```ts +export * from "./auth"; +export * from "./objects"; +export * from "./field-defs"; +export * from "./vocab"; +export * from "./authorities"; +export * from "./search"; +export * from "../errors"; +export type { ObjectListParams } from "../query-keys"; +``` + +- [ ] **Step 8: Delete the old monolith:** `git rm web/src/api/queries.ts` (every hook has been moved; the barrel + modules now provide the same exports). Confirm no hook/type was dropped: each of the 24 hooks + `ObjectListParams` + the 4 error classes is exported via the barrel. + +- [ ] **Step 9: FULL FRONTEND GATE (run tests EXACTLY ONCE):** +```bash +cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size && pnpm check:colors +``` +All green. The `../api/queries` import path now resolves to `api/queries/index.ts`, so all ~30 consumers + the query-layer test suites resolve unchanged. If typecheck reports a missing export, a hook landed in the wrong module or an import path is off — fix the module, do NOT edit consumers/tests. Report test totals, largest chunk (gz), and the `check:colors` line. + +- [ ] **Step 10: Codename + status:** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git grep -in 'biggus\|dickus' -- web/src; echo "codename-exit=$?" +git status --short +``` +Expected: no matches (`codename-exit=1`); `web/src/api/queries.ts` shows as deleted, the 7 new files added. + +- [ ] **Step 11: Commit** +```bash +cd /Users/olsson/Laboratory/biggus-dickus +git add web/src/api/queries/ && git rm -q web/src/api/queries.ts 2>/dev/null; git add -A web/src/api +git commit -m "refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65)" +``` + +--- + +## Self-Review (completed) + +**Spec coverage:** AC1 errors extracted + error-message repointed + barrel re-export (T1, T3 S7); AC2 directory split + queries.ts deleted + stable path (T3); AC3 key factory used everywhere incl. config-provider (T2 S3/S5); AC4 search invalidation on the 3 object mutations (T2 S4); AC5 existing tests unchanged + gate (T1 S4, T2 S7, T3 S9). ✓ + +**Placeholder scan:** every new file shown in full or as a precise header + verbatim-move instruction; the move tasks name the exact hook list per module; tests have concrete assertions. No TBD. ✓ + +**Type/consistency:** `keys` (T2) is the same object consumed in T3's modules; `ObjectListParams` defined in `query-keys.ts` (T2), imported by `objects.ts` (T3 S2) and re-exported by the barrel (T3 S7); error classes from `errors.ts` (T1) imported by `objects/field-defs/vocab/authorities/search` modules (T3) and re-exported by the barrel; `keys.terms`/`keys.authorities` accept `string | null | undefined` to match the enabled-gated query usage. ✓ + +## Notes +- No new dependency, no new i18n keys, `components/ui/*` untouched. `check:size` should be unchanged (pure reorg + one invalidate call). Barrel keeps `../api/queries` stable → zero consumer churn. +- The error classes are intentionally importable from both `../api/errors` (canonical) and `../api/queries` (compat re-export). Repointing the 4 component importers to `../api/errors` is a deferred cosmetic follow-up. +- `auth.ts` needs no `errors.ts` import (its throws are plain `Error`); every other module imports the error classes it throws. From c1bddb47c4ae62f3bb31b563d4d07db83a1258cd Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 21:27:21 +0200 Subject: [PATCH 3/5] refactor(web): extract API error classes to api/errors.ts (#65) --- web/src/api/error-message.ts | 2 +- web/src/api/errors.ts | 28 ++++++++++++++++++++++++++++ web/src/api/queries.ts | 30 ++---------------------------- 3 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 web/src/api/errors.ts diff --git a/web/src/api/error-message.ts b/web/src/api/error-message.ts index b4538a3..1ad45e2 100644 --- a/web/src/api/error-message.ts +++ b/web/src/api/error-message.ts @@ -1,4 +1,4 @@ -import { HttpError, InUseError } from "./queries"; +import { HttpError, InUseError } from "./errors"; /** Maps a caught mutation error to an i18n key (+ interpolation opts). The single * source of truth shared by the global toast fallback and every inline display. */ diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..32b2a8f --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,28 @@ +export class HttpError extends Error { + constructor(public readonly status: number) { + super(`HTTP ${status}`); + this.name = "HttpError"; + } +} + +export class FieldRejection extends Error { + constructor(public readonly field: string, public readonly code: string) { + super(`field rejected: ${field}`); + this.name = "FieldRejection"; + } +} + +export class InUseError extends Error { + constructor(public readonly count: number) { + super(`in use: ${count}`); + this.name = "InUseError"; + } +} + +/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ +export class VisibilityError extends Error { + constructor(public status: number) { + super(`visibility change failed (${status})`); + this.name = "VisibilityError"; + } +} diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index b004e4f..d0a37ba 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -2,27 +2,9 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClie import { api } from "./client"; import type { components } from "./schema"; +import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; -export class HttpError extends Error { - constructor(public readonly status: number) { - super(`HTTP ${status}`); - this.name = "HttpError"; - } -} - -export class FieldRejection extends Error { - constructor(public readonly field: string, public readonly code: string) { - super(`field rejected: ${field}`); - this.name = "FieldRejection"; - } -} - -export class InUseError extends Error { - constructor(public readonly count: number) { - super(`in use: ${count}`); - this.name = "InUseError"; - } -} +export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; type UserView = components["schemas"]["UserView"]; type LoginRequest = components["schemas"]["LoginRequest"]; @@ -390,14 +372,6 @@ export function useCreateFieldDefinition() { type Visibility = "draft" | "internal" | "public"; -/** Error carrying the HTTP status so callers can branch 422-gate vs 409-illegal. */ -export class VisibilityError extends Error { - constructor(public status: number) { - super(`visibility change failed (${status})`); - this.name = "VisibilityError"; - } -} - export function useSetVisibility() { const qc = useQueryClient(); From 704b159d48c1ec23994bde15d326c8cb20967cce Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 21:30:57 +0200 Subject: [PATCH 4/5] refactor(web): central query-key factory + invalidate search on object writes (#65) Co-Authored-By: Claude Opus 4.8 (1M context) --- web/src/api/queries.ts | 74 ++++++++++++------------ web/src/api/query-keys.test.ts | 24 ++++++++ web/src/api/query-keys.ts | 24 ++++++++ web/src/api/search-invalidation.test.tsx | 28 +++++++++ web/src/config/config-provider.tsx | 3 +- 5 files changed, 114 insertions(+), 39 deletions(-) create mode 100644 web/src/api/query-keys.test.ts create mode 100644 web/src/api/query-keys.ts create mode 100644 web/src/api/search-invalidation.test.tsx diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts index d0a37ba..d26cbca 100644 --- a/web/src/api/queries.ts +++ b/web/src/api/queries.ts @@ -1,17 +1,19 @@ import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api } from "./client"; +import { keys, type ObjectListParams } from "./query-keys"; import type { components } from "./schema"; import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; +export type { ObjectListParams } from "./query-keys"; type UserView = components["schemas"]["UserView"]; type LoginRequest = components["schemas"]["LoginRequest"]; export function useMe() { return useQuery({ - queryKey: ["me"], + queryKey: keys.me(), queryFn: async (): Promise => { const { data, response } = await api.GET("/api/admin/me"); @@ -25,18 +27,9 @@ export function useMe() { }); } -export type ObjectListParams = { - limit: number; - offset: number; - sort?: string; - order?: "asc" | "desc"; - visibility?: string; - q?: string; -}; - export function useObjectsPage(params: ObjectListParams) { return useQuery({ - queryKey: ["objects", params], + queryKey: keys.objectsPage(params), placeholderData: keepPreviousData, queryFn: async () => { const { data, error } = await api.GET("/api/admin/objects", { @@ -61,7 +54,7 @@ export function useObjectsPage(params: ObjectListParams) { export function useObject(id: string) { return useQuery({ - queryKey: ["object", id], + queryKey: keys.object(id), queryFn: async () => { const { data, response } = await api.GET("/api/admin/objects/{id}", { params: { path: { id } }, @@ -80,7 +73,7 @@ export function useObject(id: string) { export function useFieldDefinitions() { return useQuery({ - queryKey: ["field-definitions"], + queryKey: keys.fieldDefinitions(), queryFn: async () => { const { data, error } = await api.GET("/api/admin/field-definitions"); @@ -103,7 +96,7 @@ export function useLogin() { throw new Error(response.status === 401 ? "invalid" : "network"); } }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["me"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }), meta: { suppressErrorToast: true }, }); } @@ -115,7 +108,7 @@ export function useLogout() { mutationFn: async () => { await api.POST("/api/admin/logout"); }, - onSuccess: () => qc.setQueryData(["me"], null), + onSuccess: () => qc.setQueryData(keys.me(), null), }); } @@ -124,7 +117,7 @@ type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; export function useTerms(vocabularyId: string | null | undefined) { return useQuery({ - queryKey: ["terms", vocabularyId], + queryKey: keys.terms(vocabularyId), enabled: !!vocabularyId, queryFn: async () => { const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { @@ -141,7 +134,7 @@ export function useTerms(vocabularyId: string | null | undefined) { export function useAuthorities(kind: string | null | undefined) { return useQuery({ - queryKey: ["authorities", kind], + queryKey: keys.authorities(kind), enabled: !!kind, queryFn: async () => { const { data, error } = await api.GET("/api/admin/authorities", { @@ -167,7 +160,7 @@ export function useCreateObject() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -185,8 +178,9 @@ export function useUpdateObject() { if (response.status !== 204) throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: ["objects"] }); - void qc.invalidateQueries({ queryKey: ["object", id] }); + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.search() }); }, meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); @@ -212,7 +206,7 @@ export function useSetFields() { throw new HttpError(response.status); }, onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: ["object", id] }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); }, meta: { suppressErrorToast: true }, }); @@ -229,7 +223,10 @@ export function useDeleteObject() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["objects"] }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -239,7 +236,7 @@ type LabelInput = components["schemas"]["LabelInput"]; export function useVocabularies() { return useQuery({ - queryKey: ["vocabularies"], + queryKey: keys.vocabularies(), queryFn: async () => { const { data, error } = await api.GET("/api/admin/vocabularies"); @@ -262,7 +259,7 @@ export function useCreateVocabulary() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -288,7 +285,7 @@ export function useAddTerm() { if (response.status !== 201) throw new HttpError(response.status); }, onSuccess: (_result, { vocabularyId }) => - qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -313,7 +310,7 @@ export function useCreateAuthority() { if (response.status !== 201) throw new HttpError(response.status); }, onSuccess: (_result, { kind }) => - qc.invalidateQueries({ queryKey: ["authorities", kind] }), + qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -324,7 +321,7 @@ export function useSearch(q: string, visibility: string | null) { const term = q.trim(); return useInfiniteQuery({ - queryKey: ["search", term, visibility], + queryKey: keys.searchResults(term, visibility), enabled: term.length > 0, initialPageParam: 0, queryFn: async ({ pageParam }) => { @@ -365,7 +362,7 @@ export function useCreateFieldDefinition() { return data; }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.created", suppressErrorToast: true }, }); } @@ -385,8 +382,9 @@ export function useSetVisibility() { if (response.status !== 204) throw new VisibilityError(response.status); }, onSuccess: (_result, { id }) => { - void qc.invalidateQueries({ queryKey: ["object", id] }); - void qc.invalidateQueries({ queryKey: ["objects"] }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); }, meta: { successMessage: "toast.published", suppressErrorToast: true }, }); @@ -414,7 +412,7 @@ export function useUpdateTerm() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -431,7 +429,7 @@ export function useDeleteTerm() { if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: ["terms", vocabularyId] }), + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -448,7 +446,7 @@ export function useRenameVocabulary() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.renamed", suppressErrorToast: true }, }); } @@ -465,7 +463,7 @@ export function useDeleteVocabulary() { if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -491,7 +489,7 @@ export function useUpdateAuthority() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -508,7 +506,7 @@ export function useDeleteAuthority() { if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: ["authorities", kind] }), + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } @@ -535,7 +533,7 @@ export function useUpdateFieldDefinition() { if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.saved", suppressErrorToast: true }, }); } @@ -552,7 +550,7 @@ export function useDeleteFieldDefinition() { if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); if (response.status !== 204) throw new HttpError(response.status); }, - onSuccess: () => qc.invalidateQueries({ queryKey: ["field-definitions"] }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), meta: { successMessage: "toast.deleted", suppressErrorToast: true }, }); } diff --git a/web/src/api/query-keys.test.ts b/web/src/api/query-keys.test.ts new file mode 100644 index 0000000..ddc6c09 --- /dev/null +++ b/web/src/api/query-keys.test.ts @@ -0,0 +1,24 @@ +import { expect, test } from "vitest"; + +import { keys } from "./query-keys"; + +test("the key factory produces the expected arrays", () => { + expect(keys.me()).toEqual(["me"]); + expect(keys.config()).toEqual(["config"]); + expect(keys.objects()).toEqual(["objects"]); + const p = { limit: 50, offset: 0 }; + expect(keys.objectsPage(p)).toEqual(["objects", p]); + expect(keys.object("x")).toEqual(["object", "x"]); + expect(keys.fieldDefinitions()).toEqual(["field-definitions"]); + expect(keys.vocabularies()).toEqual(["vocabularies"]); + expect(keys.terms("v1")).toEqual(["terms", "v1"]); + expect(keys.authorities("person")).toEqual(["authorities", "person"]); + expect(keys.search()).toEqual(["search"]); + expect(keys.searchResults("q", null)).toEqual(["search", "q", null]); +}); + +test("objects() is a prefix of objectsPage() so invalidation matches", () => { + const prefix = keys.objects(); + const full = keys.objectsPage({ limit: 50, offset: 0 }); + expect(full.slice(0, prefix.length)).toEqual(prefix); +}); diff --git a/web/src/api/query-keys.ts b/web/src/api/query-keys.ts new file mode 100644 index 0000000..b1fec78 --- /dev/null +++ b/web/src/api/query-keys.ts @@ -0,0 +1,24 @@ +export type ObjectListParams = { + limit: number; + offset: number; + sort?: string; + order?: "asc" | "desc"; + visibility?: string; + q?: string; +}; + +/** Central query-key factory — the single source of truth for cache keys, so + * query/invalidate/setQueryData sites can't drift. */ +export const keys = { + me: () => ["me"] as const, + config: () => ["config"] as const, + objects: () => ["objects"] as const, + objectsPage: (params: ObjectListParams) => ["objects", params] as const, + object: (id: string) => ["object", id] as const, + fieldDefinitions: () => ["field-definitions"] as const, + vocabularies: () => ["vocabularies"] as const, + terms: (vocabularyId: string | null | undefined) => ["terms", vocabularyId] as const, + authorities: (kind: string | null | undefined) => ["authorities", kind] as const, + search: () => ["search"] as const, + searchResults: (term: string, visibility: string | null) => ["search", term, visibility] as const, +}; diff --git a/web/src/api/search-invalidation.test.tsx b/web/src/api/search-invalidation.test.tsx new file mode 100644 index 0000000..7c7dbc9 --- /dev/null +++ b/web/src/api/search-invalidation.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { http, HttpResponse } from "msw"; +import type { ReactNode } from "react"; + +import { server } from "../test/server"; +import { useSetVisibility } from "./queries"; +import { keys } from "./query-keys"; + +test("changing an object's visibility invalidates the active search query", async () => { + server.use( + http.post("/api/admin/objects/:id/visibility", () => new HttpResponse(null, { status: 204 })), + ); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + qc.setQueryData(keys.searchResults("amphora", null), { pages: [], pageParams: [] }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const { result } = renderHook(() => useSetVisibility(), { wrapper }); + + await result.current.mutateAsync({ id: "o1", visibility: "public" }); + + await waitFor(() => + expect(qc.getQueryState(keys.searchResults("amphora", null))?.isInvalidated).toBe(true), + ); +}); diff --git a/web/src/config/config-provider.tsx b/web/src/config/config-provider.tsx index 8573294..dc6a668 100644 --- a/web/src/config/config-provider.tsx +++ b/web/src/config/config-provider.tsx @@ -2,12 +2,13 @@ import { useEffect, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { api } from "../api/client"; +import { keys } from "../api/query-keys"; import i18n, { LOCALE_KEY } from "../i18n"; import { ConfigContext, DEFAULTS, type ConfigView } from "./config-context"; export function ConfigProvider({ children }: { children: ReactNode }) { const { data } = useQuery({ - queryKey: ["config"], + queryKey: keys.config(), queryFn: async (): Promise => { const { data, error } = await api.GET("/api/config"); From d8d80358505285a43dcc9b715793faea214b4d16 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Mon, 8 Jun 2026 21:35:02 +0200 Subject: [PATCH 5/5] refactor(web): split queries.ts into api/queries/ domain modules behind a barrel (#65) --- web/src/api/queries.ts | 556 ----------------------------- web/src/api/queries/auth.ts | 51 +++ web/src/api/queries/authorities.ts | 93 +++++ web/src/api/queries/field-defs.ts | 83 +++++ web/src/api/queries/index.ts | 8 + web/src/api/queries/objects.ts | 157 ++++++++ web/src/api/queries/search.ts | 39 ++ web/src/api/queries/vocab.ts | 160 +++++++++ 8 files changed, 591 insertions(+), 556 deletions(-) delete mode 100644 web/src/api/queries.ts create mode 100644 web/src/api/queries/auth.ts create mode 100644 web/src/api/queries/authorities.ts create mode 100644 web/src/api/queries/field-defs.ts create mode 100644 web/src/api/queries/index.ts create mode 100644 web/src/api/queries/objects.ts create mode 100644 web/src/api/queries/search.ts create mode 100644 web/src/api/queries/vocab.ts diff --git a/web/src/api/queries.ts b/web/src/api/queries.ts deleted file mode 100644 index d26cbca..0000000 --- a/web/src/api/queries.ts +++ /dev/null @@ -1,556 +0,0 @@ -import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; - -import { api } from "./client"; -import { keys, type ObjectListParams } from "./query-keys"; -import type { components } from "./schema"; -import { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; - -export { HttpError, FieldRejection, InUseError, VisibilityError } from "./errors"; -export type { ObjectListParams } from "./query-keys"; - -type UserView = components["schemas"]["UserView"]; -type LoginRequest = components["schemas"]["LoginRequest"]; - -export function useMe() { - return useQuery({ - queryKey: keys.me(), - queryFn: async (): Promise => { - const { data, response } = await api.GET("/api/admin/me"); - - if (response.status === 401) return null; - - if (!data) throw new Error("failed to load session"); - - return data; - }, - retry: false, - }); -} - -export function useObjectsPage(params: ObjectListParams) { - return useQuery({ - queryKey: keys.objectsPage(params), - placeholderData: keepPreviousData, - queryFn: async () => { - const { data, error } = await api.GET("/api/admin/objects", { - params: { - query: { - limit: params.limit, - offset: params.offset, - sort: params.sort, - order: params.order, - visibility: params.visibility, - q: params.q, - }, - }, - }); - - if (error || !data) throw new Error("failed to load objects"); - - return data; - }, - }); -} - -export function useObject(id: string) { - return useQuery({ - queryKey: keys.object(id), - queryFn: async () => { - const { data, response } = await api.GET("/api/admin/objects/{id}", { - params: { path: { id } }, - }); - - if (response.status === 404) return null; - - if (!data) throw new Error("failed to load object"); - - return data; - }, - // A 404 resolves to null rather than erroring, so don't retry it. - retry: false, - }); -} - -export function useFieldDefinitions() { - return useQuery({ - queryKey: keys.fieldDefinitions(), - queryFn: async () => { - const { data, error } = await api.GET("/api/admin/field-definitions"); - - if (error || !data) throw new Error("failed to load field definitions"); - - return data; - }, - staleTime: 5 * 60 * 1000, - }); -} - -export function useLogin() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (body: LoginRequest) => { - const { response } = await api.POST("/api/admin/login", { body }); - - if (response.status !== 204) { - throw new Error(response.status === 401 ? "invalid" : "network"); - } - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }), - meta: { suppressErrorToast: true }, - }); -} - -export function useLogout() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async () => { - await api.POST("/api/admin/logout"); - }, - onSuccess: () => qc.setQueryData(keys.me(), null), - }); -} - -type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; -type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; - -export function useTerms(vocabularyId: string | null | undefined) { - return useQuery({ - queryKey: keys.terms(vocabularyId), - enabled: !!vocabularyId, - queryFn: async () => { - const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { - params: { path: { id: vocabularyId! } }, - }); - - if (error || !data) throw new Error("failed to load terms"); - - return data; - }, - staleTime: 5 * 60 * 1000, - }); -} - -export function useAuthorities(kind: string | null | undefined) { - return useQuery({ - queryKey: keys.authorities(kind), - enabled: !!kind, - queryFn: async () => { - const { data, error } = await api.GET("/api/admin/authorities", { - params: { query: { kind: kind! } }, - }); - - if (error || !data) throw new Error("failed to load authorities"); - - return data; - }, - staleTime: 5 * 60 * 1000, - }); -} - -export function useCreateObject() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (body: ObjectCreateRequest) => { - const { data, error, response } = await api.POST("/api/admin/objects", { body }); - - if (error || !data) throw new HttpError(response.status); - - return data; - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -export function useUpdateObject() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => { - const { response } = await api.PUT("/api/admin/objects/{id}", { - params: { path: { id } }, - body, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.object(id) }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - meta: { successMessage: "toast.saved", suppressErrorToast: true }, - }); -} - -export function useSetFields() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, fields }: { id: string; fields: Record }) => { - const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", { - params: { path: { id } }, - body: fields as Record, - }); - - if (response.status === 204) return; - - if (response.status === 422 && error && typeof error === "object" && "field" in error) { - const detail = error as { field: string; code: string }; - throw new FieldRejection(detail.field, detail.code); - } - - throw new HttpError(response.status); - }, - onSuccess: (_d, { id }) => { - void qc.invalidateQueries({ queryKey: keys.object(id) }); - }, - meta: { suppressErrorToast: true }, - }); -} - -export function useDeleteObject() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (id: string) => { - const { response } = await api.DELETE("/api/admin/objects/{id}", { - params: { path: { id } }, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: () => { - void qc.invalidateQueries({ queryKey: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} - -type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; -type LabelInput = components["schemas"]["LabelInput"]; - -export function useVocabularies() { - return useQuery({ - queryKey: keys.vocabularies(), - queryFn: async () => { - const { data, error } = await api.GET("/api/admin/vocabularies"); - - if (error || !data) throw new Error("failed to load vocabularies"); - - return data; - }, - staleTime: 5 * 60 * 1000, - }); -} - -export function useCreateVocabulary() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (body: NewVocabularyRequest) => { - const { data, error, response } = await api.POST("/api/admin/vocabularies", { body }); - - if (error || !data) throw new HttpError(response.status); - - return data; - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -export function useAddTerm() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - vocabularyId, - external_uri, - labels, - }: { - vocabularyId: string; - external_uri: string | null; - labels: LabelInput[]; - }) => { - const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", { - params: { path: { id: vocabularyId } }, - body: { external_uri, labels }, - }); - - if (response.status !== 201) throw new HttpError(response.status); - }, - onSuccess: (_result, { vocabularyId }) => - qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -export function useCreateAuthority() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - kind, - external_uri, - labels, - }: { - kind: string; - external_uri: string | null; - labels: LabelInput[]; - }) => { - const { response } = await api.POST("/api/admin/authorities", { - body: { kind, external_uri, labels }, - }); - - if (response.status !== 201) throw new HttpError(response.status); - }, - onSuccess: (_result, { kind }) => - qc.invalidateQueries({ queryKey: keys.authorities(kind) }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -const SEARCH_PAGE = 20; - -export function useSearch(q: string, visibility: string | null) { - const term = q.trim(); - - return useInfiniteQuery({ - queryKey: keys.searchResults(term, visibility), - enabled: term.length > 0, - initialPageParam: 0, - queryFn: async ({ pageParam }) => { - const { data, error, response } = await api.GET("/api/admin/search", { - params: { - query: { - q: term, - ...(visibility ? { visibility } : {}), - offset: pageParam, - limit: SEARCH_PAGE, - }, - }, - }); - - if (error || !data) throw new HttpError(response.status); - - return data; - }, - placeholderData: keepPreviousData, - getNextPageParam: (lastPage, allPages) => { - const loaded = allPages.reduce((n, page) => n + page.hits.length, 0); - - return loaded < lastPage.estimated_total ? loaded : undefined; - }, - }); -} - -type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; - -export function useCreateFieldDefinition() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (body: NewFieldDefinitionRequest) => { - const { data, response } = await api.POST("/api/admin/field-definitions", { body }); - - if (response.status !== 201 || !data) throw new HttpError(response.status); - - return data; - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), - meta: { successMessage: "toast.created", suppressErrorToast: true }, - }); -} - -type Visibility = "draft" | "internal" | "public"; - -export function useSetVisibility() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => { - const { response } = await api.POST("/api/admin/objects/{id}/visibility", { - params: { path: { id } }, - body: { visibility }, - }); - - if (response.status !== 204) throw new VisibilityError(response.status); - }, - onSuccess: (_result, { id }) => { - void qc.invalidateQueries({ queryKey: keys.object(id) }); - void qc.invalidateQueries({ queryKey: keys.objects() }); - void qc.invalidateQueries({ queryKey: keys.search() }); - }, - meta: { successMessage: "toast.published", suppressErrorToast: true }, - }); -} - -export function useUpdateTerm() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - vocabularyId, - termId, - external_uri, - labels, - }: { - vocabularyId: string; - termId: string; - external_uri: string | null; - labels: LabelInput[]; - }) => { - const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", { - params: { path: { id: vocabularyId, term_id: termId } }, - body: { external_uri, labels }, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), - meta: { successMessage: "toast.saved", suppressErrorToast: true }, - }); -} - -export function useDeleteTerm() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => { - const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", { - params: { path: { id: vocabularyId, term_id: termId } }, - }); - - if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} - -export function useRenameVocabulary() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id, key }: { id: string; key: string }) => { - const { response } = await api.PATCH("/api/admin/vocabularies/{id}", { - params: { path: { id } }, - body: { key }, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), - meta: { successMessage: "toast.renamed", suppressErrorToast: true }, - }); -} - -export function useDeleteVocabulary() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (id: string) => { - const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", { - params: { path: { id } }, - }); - - if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} - -export function useUpdateAuthority() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - id, - external_uri, - labels, - }: { - id: string; - kind: string; - external_uri: string | null; - labels: LabelInput[]; - }) => { - const { response } = await api.PATCH("/api/admin/authorities/{id}", { - params: { path: { id } }, - body: { external_uri, labels }, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), - meta: { successMessage: "toast.saved", suppressErrorToast: true }, - }); -} - -export function useDeleteAuthority() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ id }: { id: string; kind: string }) => { - const { error, response } = await api.DELETE("/api/admin/authorities/{id}", { - params: { path: { id } }, - }); - - if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} - -export function useUpdateFieldDefinition() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - key, - required, - group, - labels, - }: { - key: string; - required: boolean; - group: string | null; - labels: LabelInput[]; - }) => { - const { response } = await api.PATCH("/api/admin/field-definitions/{key}", { - params: { path: { key } }, - body: { required, group, labels }, - }); - - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), - meta: { successMessage: "toast.saved", suppressErrorToast: true }, - }); -} - -export function useDeleteFieldDefinition() { - const qc = useQueryClient(); - - return useMutation({ - mutationFn: async (key: string) => { - const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", { - params: { path: { key } }, - }); - - if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); - if (response.status !== 204) throw new HttpError(response.status); - }, - onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), - meta: { successMessage: "toast.deleted", suppressErrorToast: true }, - }); -} diff --git a/web/src/api/queries/auth.ts b/web/src/api/queries/auth.ts new file mode 100644 index 0000000..45c612a --- /dev/null +++ b/web/src/api/queries/auth.ts @@ -0,0 +1,51 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { keys } from "../query-keys"; + +type UserView = components["schemas"]["UserView"]; +type LoginRequest = components["schemas"]["LoginRequest"]; + +export function useMe() { + return useQuery({ + queryKey: keys.me(), + queryFn: async (): Promise => { + const { data, response } = await api.GET("/api/admin/me"); + + if (response.status === 401) return null; + + if (!data) throw new Error("failed to load session"); + + return data; + }, + retry: false, + }); +} + +export function useLogin() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (body: LoginRequest) => { + const { response } = await api.POST("/api/admin/login", { body }); + + if (response.status !== 204) { + throw new Error(response.status === 401 ? "invalid" : "network"); + } + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.me() }), + meta: { suppressErrorToast: true }, + }); +} + +export function useLogout() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async () => { + await api.POST("/api/admin/logout"); + }, + onSuccess: () => qc.setQueryData(keys.me(), null), + }); +} diff --git a/web/src/api/queries/authorities.ts b/web/src/api/queries/authorities.ts new file mode 100644 index 0000000..cce2bff --- /dev/null +++ b/web/src/api/queries/authorities.ts @@ -0,0 +1,93 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type LabelInput = components["schemas"]["LabelInput"]; + +export function useAuthorities(kind: string | null | undefined) { + return useQuery({ + queryKey: keys.authorities(kind), + enabled: !!kind, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/authorities", { + params: { query: { kind: kind! } }, + }); + + if (error || !data) throw new Error("failed to load authorities"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateAuthority() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + kind, + external_uri, + labels, + }: { + kind: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.POST("/api/admin/authorities", { + body: { kind, external_uri, labels }, + }); + + if (response.status !== 201) throw new HttpError(response.status); + }, + onSuccess: (_result, { kind }) => + qc.invalidateQueries({ queryKey: keys.authorities(kind) }), + meta: { successMessage: "toast.created", suppressErrorToast: true }, + }); +} + +export function useUpdateAuthority() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + external_uri, + labels, + }: { + id: string; + kind: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/authorities/{id}", { + params: { path: { id } }, + body: { external_uri, labels }, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), + meta: { successMessage: "toast.saved", suppressErrorToast: true }, + }); +} + +export function useDeleteAuthority() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id }: { id: string; kind: string }) => { + const { error, response } = await api.DELETE("/api/admin/authorities/{id}", { + params: { path: { id } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: (_d, { kind }) => qc.invalidateQueries({ queryKey: keys.authorities(kind) }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/field-defs.ts b/web/src/api/queries/field-defs.ts new file mode 100644 index 0000000..8bd7b5f --- /dev/null +++ b/web/src/api/queries/field-defs.ts @@ -0,0 +1,83 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewFieldDefinitionRequest = components["schemas"]["NewFieldDefinitionRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function useFieldDefinitions() { + return useQuery({ + queryKey: keys.fieldDefinitions(), + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/field-definitions"); + + if (error || !data) throw new Error("failed to load field definitions"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateFieldDefinition() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (body: NewFieldDefinitionRequest) => { + const { data, response } = await api.POST("/api/admin/field-definitions", { body }); + + if (response.status !== 201 || !data) throw new HttpError(response.status); + + return data; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), + meta: { successMessage: "toast.created", suppressErrorToast: true }, + }); +} + +export function useUpdateFieldDefinition() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + key, + required, + group, + labels, + }: { + key: string; + required: boolean; + group: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/field-definitions/{key}", { + params: { path: { key } }, + body: { required, group, labels }, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), + meta: { successMessage: "toast.saved", suppressErrorToast: true }, + }); +} + +export function useDeleteFieldDefinition() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (key: string) => { + const { error, response } = await api.DELETE("/api/admin/field-definitions/{key}", { + params: { path: { key } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.fieldDefinitions() }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/index.ts b/web/src/api/queries/index.ts new file mode 100644 index 0000000..4f71f3d --- /dev/null +++ b/web/src/api/queries/index.ts @@ -0,0 +1,8 @@ +export * from "./auth"; +export * from "./objects"; +export * from "./field-defs"; +export * from "./vocab"; +export * from "./authorities"; +export * from "./search"; +export * from "../errors"; +export type { ObjectListParams } from "../query-keys"; diff --git a/web/src/api/queries/objects.ts b/web/src/api/queries/objects.ts new file mode 100644 index 0000000..024e11d --- /dev/null +++ b/web/src/api/queries/objects.ts @@ -0,0 +1,157 @@ +import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, FieldRejection, VisibilityError } from "../errors"; +import { keys, type ObjectListParams } from "../query-keys"; + +type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"]; +type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"]; +type Visibility = "draft" | "internal" | "public"; + +export function useObjectsPage(params: ObjectListParams) { + return useQuery({ + queryKey: keys.objectsPage(params), + placeholderData: keepPreviousData, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/objects", { + params: { + query: { + limit: params.limit, + offset: params.offset, + sort: params.sort, + order: params.order, + visibility: params.visibility, + q: params.q, + }, + }, + }); + + if (error || !data) throw new Error("failed to load objects"); + + return data; + }, + }); +} + +export function useObject(id: string) { + return useQuery({ + queryKey: keys.object(id), + queryFn: async () => { + const { data, response } = await api.GET("/api/admin/objects/{id}", { + params: { path: { id } }, + }); + + if (response.status === 404) return null; + + if (!data) throw new Error("failed to load object"); + + return data; + }, + // A 404 resolves to null rather than erroring, so don't retry it. + retry: false, + }); +} + +export function useCreateObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (body: ObjectCreateRequest) => { + const { data, error, response } = await api.POST("/api/admin/objects", { body }); + + if (error || !data) throw new HttpError(response.status); + + return data; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.objects() }), + meta: { successMessage: "toast.created", suppressErrorToast: true }, + }); +} + +export function useUpdateObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, body }: { id: string; body: ObjectUpdateRequest }) => { + const { response } = await api.PUT("/api/admin/objects/{id}", { + params: { path: { id } }, + body, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: (_d, { id }) => { + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + meta: { successMessage: "toast.saved", suppressErrorToast: true }, + }); +} + +export function useSetFields() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, fields }: { id: string; fields: Record }) => { + const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", { + params: { path: { id } }, + body: fields as Record, + }); + + if (response.status === 204) return; + + if (response.status === 422 && error && typeof error === "object" && "field" in error) { + const detail = error as { field: string; code: string }; + throw new FieldRejection(detail.field, detail.code); + } + + throw new HttpError(response.status); + }, + onSuccess: (_d, { id }) => { + void qc.invalidateQueries({ queryKey: keys.object(id) }); + }, + meta: { suppressErrorToast: true }, + }); +} + +export function useDeleteObject() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { response } = await api.DELETE("/api/admin/objects/{id}", { + params: { path: { id } }, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} + +export function useSetVisibility() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, visibility }: { id: string; visibility: Visibility }) => { + const { response } = await api.POST("/api/admin/objects/{id}/visibility", { + params: { path: { id } }, + body: { visibility }, + }); + + if (response.status !== 204) throw new VisibilityError(response.status); + }, + onSuccess: (_result, { id }) => { + void qc.invalidateQueries({ queryKey: keys.object(id) }); + void qc.invalidateQueries({ queryKey: keys.objects() }); + void qc.invalidateQueries({ queryKey: keys.search() }); + }, + meta: { successMessage: "toast.published", suppressErrorToast: true }, + }); +} diff --git a/web/src/api/queries/search.ts b/web/src/api/queries/search.ts new file mode 100644 index 0000000..76e9ecc --- /dev/null +++ b/web/src/api/queries/search.ts @@ -0,0 +1,39 @@ +import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; + +import { api } from "../client"; +import { HttpError } from "../errors"; +import { keys } from "../query-keys"; + +const SEARCH_PAGE = 20; + +export function useSearch(q: string, visibility: string | null) { + const term = q.trim(); + + return useInfiniteQuery({ + queryKey: keys.searchResults(term, visibility), + enabled: term.length > 0, + initialPageParam: 0, + queryFn: async ({ pageParam }) => { + const { data, error, response } = await api.GET("/api/admin/search", { + params: { + query: { + q: term, + ...(visibility ? { visibility } : {}), + offset: pageParam, + limit: SEARCH_PAGE, + }, + }, + }); + + if (error || !data) throw new HttpError(response.status); + + return data; + }, + placeholderData: keepPreviousData, + getNextPageParam: (lastPage, allPages) => { + const loaded = allPages.reduce((n, page) => n + page.hits.length, 0); + + return loaded < lastPage.estimated_total ? loaded : undefined; + }, + }); +} diff --git a/web/src/api/queries/vocab.ts b/web/src/api/queries/vocab.ts new file mode 100644 index 0000000..72db7f7 --- /dev/null +++ b/web/src/api/queries/vocab.ts @@ -0,0 +1,160 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api } from "../client"; +import type { components } from "../schema"; +import { HttpError, InUseError } from "../errors"; +import { keys } from "../query-keys"; + +type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"]; +type LabelInput = components["schemas"]["LabelInput"]; + +export function useVocabularies() { + return useQuery({ + queryKey: keys.vocabularies(), + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/vocabularies"); + + if (error || !data) throw new Error("failed to load vocabularies"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useCreateVocabulary() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (body: NewVocabularyRequest) => { + const { data, error, response } = await api.POST("/api/admin/vocabularies", { body }); + + if (error || !data) throw new HttpError(response.status); + + return data; + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), + meta: { successMessage: "toast.created", suppressErrorToast: true }, + }); +} + +export function useRenameVocabulary() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, key }: { id: string; key: string }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}", { + params: { path: { id } }, + body: { key }, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), + meta: { successMessage: "toast.renamed", suppressErrorToast: true }, + }); +} + +export function useDeleteVocabulary() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}", { + params: { path: { id } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: keys.vocabularies() }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +} + +export function useTerms(vocabularyId: string | null | undefined) { + return useQuery({ + queryKey: keys.terms(vocabularyId), + enabled: !!vocabularyId, + queryFn: async () => { + const { data, error } = await api.GET("/api/admin/vocabularies/{id}/terms", { + params: { path: { id: vocabularyId! } }, + }); + + if (error || !data) throw new Error("failed to load terms"); + + return data; + }, + staleTime: 5 * 60 * 1000, + }); +} + +export function useAddTerm() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + vocabularyId, + external_uri, + labels, + }: { + vocabularyId: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.POST("/api/admin/vocabularies/{id}/terms", { + params: { path: { id: vocabularyId } }, + body: { external_uri, labels }, + }); + + if (response.status !== 201) throw new HttpError(response.status); + }, + onSuccess: (_result, { vocabularyId }) => + qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), + meta: { successMessage: "toast.created", suppressErrorToast: true }, + }); +} + +export function useUpdateTerm() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + vocabularyId, + termId, + external_uri, + labels, + }: { + vocabularyId: string; + termId: string; + external_uri: string | null; + labels: LabelInput[]; + }) => { + const { response } = await api.PATCH("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + body: { external_uri, labels }, + }); + + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), + meta: { successMessage: "toast.saved", suppressErrorToast: true }, + }); +} + +export function useDeleteTerm() { + const qc = useQueryClient(); + + return useMutation({ + mutationFn: async ({ vocabularyId, termId }: { vocabularyId: string; termId: string }) => { + const { error, response } = await api.DELETE("/api/admin/vocabularies/{id}/terms/{term_id}", { + params: { path: { id: vocabularyId, term_id: termId } }, + }); + + if (response.status === 409) throw new InUseError((error as { count?: number })?.count ?? 0); + if (response.status !== 204) throw new HttpError(response.status); + }, + onSuccess: (_d, { vocabularyId }) => qc.invalidateQueries({ queryKey: keys.terms(vocabularyId) }), + meta: { successMessage: "toast.deleted", suppressErrorToast: true }, + }); +}