@terpjs/react-core 0.5.9 → 0.6.0

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,9 +71,47 @@ 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`. Replaces the unchecked `useParams({ strict: false }) as {…}` cast (ADR 0092). Checked against the generated route table when the app has one. |
76
+ | `useRouteParams` | Read a whole declared route's params, typed exactly: `const { recordId } = useRouteParams("/records/:recordId")`. With a generated route table, a typo in the path *or* a param name is a typecheck error. |
77
+ | `useTerpNavigate` | Navigate by manifest path: `navigate({ to: "/records/:recordId", params: { recordId } })`. An undeclared path is a typecheck error and a parameterised route requires its params — a typo'd path used to be a dead link that shipped green. Takes the manifest's `:id` spelling and translates to the router's `$id`. |
74
78
  | `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
75
79
  | `PageActions` | Primary action + overflow menu for a page header. |
76
80
 
81
+ ### Typed route paths and params (generated, ADR 0092)
82
+
83
+ `buildAppRouter` realises routes at runtime from manifest data, which leaves TanStack
84
+ Router's type registry empty: nothing checks a route path or a param name, so a typo'd
85
+ path is a dead link and a typo'd param silently reads `undefined`. The manifests are
86
+ static data, so the check is generated from them:
87
+
88
+ ```bash
89
+ npm --prefix frontend run routes # or: uv run terp routes
90
+ ```
91
+
92
+ `terp-routes` reads every `src/modules/<name>/module.tsx` manifest and writes a
93
+ **committed** `src/routes.gen.d.ts` that augments `TerpRouteTable`:
94
+
95
+ ```ts
96
+ declare module "@terpjs/react-core" {
97
+ interface TerpRouteTable {
98
+ "/records": Record<never, never>;
99
+ "/records/:recordId": { recordId: string };
100
+ }
101
+ }
102
+ ```
103
+
104
+ From then on `useRouteParams("/records/:recordId")` is exact, `useRouteParam` refuses a
105
+ param no route declares, and `useTerpNavigate` refuses an undeclared path. Regenerate
106
+ after changing a manifest route — `terp verify`'s `routes-drift` check refuses a stale
107
+ table and names the command (it runs before the typecheck, so a stale table reads as
108
+ "regenerate", not as errors in your own screens). A route whose `path` is not a plain
109
+ string literal is refused with its file and line rather than silently omitted: a partial
110
+ table would turn a real path into a type error. Routes a packaged area mounts (the admin
111
+ area) are not keyed — the file stays a pure function of the app's own manifests. Without
112
+ a generated file every helper falls back to `string`, so adopting is opt-in: add the
113
+ `routes` script, generate, commit.
114
+
77
115
  ### Slot-typed layout contracts (opt-in, ADR 0079)
78
116
 
79
117
  An app can ratchet the archetype control further with a named **layout contract**:
@@ -82,11 +120,13 @@ An app can ratchet the archetype control further with a named **layout contract*
82
120
  `terp/layout-contract` lint half — keep the two in sync; the project template generates
83
121
  both). Each governed archetype's body slot then accepts **only** the contract's
84
122
  components — `standard`: hub bodies hold `HubCard` only; overview bodies hold
85
- `DataView` / `ResourceList` / `ModuleNav` / `Stack` plus the framework states
123
+ `DataView` / `ResourceList` / `ModuleNav` / `Stack` / `Card` plus the framework states
86
124
  (`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
125
+ bodies hold `DetailList` / `Stack` / `Tabs` / `ModuleNav` / `DataView` / `Card` plus
126
+ the same states. The plain `Page` stays unconstrained (the sanctioned home for a
127
+ bespoke screen). Only the slot's **direct** children are governed an allowed
128
+ container's own subtree (a `Card` body, a `Stack` of rows) is the app's to compose.
129
+ Enforcement is two-layer and fail-closed: the lint rule checks static JSX
90
130
  children; the archetypes verify the rendered DOM (sanctioned components stamp a
91
131
  `data-terp` marker) and refuse a non-conforming view with the **same directive
92
132
  message** — contract, slot, what was found, what is allowed, and the fix — so a
@@ -103,9 +143,10 @@ marker, counted by the escape-hatch budget.
103
143
  | `InMemoryDataViewRepository`, `HttpDataViewRepository` | Data repositories (client-side / server-side); `useServerDataView` keeps server query state in the URL. |
104
144
  | `InMemoryViewStateRepository`, `LocalStorageViewStateRepository` | Preference persistence seam. |
105
145
  | `useResource` | An async collection: rows + loading/error + reload + create-then-reload. |
146
+ | `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
147
  | `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
148
  | `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`. |
149
+ | `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
150
  | `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
151
 
111
152
  ## 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.6.0",
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.6.0"
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 { useDeclaredParam } 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 = useDeclaredParam("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 { useDeclaredParam } 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 = useDeclaredParam("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,8 +116,24 @@ 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 {
120
+ buildAppRouter,
121
+ DEFAULT_ROLE_RANKS,
122
+ PROFILE_PATH,
123
+ useRouteParam,
124
+ useRouteParams,
125
+ useTerpNavigate,
126
+ } from "./router";
114
127
  export type { BuildAppRouterOptions } from "./router";
128
+ // The generated `routes.gen.d.ts` augments TerpRouteTable (ADR 0092); the derived types
129
+ // are exported so an app can name a route path or a param object in its own signatures.
130
+ export type {
131
+ TerpNavigateTarget,
132
+ TerpRouteParamName,
133
+ TerpRouteParams,
134
+ TerpRoutePath,
135
+ TerpRouteTable,
136
+ } from "./routeTypes";
115
137
  export { LoginView } from "./LoginView";
116
138
  export type { DevCredentials, LoginViewProps } from "./LoginView";
117
139
  export {