@terpjs/react-core 0.5.9 → 0.5.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -71,6 +71,8 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
71
71
  | `OverviewPage` | A module's top-level listing screen (level 2); detail pages crumb back to it. |
72
72
  | `DetailPage` | One record's screen (level 3); breadcrumb trail = ancestors + record title. |
73
73
  | `Breadcrumbs` | The trail itself (used by the archetypes; rarely composed directly). Ancestor crumbs use the router's `Link` by default — `renderLink` is only for rendering outside a Terp router. |
74
+ | `NavLinkContext`, `useNavLink` | The ambient link renderer `buildAppRouter` publishes (and the layout components default to); provide it yourself in a standalone story/test tree or a bespoke shell. |
75
+ | `useRouteParam` | Read one route param, fail closed: the declared param comes back as a string, an undeclared name throws a directive error instead of silently yielding `undefined`. Routes are realised at runtime from manifests, so TanStack's type registry cannot check param names in any app — this replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). |
74
76
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
75
77
  | `PageActions` | Primary action + overflow menu for a page header. |
76
78
 
@@ -82,11 +84,13 @@ An app can ratchet the archetype control further with a named **layout contract*
82
84
  `terp/layout-contract` lint half — keep the two in sync; the project template generates
83
85
  both). Each governed archetype's body slot then accepts **only** the contract's
84
86
  components — `standard`: hub bodies hold `HubCard` only; overview bodies hold
85
- `DataView` / `ResourceList` / `ModuleNav` / `Stack` plus the framework states
87
+ `DataView` / `ResourceList` / `ModuleNav` / `Stack` / `Card` plus the framework states
86
88
  (`EmptyState` / `ErrorState` / `LoadingState` / `Alert`) and `ConfirmDialog`; detail
87
- bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` / `DataView` plus the same
88
- states. The plain `Page` stays unconstrained (the sanctioned home for a bespoke
89
- screen). Enforcement is two-layer and fail-closed: the lint rule checks static JSX
89
+ bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` / `DataView` / `Card` plus
90
+ the same states. The plain `Page` stays unconstrained (the sanctioned home for a
91
+ bespoke screen). Only the slot's **direct** children are governed an allowed
92
+ container's own subtree (a `Card` body, a `Stack` of rows) is the app's to compose.
93
+ Enforcement is two-layer and fail-closed: the lint rule checks static JSX
90
94
  children; the archetypes verify the rendered DOM (sanctioned components stamp a
91
95
  `data-terp` marker) and refuse a non-conforming view with the **same directive
92
96
  message** — contract, slot, what was found, what is allowed, and the fix — so a
@@ -103,9 +107,10 @@ marker, counted by the escape-hatch budget.
103
107
  | `InMemoryDataViewRepository`, `HttpDataViewRepository` | Data repositories (client-side / server-side); `useServerDataView` keeps server query state in the URL. |
104
108
  | `InMemoryViewStateRepository`, `LocalStorageViewStateRepository` | Preference persistence seam. |
105
109
  | `useResource` | An async collection: rows + loading/error + reload + create-then-reload. |
110
+ | `useRecord` | The singleton counterpart of `useResource` — the one record a detail screen shows: `item` (or `null`) + loading/error + reload + mutate. Deletes the one-element-list wart (`list: async () => [unwrap(…)]` then `items[0]`). |
106
111
  | `useRealtimeChannel` | The sanctioned typed SSE/WebSocket seam for the optional realtime capability: mints a short-lived one-use ticket via the authenticated generated client, validates every inbound JSON payload with the channel's runtime type guard, and exposes connection state / last message / WebSocket send. App modules never touch raw transports. |
107
112
  | `ResourceList` | The standard simple CRUD list screen: titled section, write-gated create form, loading/error/empty states. Composable — screens needing more render their own React. |
108
- | `unwrap`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. |
113
+ | `unwrap`, `unwrapOptional`, `ApiError` | Turn a generated-client result into data-or-throw; `ApiError` carries the envelope's `code` / `status` / `requestId`. `unwrapOptional` returns `null` on a 404 instead — for resources whose absence is a normal state (a `/latest` snapshot not yet published), the client-side analog of `BaseService.find` beside `get`. |
109
114
  | `FileUpload`, `useFileDownload` | The files-capability surface (ADR 0056/0057): a token-styled attachment picker that uploads through the typed client, and an authenticated download helper (a raw `<a href>` would carry no bearer token). |
110
115
 
111
116
  ## Feedback & states
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@terpjs/react-core",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "type": "module",
5
5
  "description": "Terp React stack core — typed @terpjs/contract client provider, auth session, capability gates, TanStack Router adapter, app shell, page archetypes, DataView and token-styled UI primitives. First frontend stack; see README.md for the component catalog.",
6
6
  "exports": {
@@ -13,7 +13,7 @@
13
13
  },
14
14
  "dependencies": {
15
15
  "@tanstack/react-router": "^1.170.16",
16
- "@terpjs/contract": "^0.5.9"
16
+ "@terpjs/contract": "^0.5.10"
17
17
  },
