@terpjs/react-core 0.5.8 → 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 +10 -5
- package/package.json +2 -2
- package/src/DetailPage.tsx +1 -1
- package/src/OverviewPage.tsx +1 -1
- package/src/admin/GroupDetail.tsx +11 -13
- package/src/admin/UserDetail.tsx +10 -12
- package/src/dataview/DataView.test.tsx +14 -0
- package/src/dataview/DataView.tsx +11 -0
- package/src/dataview/DataViewCardList.tsx +6 -1
- package/src/dataview/DataViewTable.tsx +13 -1
- package/src/dataview/README.md +11 -1
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +39 -16
- package/src/dataview/repositories/repositories.test.ts +20 -0
- package/src/dataview/types.ts +2 -0
- package/src/index.ts +8 -2
- package/src/layoutContract.test.tsx +2 -0
- package/src/layoutContract.ts +9 -2
- package/src/router.test.tsx +62 -1
- package/src/router.tsx +28 -0
- package/src/ui/Badge.tsx +7 -3
- package/src/ui/Card.tsx +5 -3
- package/src/unwrap.test.ts +32 -1
- package/src/unwrap.ts +16 -0
- package/src/useRecord.test.tsx +81 -0
- package/src/useRecord.ts +64 -0
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`
|
|
88
|
-
states. The plain `Page` stays unconstrained (the sanctioned home for a
|
|
89
|
-
screen).
|
|
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.
|
|
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.
|
|
16
|
+
"@terpjs/contract": "^0.5.10"
|
|
17
17
|
},
|
|
18
18
|
"peerDependencies": {
|
|
19
19
|
"react": "^18.3.0 || ^19.0.0",
|
package/src/DetailPage.tsx
CHANGED
|
@@ -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 (
|
package/src/OverviewPage.tsx
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
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 =
|
|
78
|
+
const group = useRecord<GroupRead>(
|
|
79
79
|
{
|
|
80
|
-
|
|
81
|
-
|
|
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.
|
|
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 !==
|
|
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 !==
|
|
332
|
+
{record !== null && (
|
|
335
333
|
<DetailList
|
|
336
334
|
items={[
|
|
337
335
|
{ label: strings.description, value: record.description || "-" },
|
package/src/admin/UserDetail.tsx
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
49
|
+
const user = useRecord<UserRead>(
|
|
51
50
|
{
|
|
52
|
-
|
|
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.
|
|
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 ===
|
|
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 ===
|
|
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 !==
|
|
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 !==
|
|
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
|
-
|
|
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 && (
|
package/src/dataview/README.md
CHANGED
|
@@ -36,7 +36,11 @@ const columns: DataViewColumn<Ticket>[] = [
|
|
|
36
36
|
|
|
37
37
|
const repository = new InMemoryDataViewRepository(tickets, {
|
|
38
38
|
getRowId: (t) => t.id,
|
|
39
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
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:
|
|
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?:
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
128
|
-
this.
|
|
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, {
|
package/src/dataview/types.ts
CHANGED
|
@@ -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>
|
package/src/layoutContract.ts
CHANGED
|
@@ -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)
|
|
37
|
-
"
|
|
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",
|
package/src/router.test.tsx
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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:
|
|
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
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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,
|
package/src/unwrap.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/useRecord.ts
ADDED
|
@@ -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
|
+
}
|