merge: split queries.ts — errors + key factory + domain modules; invalidate search on object writes (#65)
CI / web (push) Has been cancelled
CI / web (push) Has been cancelled
This commit is contained in:
@@ -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 }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
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.
|
||||
@@ -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).
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,584 +0,0 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api } from "./client";
|
||||
import type { components } from "./schema";
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
type UserView = components["schemas"]["UserView"];
|
||||
type LoginRequest = components["schemas"]["LoginRequest"];
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: async (): Promise<UserView | null> => {
|
||||
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 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],
|
||||
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: ["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: ["field-definitions"],
|
||||
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: ["me"] }),
|
||||
meta: { suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
await api.POST("/api/admin/logout");
|
||||
},
|
||||
onSuccess: () => qc.setQueryData(["me"], null),
|
||||
});
|
||||
}
|
||||
|
||||
type ObjectCreateRequest = components["schemas"]["ObjectCreateRequest"];
|
||||
type ObjectUpdateRequest = components["schemas"]["ObjectUpdateRequest"];
|
||||
|
||||
export function useTerms(vocabularyId: string | null | undefined) {
|
||||
return useQuery({
|
||||
queryKey: ["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: ["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: ["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: ["objects"] });
|
||||
void qc.invalidateQueries({ queryKey: ["object", id] });
|
||||
},
|
||||
meta: { successMessage: "toast.saved", suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetFields() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, fields }: { id: string; fields: Record<string, unknown> }) => {
|
||||
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||
params: { path: { id } },
|
||||
body: fields as Record<string, never>,
|
||||
});
|
||||
|
||||
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: ["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: () => qc.invalidateQueries({ queryKey: ["objects"] }),
|
||||
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
type NewVocabularyRequest = components["schemas"]["NewVocabularyRequest"];
|
||||
type LabelInput = components["schemas"]["LabelInput"];
|
||||
|
||||
export function useVocabularies() {
|
||||
return useQuery({
|
||||
queryKey: ["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: ["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: ["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: ["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: ["search", 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: ["field-definitions"] }),
|
||||
meta: { successMessage: "toast.created", suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
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: ["object", id] });
|
||||
void qc.invalidateQueries({ queryKey: ["objects"] });
|
||||
},
|
||||
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: ["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: ["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: ["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: ["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: ["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: ["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: ["field-definitions"] }),
|
||||
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: ["field-definitions"] }),
|
||||
meta: { successMessage: "toast.deleted", suppressErrorToast: true },
|
||||
});
|
||||
}
|
||||
@@ -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<UserView | null> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<string, unknown> }) => {
|
||||
const { response, error } = await api.PUT("/api/admin/objects/{id}/fields", {
|
||||
params: { path: { id } },
|
||||
body: fields as Record<string, never>,
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
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),
|
||||
);
|
||||
});
|
||||
@@ -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<ConfigView> => {
|
||||
const { data, error } = await api.GET("/api/config");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user