18
18
  "peerDependencies": {
19
19
  "react": "^18.3.0 || ^19.0.0",
@@ -17,7 +17,7 @@ export interface DetailPageProps extends Omit<PageProps, "breadcrumbs"> {
17
17
  * `Page` whose breadcrumb trail is the ancestor layers plus the record itself (`title`), so
18
18
  * users can always navigate back up — the shell -> overview -> detail layering by construction.
19
19
  * With a layout contract active (ADR 0079), the body slot accepts only the contract's record
20
- * components (e.g. `DetailList` / `Stack` / `Tabs`) — refused fail closed otherwise.
20
+ * components (e.g. `DetailList` / `Stack` / `Tabs` / `Card`) — refused fail closed otherwise.
21
21
  */
22
22
  export function DetailPage({ parents, ...page }: DetailPageProps) {
23
23
  return (
@@ -15,7 +15,7 @@ export interface OverviewPageProps extends Omit<PageProps, "breadcrumbs"> {
15
15
  * without a redundant current-page-only crumb; detail pages under it link back here — so every
16
16
  * module's overview is constructed the same. Compose the body from `ResourceList` (or any listing UI).
17
17
  * With a layout contract active (ADR 0079), the body slot accepts only the contract's
18
- * listing components (e.g. `DataView` / `ResourceList`) — refused fail closed otherwise.
18
+ * listing components (e.g. `DataView` / `ResourceList` / `Card`) — refused fail closed otherwise.
19
19
  */
20
20
  export function OverviewPage({ parents, ...page }: OverviewPageProps): ReactNode {
21
21
  return (
@@ -1,4 +1,4 @@
1
- import { useNavigate, useParams } from "@tanstack/react-router";
1
+ import { useNavigate } from "@tanstack/react-router";
2
2
  import { useEffect, useId, useMemo, useState } from "react";
3
3
  import type { FormEvent } from "react";
4
4
  import type { components } from "@terpjs/contract";
@@ -12,7 +12,8 @@ import { DataView, HttpDataViewRepository } from "../dataview";
12
12
  import type { DataViewColumn } from "../dataview";
13
13
  import { DetailList, Stack } from "../layout";
14
14
  import { PageActions } from "../PageActions";
15
- import { useResource } from "../useResource";
15
+ import { useRouteParam } from "../router";
16
+ import { useRecord } from "../useRecord";
16
17
  import { useToast } from "../toast";
17
18
  import { Button } from "../ui/Button";
18
19
  import { Input } from "../ui/Input";
@@ -39,8 +40,7 @@ const SEARCH_DEBOUNCE_MS = 250;
39
40
  * backend resolved for them.
40
41
  */
41
42
  export function GroupDetail() {
42
- const params = useParams({ strict: false }) as { groupId?: string };
43
- const groupId = params.groupId ?? "";
43
+ const groupId = useRouteParam("groupId");
44
44
  const client = useTerpClient();
45
45
  const navigate = useNavigate();
46
46
  const toast = useToast();
@@ -75,16 +75,14 @@ export function GroupDetail() {
75
75
  setRevoking(false);
76
76
  }, [groupId]);
77
77
 
78
- const group = useResource<GroupRead>(
78
+ const group = useRecord<GroupRead>(
79
79
  {
80
- list: async () => {
81
- const row = unwrap(
80
+ get: async () =>
81
+ unwrap(
82
82
  await client.GET("/api/v1/groups/{group_id}", {
83
83
  params: { path: { group_id: groupId } },
84
84
  }),
85
- );
86
- return [row];
87
- },
85
+ ),
88
86
  },
89
87
  // Reload when navigating between group detail pages in place.
90
88
  [groupId],
@@ -306,7 +304,7 @@ export function GroupDetail() {
306
304
  }
307
305
  }
308
306
 
309
- const record = group.items[0];
307
+ const record = group.item;
310
308
  return (
311
309
  <DetailPage
312
310
  title={record?.name ?? strings.adminGroups}
@@ -317,7 +315,7 @@ export function GroupDetail() {
317
315
  renderLink={renderAdminCrumb}
318
316
  isLoading={group.loading}
319
317
  error={group.cause ?? group.error ?? undefined}
320
- actions={record !== undefined ? (
318
+ actions={record !== null ? (
321
319
  <PageActions
322
320
  overflow={[
323
321
  {
@@ -331,7 +329,7 @@ export function GroupDetail() {
331
329
  ) : undefined}
332
330
  >
333
331
  <Stack gap={6}>
334
- {record !== undefined && (
332
+ {record !== null && (
335
333
  <DetailList
336
334
  items={[
337
335
  { label: strings.description, value: record.description || "-" },
@@ -1,4 +1,3 @@
1
- import { useParams } from "@tanstack/react-router";
2
1
  import { useEffect, useState } from "react";
3
2
  import type { components } from "@terpjs/contract";
4
3
 
@@ -8,8 +7,9 @@ import { Field } from "../Field";
8
7
  import { Icon } from "../icons";
9
8
  import { DetailList } from "../layout";
10
9
  import { PageActions } from "../PageActions";
10
+ import { useRouteParam } from "../router";
11
11
  import { useTerpClient } from "../TerpProvider";
12
- import { useResource } from "../useResource";
12
+ import { useRecord } from "../useRecord";
13
13
  import { useToast } from "../toast";
14
14
  import { Button } from "../ui/Button";
15
15
  import { Input } from "../ui/Input";
@@ -27,8 +27,7 @@ type PendingLifecycle =
27
27
 
28
28
  /** Dedicated account detail and lifecycle page (`/admin/users/$userId`). */
29
29
  export function UserDetail() {
30
- const params = useParams({ strict: false }) as { userId?: string };
31
- const userId = params.userId ?? "";
30
+ const userId = useRouteParam("userId");
32
31
  const client = useTerpClient();
33
32
  const strings = useStrings();
34
33
  const toast = useToast();
@@ -47,26 +46,25 @@ export function UserDetail() {
47
46
  setResetting(false);
48
47
  }, [userId]);
49
48
 
50
- const user = useResource<UserRead>(
49
+ const user = useRecord<UserRead>(
51
50
  {
52
- list: async () => [
51
+ get: async () =>
53
52
  unwrap(
54
53
  await client.GET("/api/v1/users/{user_id}", {
55
54
  params: { path: { user_id: userId } },
56
55
  }),
57
56
  ),
58
- ],
59
57
  },
60
58
  [userId],
61
59
  );
62
- const record = user.items[0];
60
+ const record = user.item;
63
61
 
64
62
  function failed(error: unknown): void {
65
63
  toast.warning(error instanceof Error ? error.message : strings.requestFailed);
66
64
  }
67
65
 
68
66
  async function onConfirmLifecycle() {
69
- if (record === undefined || pendingLifecycle === null) return;
67
+ if (record === null || pendingLifecycle === null) return;
70
68
  setMutating(true);
71
69
  try {
72
70
  if (pendingLifecycle.kind === "role") {
@@ -93,7 +91,7 @@ export function UserDetail() {
93
91
  }
94
92
 
95
93
  async function onConfirmReset() {
96
- if (record === undefined || resetPassword.trim() === "") return;
94
+ if (record === null || resetPassword.trim() === "") return;
97
95
  setResetting(true);
98
96
  try {
99
97
  unwrap(
@@ -132,7 +130,7 @@ export function UserDetail() {
132
130
  renderLink={renderAdminCrumb}
133
131
  isLoading={user.loading}
134
132
  error={user.cause ?? user.error ?? undefined}
135
- actions={record !== undefined ? (
133
+ actions={record !== null ? (
136
134
  <PageActions
137
135
  secondary={
138
136
  <Button
@@ -166,7 +164,7 @@ export function UserDetail() {
166
164
  />
167
165
  ) : undefined}
168
166
  >
169
- {record !== undefined && (
167
+ {record !== null && (
170
168
  <DetailList
171
169
  items={[
172
170
  { label: strings.email, value: record.email },
@@ -69,6 +69,20 @@ describe("DataView states", () => {
69
69
  render(<DataView repository={failing} columns={COLUMNS} />);
70
70
  expect(await screen.findByRole("alert")).toHaveTextContent("Could not load data.");
71
71
  });
72
+
73
+ it("tints and stamps a row whose getRowTone returns a tone (row-level state)", async () => {
74
+ render(
75
+ <DataView
76
+ repository={inMemoryRepo()}
77
+ columns={COLUMNS}
78
+ getRowTone={(t) => (t.status === "closed" ? "danger" : null)}
79
+ />,
80
+ );
81
+ const toned = (await screen.findByText("VPN access")).closest("tr");
82
+ expect(toned).toHaveAttribute("data-tone", "danger");
83
+ const untinted = screen.getByText("Broken printer").closest("tr");
84
+ expect(untinted).not.toHaveAttribute("data-tone");
85
+ });
72
86
  });
73
87
 
74
88
  describe("DataView server-side mode", () => {
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
3
3
 
4
4
  import { EmptyState } from "../EmptyState";
5
5
  import { ErrorState } from "../ErrorState";
6
+ import type { BadgeTone } from "../ui/Badge";
6
7
  import type { UiText } from "../uiText";
7
8
  import { DataViewCardList } from "./DataViewCardList";
8
9
  import { DataViewPagination } from "./DataViewPagination";
@@ -49,6 +50,14 @@ interface DataViewBaseProps<T> {
49
50
  pageSizeOptions?: number[];
50
51
  initialPageSize?: number;
51
52
  renderExpanded?: (row: T) => ReactNode;
53
+ /**
54
+ * Row-level status tone: the *row* is in that state (a refused link, a failed run),
55
+ * not one of its cells — the right altitude for a validation-driven table, where a
56
+ * Badge cell would misattribute the verdict to a column. Tints the row/card with the
57
+ * tone's soft token (the same one `Badge` uses) and stamps `data-tone` on it;
58
+ * `null`/`undefined` leaves the row untinted.
59
+ */
60
+ getRowTone?: (row: T) => BadgeTone | null;
52
61
  /** Fully custom cards in the responsive card layout. */
53
62
  renderCard?: (row: T) => ReactNode;
54
63
  /** Custom filter controls, rendered in the toolbar. */
@@ -303,6 +312,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
303
312
  getRowId={getRowId}
304
313
  onRowClick={props.onRowClick}
305
314
  getRowLabel={props.getRowLabel}
315
+ getRowTone={props.getRowTone}
306
316
  renderCard={props.renderCard}
307
317
  selectionEnabled={props.enableSelection === true}
308
318
  isSelected={(id) => selectedIds.has(id)}
@@ -322,6 +332,7 @@ function DataViewInner<T>(props: DataViewProps<T>) {
322
332
  getRowId={getRowId}
323
333
  onRowClick={props.onRowClick}
324
334
  getRowLabel={props.getRowLabel}
335
+ getRowTone={props.getRowTone}
325
336
  isMobile={isMobile}
326
337
  sorting={state.sorting}
327
338
  onToggleSort={state.toggleSort}
@@ -1,5 +1,7 @@
1
1
  import type { CSSProperties, ReactNode } from "react";
2
2
 
3
+ import type { BadgeTone } from "../ui/Badge";
4
+ import { toneSoftColors } from "../ui/Badge";
3
5
  import type { UiText } from "../uiText";
4
6
 
5
7
  import { DataViewExpandToggle } from "./DataViewExpandableRow";
@@ -13,6 +15,7 @@ export interface DataViewCardListProps<T> {
13
15
  getRowId: (row: T) => string;
14
16
  onRowClick?: (row: T) => void;
15
17
  getRowLabel?: (row: T) => UiText;
18
+ getRowTone?: (row: T) => BadgeTone | null;
16
19
  /** Escape hatch for fully custom cards. */
17
20
  renderCard?: (row: T) => ReactNode;
18
21
  // Selection
@@ -70,16 +73,18 @@ export function DataViewCardList<T>(props: DataViewCardListProps<T>) {
70
73
  const rowId = props.getRowId(row);
71
74
  const expanded = props.isExpanded(rowId);
72
75
  const clickable = props.onRowClick !== undefined;
76
+ const tone = props.getRowTone?.(row) ?? null;
73
77
  return (
74
78
  <li key={rowId}>
75
79
  <div
76
80
  onClick={clickable ? () => props.onRowClick?.(row) : undefined}
77
81
  data-terp={clickable ? "dataview-card" : undefined}
82
+ data-tone={tone ?? undefined}
78
83
  style={{
79
84
  display: "grid",
80
85
  gap: "var(--space-2)",
81
86
  padding: "var(--space-3)",
82
- background: "var(--color-neutral-0)",
87
+ background: tone !== null ? toneSoftColors[tone] : "var(--color-neutral-0)",
83
88
  border: "1px solid var(--color-neutral-200)",
84
89
  borderRadius: "var(--radius-lg)",
85
90
  boxShadow: "var(--shadow-sm)",
@@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
2
2
  import type { CSSProperties, ReactNode } from "react";
3
3
 
4
4
  import { injectTerpStyles } from "../styles";
5
+ import type { BadgeTone } from "../ui/Badge";
6
+ import { toneSoftColors } from "../ui/Badge";
5
7
  import type { UiText } from "../uiText";
6
8
  import { DataViewExpandToggle, DataViewExpandableRow } from "./DataViewExpandableRow";
7
9
  import { DataViewRowActions } from "./DataViewRowActions";
@@ -20,6 +22,7 @@ export interface DataViewTableProps<T> {
20
22
  getRowId: (row: T) => string;
21
23
  onRowClick?: (row: T) => void;
22
24
  getRowLabel?: (row: T) => UiText;
25
+ getRowTone?: (row: T) => BadgeTone | null;
23
26
  isMobile: boolean;
24
27
  // Sorting
25
28
  sorting: { id: string; desc: boolean }[];
@@ -275,15 +278,24 @@ export function DataViewTable<T>(props: DataViewTableProps<T>) {
275
278
  const rowId = props.getRowId(row);
276
279
  const expanded = props.isExpanded(rowId);
277
280
  const clickable = props.onRowClick !== undefined;
281
+ const tone = props.getRowTone?.(row) ?? null;
278
282
  return (
279
283
  <RowGroup key={rowId}>
280
284
  <tr
281
285
  onClick={clickable ? () => props.onRowClick?.(row) : undefined}
282
286
  data-terp={clickable ? "dataview-row" : undefined}
283
287
  data-selected={props.isSelected(rowId) || undefined}
288
+ data-tone={tone ?? undefined}
284
289
  style={{
285
290
  cursor: clickable ? "pointer" : undefined,
286
- background: props.isSelected(rowId) ? "var(--color-neutral-50)" : undefined,
291
+ // A row's own state outranks the selection tint — selection still
292
+ // shows via the checkbox and data-selected.
293
+ background:
294
+ tone !== null
295
+ ? toneSoftColors[tone]
296
+ : props.isSelected(rowId)
297
+ ? "var(--color-neutral-50)"
298
+ : undefined,
287
299
  }}
288
300
  >
289
301
  {hasExpand && (
@@ -36,7 +36,11 @@ const columns: DataViewColumn<Ticket>[] = [
36
36
 
37
37
  const repository = new InMemoryDataViewRepository(tickets, {
38
38
  getRowId: (t) => t.id,
39
- getValue: (t, col) => t[col as keyof Ticket],
39
+ // Annotate the field parameter and `searchFields` is checked at compile time —
40
+ // a misspelled entry otherwise resolves to undefined for every row, so search
41
+ // silently never matches it. searchFields entries are the names getValue
42
+ // understands (typically column ids).
43
+ getValue: (t, col: keyof Ticket & string) => t[col],
40
44
  searchFields: ["title", "status"],
41
45
  });
42
46
 
@@ -115,6 +119,12 @@ versioned envelope; corrupt data falls back to defaults) and
115
119
  - **Column resizing**: drag the header handle; widths update live with no persistence
116
120
  writes per pointermove and are persisted once, on pointer-up. Width precedence:
117
121
  pinned system columns → user-resized → static `meta.width` hint → auto.
122
+ - **Row tone**: `getRowTone={(row) => tone | null}` marks the *row* as being in a
123
+ state (a refused link, a failed run) — the right altitude when the verdict belongs
124
+ to the record, not to one of its cells. The row/card is tinted with the tone's soft
125
+ token (the same one `Badge` uses) and stamped `data-tone`; a toned row's tint
126
+ outranks the selection tint. Keep cell-level `Badge`s for statuses that belong to a
127
+ column.
118
128
  - **Select-all-across-pages**: after selecting the whole page the toolbar offers
119
129
  "Select all N results"; batch actions then invoke their `onSelectAll` variant. The
120
130
  mode resets whenever the page selection is broken.
@@ -4,22 +4,32 @@ import type {
4
4
  DataViewResult,
5
5
  } from "../types";
6
6
 
7
- /** How {@link InMemoryDataViewRepository} reads and matches rows. */
8
- export interface InMemoryDataViewRepositoryOptions<T> {
7
+ /**
8
+ * How {@link InMemoryDataViewRepository} reads and matches rows.
9
+ *
10
+ * `TField` is the union of field names `getValue` understands. Annotate getValue's
11
+ * field parameter (`(row, field: keyof Ticket & string) => row[field]`) and
12
+ * `searchFields` is checked against it at compile time — without the annotation a
13
+ * misspelled entry resolves to `undefined` for every row, so search silently never
14
+ * matches it (no error at any layer). Leaving the parameter untyped keeps today's
15
+ * unchecked `string` behavior.
16
+ */
17
+ export interface InMemoryDataViewRepositoryOptions<T, TField extends string = string> {
9
18
  /** Stable row identity. */
10
19
  getRowId: (row: T) => string;
11
20
  /** The raw sortable/filterable value of a column for a row. */
12
- getValue: (row: T, columnId: string) => unknown;
21
+ getValue: (row: T, columnId: TField) => unknown;
13
22
  /**
14
23
  * Column ids the free-text search matches against (case-insensitive substring).
15
- * Omit to disable search (`capabilities.search` becomes false).
24
+ * Omit to disable search (`capabilities.search` becomes false). Checked against
25
+ * getValue's declared field union (`NoInfer` keeps a typo here from widening it).
16
26
  */
17
- searchFields?: string[];
27
+ searchFields?: NoInfer<TField>[];
18
28
  /**
19
29
  * Custom filter match; the default is faceted equality (`value` is the filter value or,
20
30
  * when an array, any-of).
21
31
  */
22
- matchesFilter?: (row: T, columnId: string, value: unknown) => boolean;
32
+ matchesFilter?: (row: T, columnId: TField, value: unknown) => boolean;
23
33
  }
24
34
 
25
35
  function defaultMatchesFilter(cell: unknown, value: unknown): boolean {
@@ -57,18 +67,21 @@ function compareValues(a: unknown, b: unknown): number {
57
67
  * ```ts
58
68
  * const repo = new InMemoryDataViewRepository(tickets, {
59
69
  * getRowId: (t) => t.id,
60
- * getValue: (t, col) => t[col as keyof Ticket],
70
+ * // Annotating the field parameter makes searchFields compile-checked.
71
+ * getValue: (t, col: keyof Ticket & string) => t[col],
61
72
  * searchFields: ["title", "assignee"],
62
73
  * });
63
74
  * ```
64
75
  */
65
- export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
76
+ export class InMemoryDataViewRepository<T, TField extends string = string>
77
+ implements DataViewRepository<T>
78
+ {
66
79
  readonly capabilities: DataViewRepository<T>["capabilities"];
67
80
 
68
81
  private rows: T[];
69
- private readonly options: InMemoryDataViewRepositoryOptions<T>;
82
+ private readonly options: InMemoryDataViewRepositoryOptions<T, TField>;
70
83
 
71
- constructor(rows: T[], options: InMemoryDataViewRepositoryOptions<T>) {
84
+ constructor(rows: T[], options: InMemoryDataViewRepositoryOptions<T, TField>) {
72
85
  this.rows = rows;
73
86
  this.options = options;
74
87
  this.capabilities = {
@@ -87,11 +100,21 @@ export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
87
100
  this.rows = rows;
88
101
  }
89
102
 
103
+ /**
104
+ * Query ids arrive as plain strings ({@link DataViewQuery} is column-agnostic); the
105
+ * `TField` union is an authoring-time contract for the options, so the one narrowing
106
+ * lives here rather than at every call site. (Deliberately not named `valueOf` —
107
+ * that shadows `Object.prototype.valueOf`, which JS calls with no arguments.)
108
+ */
109
+ private fieldValue(row: T, columnId: string): unknown {
110
+ return this.options.getValue(row, columnId as TField);
111
+ }
112
+
90
113
  /** Distinct values of one column across the full (unfiltered) data set. */
91
114
  getFacetedValues(columnId: string): unknown[] {
92
115
  const seen = new Set<unknown>();
93
116
  for (const row of this.rows) {
94
- seen.add(this.options.getValue(row, columnId));
117
+ seen.add(this.fieldValue(row, columnId));
95
118
  }
96
119
  return [...seen];
97
120
  }
@@ -102,9 +125,9 @@ export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
102
125
  for (const filter of q.filters) {
103
126
  result = result.filter((row) => {
104
127
  if (this.options.matchesFilter !== undefined) {
105
- return this.options.matchesFilter(row, filter.id, filter.value);
128
+ return this.options.matchesFilter(row, filter.id as TField, filter.value);
106
129
  }
107
- return defaultMatchesFilter(this.options.getValue(row, filter.id), filter.value);
130
+ return defaultMatchesFilter(this.fieldValue(row, filter.id), filter.value);
108
131
  });
109
132
  }
110
133
 
@@ -113,7 +136,7 @@ export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
113
136
  if (search !== "" && searchFields.length > 0) {
114
137
  result = result.filter((row) =>
115
138
  searchFields.some((field) =>
116
- String(this.options.getValue(row, field) ?? "")
139
+ String(this.fieldValue(row, field) ?? "")
117
140
  .toLowerCase()
118
141
  .includes(search),
119
142
  ),
@@ -124,8 +147,8 @@ export class InMemoryDataViewRepository<T> implements DataViewRepository<T> {
124
147
  result = [...result].sort((a, b) => {
125
148
  for (const sort of q.sorting) {
126
149
  const order = compareValues(
127
- this.options.getValue(a, sort.id),
128
- this.options.getValue(b, sort.id),
150
+ this.fieldValue(a, sort.id),
151
+ this.fieldValue(b, sort.id),
129
152
  );
130
153
  if (order !== 0) {
131
154
  return sort.desc ? -order : order;
@@ -74,6 +74,26 @@ describe("InMemoryDataViewRepository", () => {
74
74
  expect(repo().getFacetedValues("status")).toEqual(["open", "closed"]);
75
75
  });
76
76
 
77
+ it("compile-checks searchFields against getValue's declared field union", async () => {
78
+ // With getValue's field parameter annotated, a misspelled searchFields entry is a
79
+ // typecheck error instead of a search that silently never matches (the field would
80
+ // resolve to undefined for every row, with no error at any layer).
81
+ const checked = new InMemoryDataViewRepository(TICKETS, {
82
+ getRowId: (t) => t.id,
83
+ getValue: (t, col: keyof Ticket & string) => t[col],
84
+ searchFields: ["title", "status"],
85
+ });
86
+ const result = await checked.query(query({ search: "open" }));
87
+ expect(result.totalCount).toBe(3);
88
+
89
+ void new InMemoryDataViewRepository(TICKETS, {
90
+ getRowId: (t) => t.id,
91
+ getValue: (t, col: keyof Ticket & string) => t[col],
92
+ // @ts-expect-error — "titel" is not a field getValue understands
93
+ searchFields: ["titel"],
94
+ });
95
+ });
96
+
77
97
  it("advertises client-side capabilities (search only when fields are configured)", () => {
78
98
  expect(repo().capabilities).toEqual({ serverSide: false, search: true, searchScope: false });
79
99
  const noSearch = new InMemoryDataViewRepository(TICKETS, {
@@ -30,6 +30,8 @@ export interface DataViewResult<T> {
30
30
  * ```ts
31
31
  * const repo = new InMemoryDataViewRepository(tickets, {
32
32
  * getRowId: (t) => t.id,
33
+ * // Annotating the field parameter makes searchFields compile-checked.
34
+ * getValue: (t, col: keyof Ticket & string) => t[col],
33
35
  * searchFields: ["title", "assignee"],
34
36
  * });
35
37
  * <DataView repository={repo} columns={columns} />
package/src/index.ts CHANGED
@@ -12,6 +12,8 @@ export { Authorized, useCan } from "./Authorized";
12
12
  export type { AuthorizedProps } from "./Authorized";
13
13
  export { useResource } from "./useResource";
14
14
  export type { Resource, ResourceSource } from "./useResource";
15
+ export { useRecord } from "./useRecord";
16
+ export type { RecordResource, RecordSource } from "./useRecord";
15
17
  export { useRealtimeChannel } from "./realtime";
16
18
  export type {
17
19
  RealtimeChannelOptions,
@@ -19,7 +21,7 @@ export type {
19
21
  RealtimeStatus,
20
22
  RealtimeTransport,
21
23
  } from "./realtime";
22
- export { unwrap, ApiError } from "./unwrap";
24
+ export { unwrap, unwrapOptional, ApiError } from "./unwrap";
23
25
  export type { FetchResult } from "./unwrap";
24
26
  export { ResourceList } from "./ResourceList";
25
27
  export type { ResourceListProps } from "./ResourceList";
@@ -44,6 +46,10 @@ export type { IconProps, NavIconProps } from "./icons";
44
46
  export { ProfileView } from "./ProfileView";
45
47
  export { Breadcrumbs } from "./Breadcrumbs";
46
48
  export type { BreadcrumbItem, BreadcrumbsProps, RenderBreadcrumbLink } from "./Breadcrumbs";
49
+ // Published by buildAppRouter; exported so a standalone story/test tree (or a bespoke
50
+ // shell) can provide the ambient link renderer the layout components default to.
51
+ export { NavLinkContext, useNavLink } from "./navLink";
52
+ export type { NavLinkRenderer } from "./navLink";
47
53
  export { Page } from "./Page";
48
54
  export type { PageProps } from "./Page";
49
55
  export { LAYOUT_CONTRACTS } from "./layoutContract";
@@ -110,7 +116,7 @@ export { Field } from "./Field";
110
116
  export type { FieldProps } from "./Field";
111
117
  export { Stack, DetailList } from "./layout";
112
118
  export type { StackProps, DetailListProps, DetailItem, SpaceToken } from "./layout";
113
- export { buildAppRouter, DEFAULT_ROLE_RANKS, PROFILE_PATH } from "./router";
119
+ export { buildAppRouter, DEFAULT_ROLE_RANKS, PROFILE_PATH, useRouteParam } from "./router";
114
120
  export type { BuildAppRouterOptions } from "./router";
115
121
  export { LoginView } from "./LoginView";
116
122
  export type { DevCredentials, LoginViewProps } from "./LoginView";
@@ -21,6 +21,7 @@ import {
21
21
  import { OverviewPage } from "./OverviewPage";
22
22
  import { Page } from "./Page";
23
23
  import { DetailList, Stack } from "./layout";
24
+ import { Card } from "./ui/Card";
24
25
 
25
26
  afterEach(cleanup);
26
27
 
@@ -120,6 +121,7 @@ describe("runtime slot enforcement", () => {
120
121
  it("passes a DetailPage of record sections and refuses a rogue one", async () => {
121
122
  underContract(
122
123
  <DetailPage title="Record 1" parents={[{ label: "Records", to: "/records" }]}>
124
+ <Card title="A section">the sanctioned visual separation, directly in the slot</Card>
123
125
  <Stack>
124
126
  <DetailList items={[{ label: "Status", value: "open" }]} />
125
127
  </Stack>
@@ -9,6 +9,10 @@ import { createContext, useContext } from "react";
9
9
  * a `data-terp` marker on its root — and refuses the view, fail closed, with the same
10
10
  * agent-directive message the `terp/layout-contract` lint rule phrases.
11
11
  *
12
+ * Both halves govern the slot's DIRECT children only: an allowed container's own
13
+ * subtree (a Card's body, a Stack's rows) is the app's to compose — nesting content
14
+ * inside an allowed component is sanctioned composition, not an escape hatch.
15
+ *
12
16
  * This table is the TypeScript mirror of the spec-as-data source in
13
17
  * `@terpjs/eslint-boundaries/src/layouts.js` (react-core ships standalone, so it cannot
14
18
  * import a lint package); the parity test in ./layoutContract.test.tsx keeps the two
@@ -33,8 +37,9 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
33
37
  "The standard three-level shape: hub bodies are card grids (HubCard only), " +
34
38
  "overview bodies are data collections (DataView / ResourceList + framework " +
35
39
  "states), detail bodies are record sections (DetailList / Stack / Tabs + " +
36
- "framework states). A bespoke screen composes the plain Page, which the " +
37
- "contract deliberately leaves unconstrained.",
40
+ "framework states); Card is allowed in overview and detail bodies as the " +
41
+ "sanctioned visual separation between sections. A bespoke screen composes " +
42
+ "the plain Page, which the contract deliberately leaves unconstrained.",
38
43
  slots: {
39
44
  HubPage: {
40
45
  components: { HubCard: "hubcard" },
@@ -45,6 +50,7 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
45
50
  ResourceList: "resource-list",
46
51
  ModuleNav: "module-nav",
47
52
  Stack: "stack",
53
+ Card: "card",
48
54
  EmptyState: "empty-state",
49
55
  ErrorState: "error-state",
50
56
  LoadingState: "loading-state",
@@ -59,6 +65,7 @@ export const LAYOUT_CONTRACTS: Readonly<Record<string, LayoutContractSpec>> = {
59
65
  Tabs: "tabs",
60
66
  ModuleNav: "module-nav",
61
67
  DataView: "dataview",
68
+ Card: "card",
62
69
  EmptyState: "empty-state",
63
70
  ErrorState: "error-state",
64
71
  LoadingState: "loading-state",
@@ -5,7 +5,7 @@ import { useEffect, useState } from "react";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
  import type { ModuleManifest } from "@terpjs/contract";
7
7
 
8
- import { buildAppRouter } from "./router";
8
+ import { buildAppRouter, useRouteParam } from "./router";
9
9
  import { Page } from "./Page";
10
10
  import { TerpProvider, useAuth } from "./TerpProvider";
11
11
 
@@ -126,6 +126,67 @@ describe("buildAppRouter", () => {
126
126
  }
127
127
  });
128
128
 
129
+ it("useRouteParam reads a declared param and refuses an undeclared name, fail closed", async () => {
130
+ // buildAppRouter realises routes at runtime, so TanStack's type registry cannot
131
+ // check a param name for any app — useRouteParam is the sanctioned read: the
132
+ // declared param comes back, an undeclared name throws a directive error instead
133
+ // of silently yielding undefined (the failure mode of the raw `as {...}` cast).
134
+ vi.spyOn(console, "error").mockImplementation(() => undefined);
135
+ const fetchMock = vi.fn<typeof fetch>(async (input) => {
136
+ const url = (input as Request).url;
137
+ if (url.endsWith("/api/v1/auth/login")) {
138
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
139
+ }
140
+ return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
141
+ });
142
+ vi.stubGlobal("fetch", fetchMock);
143
+
144
+ function ThingView() {
145
+ const thingId = useRouteParam("thingId");
146
+ return <Page title={`Thing ${thingId}`}>thing body</Page>;
147
+ }
148
+ const router = buildAppRouter(
149
+ [{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
150
+ {
151
+ views: { Thing: ThingView },
152
+ title: "Terp",
153
+ history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
154
+ },
155
+ );
156
+ render(
157
+ <TerpProvider baseUrl="https://api.test">
158
+ <LogInOnMount />
159
+ <RouterProvider router={router} />
160
+ </TerpProvider>,
161
+ );
162
+ await waitFor(() =>
163
+ expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
164
+ );
165
+ cleanup();
166
+
167
+ function WrongParamView() {
168
+ const nope = useRouteParam("thisParamDoesNotExist");
169
+ return <Page title={`Wrong ${nope}`}>wrong body</Page>;
170
+ }
171
+ const wrongRouter = buildAppRouter(
172
+ [{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
173
+ {
174
+ views: { Thing: WrongParamView },
175
+ title: "Terp",
176
+ history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
177
+ },
178
+ );
179
+ render(
180
+ <TerpProvider baseUrl="https://api.test">
181
+ <LogInOnMount />
182
+ <RouterProvider router={wrongRouter} />
183
+ </TerpProvider>,
184
+ );
185
+ // The view throws before it can render; the screen never shows the wrong body.
186
+ await waitFor(() => expect(console.error).toHaveBeenCalled());
187
+ expect(screen.queryByRole("heading", { name: /Wrong/ })).not.toBeInTheDocument();
188
+ });
189
+
129
190
  it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
130
191
  // A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
131
192
  // page reload, silently, with nothing to catch it. Inside a Terp router the default
package/src/router.tsx CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  createRouter,
5
5
  Link,
6
6
  Outlet,
7
+ useParams,
7
8
  useRouter,
8
9
  type AnyRoute,
9
10
  type RouterHistory,
@@ -47,6 +48,33 @@ export function routerPath(path: string): string {
47
48
  return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
48
49
  }
49
50
 
51
+ /**
52
+ * Read one route param, fail closed when it is absent.
53
+ *
54
+ * {@link buildAppRouter} realises routes at runtime from manifest data, so TanStack's
55
+ * type-level route registry is empty for every Terp app: `useParams` cannot check a
56
+ * param name anywhere, and the raw idiom is an unchecked cast —
57
+ * `useParams({ strict: false }) as { recordId?: string }` — whose typo typechecks
58
+ * green and renders a broken screen. Until route params are generated as types
59
+ * (ADR 0092), this is the sanctioned read: the param the manifest route path declared
60
+ * (`/records/:recordId`) comes back as a string, and a name the current route did not
61
+ * declare throws a directive error instead of silently yielding undefined.
62
+ */
63
+ export function useRouteParam(name: string): string {
64
+ const params = useParams({ strict: false }) as Record<string, string | undefined>;
65
+ const value = params[name];
66
+ if (value === undefined) {
67
+ const seen = Object.keys(params);
68
+ throw new Error(
69
+ `Route param "${name}" is not present on the current route (params seen: ` +
70
+ `${seen.length > 0 ? seen.join(", ") : "none"}). A param is declared in the ` +
71
+ `module manifest's route path (e.g. "/records/:${name}") and must be read ` +
72
+ "under that route — check the name against the manifest.",
73
+ );
74
+ }
75
+ return value;
76
+ }
77
+
50
78
  export interface BuildAppRouterOptions {
51
79
  /** Maps a manifest route's `view` id to the component that renders it. */
52
80
  views: Record<string, ComponentType>;
package/src/ui/Badge.tsx CHANGED
@@ -26,7 +26,11 @@ const toneColor: Record<BadgeTone, string> = {
26
26
  danger: "var(--color-status-danger)",
27
27
  };
28
28
 
29
- const toneSoft: Record<BadgeTone, string> = {
29
+ /**
30
+ * Soft tint per tone — exported (not via the package barrel) so DataView's row/card
31
+ * tinting resolves a tone to the exact same tokens the Badge pill uses.
32
+ */
33
+ export const toneSoftColors: Record<BadgeTone, string> = {
30
34
  neutral: "var(--color-neutral-100)",
31
35
  info: "var(--color-status-info-soft)",
32
36
  success: "var(--color-status-success-soft)",
@@ -37,11 +41,11 @@ const toneSoft: Record<BadgeTone, string> = {
37
41
  const badgeStyle = (tone: BadgeTone): CSSProperties => ({
38
42
  display: "inline-flex",
39
43
  alignItems: "center",
40
- border: `1px solid ${toneSoft[tone]}`,
44
+ border: `1px solid ${toneSoftColors[tone]}`,
41
45
  borderRadius: "var(--radius-full)",
42
46
  padding: "2px var(--space-2)",
43
47
  color: toneColor[tone],
44
- background: toneSoft[tone],
48
+ background: toneSoftColors[tone],
45
49
  fontSize: "var(--font-size-xs)",
46
50
  fontWeight: "var(--font-weight-semibold)" as never,
47
51
  lineHeight: 1.4,
package/src/ui/Card.tsx CHANGED
@@ -52,9 +52,11 @@ const descriptionStyle: CSSProperties = {
52
52
 
53
53
  /**
54
54
  * A token-styled surface that groups one block of a page — the sanctioned way to give
55
- * sections visual separation (border + background + padding) without module CSS. An
56
- * optional header row carries a semantic `<h3>` title, a muted description and an
57
- * `actions` slot; the body stacks its children on the token spacing scale.
55
+ * sections visual separation (border + background + padding) without module CSS, and
56
+ * allowed directly in `OverviewPage` / `DetailPage` body slots under the `standard`
57
+ * layout contract. An optional header row carries a semantic `<h3>` title, a muted
58
+ * description and an `actions` slot; the body stacks its children on the token
59
+ * spacing scale.
58
60
  */
59
61
  export function Card({
60
62
  title,
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { ApiError, unwrap } from "./unwrap";
3
+ import { ApiError, unwrap, unwrapOptional } from "./unwrap";
4
4
 
5
5
  function response(status: number): Response {
6
6
  return new Response(null, { status });
@@ -65,3 +65,34 @@ describe("unwrap", () => {
65
65
  expect(apiError.message).toBe("Row changed.");
66
66
  });
67
67
  });
68
+
69
+ describe("unwrapOptional", () => {
70
+ it("returns the data on a 2xx result, like unwrap", () => {
71
+ expect(unwrapOptional({ data: { id: "s1" }, response: response(200) })).toEqual({
72
+ id: "s1",
73
+ });
74
+ });
75
+
76
+ it("returns null on a 404 — absence is a normal state, not a failure", () => {
77
+ expect(
78
+ unwrapOptional({
79
+ error: { code: "not_found", detail: "No snapshot published yet." },
80
+ response: response(404),
81
+ }),
82
+ ).toBeNull();
83
+ });
84
+
85
+ it("throws the same ApiError as unwrap for every other failure", () => {
86
+ let caught: unknown;
87
+ try {
88
+ unwrapOptional({
89
+ error: { code: "permission_denied", detail: "You do not have permission." },
90
+ response: response(403),
91
+ });
92
+ } catch (error) {
93
+ caught = error;
94
+ }
95
+ expect(caught).toBeInstanceOf(ApiError);
96
+ expect((caught as ApiError).status).toBe(403);
97
+ });
98
+ });
package/src/unwrap.ts CHANGED
@@ -40,6 +40,22 @@ export class ApiError extends Error {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * {@link unwrap} for a resource whose absence is a normal state, not a failure: returns
45
+ * the data on success, `null` on a 404, and throws the same {@link ApiError} for every
46
+ * other failure. The client-side analog of the backend's `BaseService.find` beside
47
+ * `get` — reach for `unwrap` when a missing record ends the request, `unwrapOptional`
48
+ * when "not there yet" is an answer (a `/latest` snapshot that has not been published,
49
+ * an optional singleton). Without it, expressing that state means exception control
50
+ * flow around `unwrap` at every call site.
51
+ */
52
+ export function unwrapOptional<T>(result: FetchResult<T>): T | null {
53
+ if (result.response.status === 404) {
54
+ return null;
55
+ }
56
+ return unwrap(result);
57
+ }
58
+
43
59
  /** Return the result's `data` on success, or throw an {@link ApiError} describing the failure. */
44
60
  export function unwrap<T>(result: FetchResult<T>): T {
45
61
  if (result.error !== undefined || !result.response.ok) {
@@ -0,0 +1,81 @@
1
+ // @vitest-environment jsdom
2
+ import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { useRecord } from "./useRecord";
6
+
7
+ afterEach(cleanup);
8
+
9
+ describe("useRecord", () => {
10
+ it("loads on mount and exposes the record", async () => {
11
+ const { result } = renderHook(() =>
12
+ useRecord<{ id: string }>({ get: async () => ({ id: "r1" }) }),
13
+ );
14
+ expect(result.current.loading).toBe(true);
15
+ expect(result.current.item).toBeNull();
16
+ await waitFor(() => expect(result.current.loading).toBe(false));
17
+ expect(result.current.item).toEqual({ id: "r1" });
18
+ expect(result.current.error).toBeNull();
19
+ });
20
+
21
+ it("treats a null get as a normal absent state, not an error (unwrapOptional composes)", async () => {
22
+ const { result } = renderHook(() => useRecord<{ id: string }>({ get: async () => null }));
23
+ await waitFor(() => expect(result.current.loading).toBe(false));
24
+ expect(result.current.item).toBeNull();
25
+ expect(result.current.error).toBeNull();
26
+ });
27
+
28
+ it("reloads when a declared dependency changes (in-place route-param navigation)", async () => {
29
+ const get = vi.fn(async (id: string) => ({ id }));
30
+ const { result, rerender } = renderHook(
31
+ ({ id }: { id: string }) => useRecord({ get: () => get(id) }, [id]),
32
+ { initialProps: { id: "r1" } },
33
+ );
34
+ await waitFor(() => expect(result.current.item).toEqual({ id: "r1" }));
35
+
36
+ rerender({ id: "r2" });
37
+ await waitFor(() => expect(result.current.item).toEqual({ id: "r2" }));
38
+ expect(get).toHaveBeenCalledTimes(2);
39
+
40
+ // A rerender with the same dependency does not refetch.
41
+ rerender({ id: "r2" });
42
+ await waitFor(() => expect(result.current.loading).toBe(false));
43
+ expect(get).toHaveBeenCalledTimes(2);
44
+ });
45
+
46
+ it("captures a get error as a message and its cause", async () => {
47
+ const { result } = renderHook(() =>
48
+ useRecord<string>({
49
+ get: async () => {
50
+ throw new Error("boom");
51
+ },
52
+ }),
53
+ );
54
+ await waitFor(() => expect(result.current.error).toBe("boom"));
55
+ expect(result.current.cause).toBeInstanceOf(Error);
56
+ expect(result.current.item).toBeNull();
57
+ });
58
+
59
+ it("mutate surfaces write failures, rejects, and reloads on success", async () => {
60
+ let label = "before";
61
+ const { result } = renderHook(() => useRecord({ get: async () => ({ label }) }));
62
+ await waitFor(() => expect(result.current.loading).toBe(false));
63
+
64
+ await act(async () => {
65
+ await expect(
66
+ result.current.mutate(async () => {
67
+ throw new Error("Save failed.");
68
+ }),
69
+ ).rejects.toThrow("Save failed.");
70
+ });
71
+ expect(result.current.error).toBe("Save failed.");
72
+
73
+ await act(async () => {
74
+ await result.current.mutate(async () => {
75
+ label = "after";
76
+ });
77
+ });
78
+ expect(result.current.item).toEqual({ label: "after" });
79
+ expect(result.current.error).toBeNull();
80
+ });
81
+ });
@@ -0,0 +1,64 @@
1
+ import { useRef } from "react";
2
+
3
+ import { useResource } from "./useResource";
4
+
5
+ /** One async record: the loaded row (or `null`) plus loading/error state and a reload. */
6
+ export interface RecordResource<T> {
7
+ /** The loaded record, or `null` until the load resolves / when it does not exist. */
8
+ item: T | null;
9
+ /** True while the initial load or a reload is in flight. */
10
+ loading: boolean;
11
+ /** The last error message, or `null` when the most recent load succeeded. */
12
+ error: string | null;
13
+ /**
14
+ * The last caught failure itself, or `null` — typically the `ApiError` thrown by
15
+ * `unwrap`, whose stable `code` lets `useErrorMessage` map it to client-owned copy.
16
+ */
17
+ cause?: unknown;
18
+ /** Re-run the get query. */
19
+ reload: () => Promise<void>;
20
+ /** Run any record-specific mutation (patch, delete, action), surface failures, then reload. */
21
+ mutate: (operation: () => Promise<void>) => Promise<void>;
22
+ }
23
+
24
+ /** How a detail screen fetches its one record — typically a typed contract-client call. */
25
+ export interface RecordSource<T> {
26
+ /**
27
+ * Fetch the record. Return `null` for "does not exist and that is a normal state"
28
+ * (compose with `unwrapOptional`); let `unwrap` throw when absence is an error.
29
+ */
30
+ get: () => Promise<T | null>;
31
+ }
32
+
33
+ /**
34
+ * The singleton counterpart of {@link useResource}: the one record a detail screen
35
+ * shows, instead of a collection. Before it existed every detail page spelled the
36
+ * record as a one-element list — `list: async () => [unwrap(await client.GET(...))]`
37
+ * then `items[0]` — a wart this hook deletes wherever detail pages exist.
38
+ *
39
+ * Same contract as `useResource`: `source` may be rebuilt each render (read through a
40
+ * ref), and `deps` (e.g. the route param the query closes over) reloads on in-place
41
+ * navigation between records. Implemented over `useResource` so the two state
42
+ * machines cannot drift.
43
+ */
44
+ export function useRecord<T>(
45
+ source: RecordSource<T>,
46
+ deps: readonly unknown[] = [],
47
+ ): RecordResource<T> {
48
+ const sourceRef = useRef(source);
49
+ sourceRef.current = source;
50
+
51
+ const resource = useResource<T | null>(
52
+ { list: async () => [await sourceRef.current.get()] },
53
+ deps,
54
+ );
55
+
56
+ return {
57
+ item: resource.items[0] ?? null,
58
+ loading: resource.loading,
59
+ error: resource.error,
60
+ cause: resource.cause,
61
+ reload: resource.reload,
62
+ mutate: resource.mutate,
63
+ };
64
+ }