@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 +46 -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 +24 -2
- package/src/layoutContract.test.tsx +2 -0
- package/src/layoutContract.ts +9 -2
- package/src/routeTypes.ts +63 -0
- package/src/router.test.tsx +178 -1
- package/src/router.tsx +125 -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,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`
|
|
88
|
-
states. The plain `Page` stays unconstrained (the sanctioned home for a
|
|
89
|
-
screen).
|
|
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.
|
|
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.
|
|
16
|
+
"@terpjs/contract": "^0.6.0"
|
|
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 { 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
|
|
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 =
|
|
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 { useDeclaredParam } 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 = 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 =
|
|
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,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 {
|
|
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 {
|