@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
|
@@ -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",
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The type-level route table an app's generated `routes.gen.d.ts` augments (ADR 0092).
|
|
3
|
+
*
|
|
4
|
+
* `buildAppRouter` realises routes at runtime from manifest data, so TanStack Router's
|
|
5
|
+
* own type registry is empty for every Terp app: no route path and no param name is
|
|
6
|
+
* checked anywhere, and a typo ships green. The information to check them is already
|
|
7
|
+
* checked in — the manifests are static data — so `terp routes` extracts it and emits a
|
|
8
|
+
* committed declaration file that augments this interface:
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* declare module "@terpjs/react-core" {
|
|
12
|
+
* interface TerpRouteTable {
|
|
13
|
+
* "/records": Record<never, never>;
|
|
14
|
+
* "/records/:recordId": { recordId: string };
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* Paths are keyed in the manifest's stack-agnostic spelling (`:recordId`, not `$recordId`),
|
|
20
|
+
* which is the spelling {@link useTerpNavigate} accepts and translates.
|
|
21
|
+
*
|
|
22
|
+
* Deliberately empty here: with no generated file the table has no keys, every helper
|
|
23
|
+
* below falls back to `string`, and an app that has not generated keeps exactly today's
|
|
24
|
+
* behavior. Generating is what turns these checks on.
|
|
25
|
+
*/
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
|
|
27
|
+
export interface TerpRouteTable {}
|
|
28
|
+
|
|
29
|
+
/** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
|
|
30
|
+
type Ungenerated = keyof TerpRouteTable extends never ? true : false;
|
|
31
|
+
|
|
32
|
+
/** Every route path the app's manifests declare — or `string` before generating. */
|
|
33
|
+
export type TerpRoutePath = Ungenerated extends true ? string : keyof TerpRouteTable & string;
|
|
34
|
+
|
|
35
|
+
/** The params of one declared route (`Record<never, never>` for a paramless route). */
|
|
36
|
+
export type TerpRouteParams<P extends TerpRoutePath> = P extends keyof TerpRouteTable
|
|
37
|
+
? TerpRouteTable[P]
|
|
38
|
+
: Record<string, string>;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Every param name declared by any route — or `string` before generating.
|
|
42
|
+
*
|
|
43
|
+
* This is the union across all routes, not per route: it refuses a name no route
|
|
44
|
+
* declares (the reported failure mode, where a typo silently yielded `undefined`),
|
|
45
|
+
* while a name belonging to a *different* route stays a runtime refusal. Read params
|
|
46
|
+
* through {@link useRouteParams} when you want the exact, per-route check.
|
|
47
|
+
*/
|
|
48
|
+
export type TerpRouteParamName = Ungenerated extends true
|
|
49
|
+
? string
|
|
50
|
+
: { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A navigation target: a declared path, plus that path's params when it takes any.
|
|
54
|
+
* A paramless route refuses a `params` object; a parameterised one requires it, with
|
|
55
|
+
* the names the manifest declared. Before generating, this is the loose shape.
|
|
56
|
+
*/
|
|
57
|
+
export type TerpNavigateTarget = Ungenerated extends true
|
|
58
|
+
? { to: string; params?: Record<string, string> }
|
|
59
|
+
: {
|
|
60
|
+
[P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
|
|
61
|
+
? { to: P; params?: undefined }
|
|
62
|
+
: { to: P; params: TerpRouteTable[P] };
|
|
63
|
+
}[keyof TerpRouteTable & string];
|
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, useRouteParams, useTerpNavigate } from "./router";
|
|
9
9
|
import { Page } from "./Page";
|
|
10
10
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
11
11
|
|
|
@@ -16,6 +16,22 @@ function jsonResponse(body: unknown): Response {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/** The login + session-probe fetch a routed test needs to reach a guarded view. */
|
|
20
|
+
function sessionFetch() {
|
|
21
|
+
return vi.fn<typeof fetch>(async (input) => {
|
|
22
|
+
const url = (input as Request).url;
|
|
23
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
24
|
+
return jsonResponse({ access_token: "t", token_type: "bearer" });
|
|
25
|
+
}
|
|
26
|
+
return jsonResponse({
|
|
27
|
+
id: "1",
|
|
28
|
+
email: "editor@example.com",
|
|
29
|
+
role_rank: 20,
|
|
30
|
+
role_name: "editor",
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
19
35
|
afterEach(() => {
|
|
20
36
|
cleanup();
|
|
21
37
|
vi.restoreAllMocks();
|
|
@@ -126,6 +142,167 @@ describe("buildAppRouter", () => {
|
|
|
126
142
|
}
|
|
127
143
|
});
|
|
128
144
|
|
|
145
|
+
it("useRouteParam reads a declared param and refuses an undeclared name, fail closed", async () => {
|
|
146
|
+
// buildAppRouter realises routes at runtime, so TanStack's type registry cannot
|
|
147
|
+
// check a param name for any app — useRouteParam is the sanctioned read: the
|
|
148
|
+
// declared param comes back, an undeclared name throws a directive error instead
|
|
149
|
+
// of silently yielding undefined (the failure mode of the raw `as {...}` cast).
|
|
150
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
151
|
+
const fetchMock = vi.fn<typeof fetch>(async (input) => {
|
|
152
|
+
const url = (input as Request).url;
|
|
153
|
+
if (url.endsWith("/api/v1/auth/login")) {
|
|
154
|
+
return jsonResponse({ access_token: "t", token_type: "bearer" });
|
|
155
|
+
}
|
|
156
|
+
return jsonResponse({ id: "1", email: "editor@example.com", role_rank: 20, role_name: "editor" });
|
|
157
|
+
});
|
|
158
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
159
|
+
|
|
160
|
+
function ThingView() {
|
|
161
|
+
const thingId = useRouteParam("thingId");
|
|
162
|
+
return <Page title={`Thing ${thingId}`}>thing body</Page>;
|
|
163
|
+
}
|
|
164
|
+
const router = buildAppRouter(
|
|
165
|
+
[{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
|
|
166
|
+
{
|
|
167
|
+
views: { Thing: ThingView },
|
|
168
|
+
title: "Terp",
|
|
169
|
+
history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
render(
|
|
173
|
+
<TerpProvider baseUrl="https://api.test">
|
|
174
|
+
<LogInOnMount />
|
|
175
|
+
<RouterProvider router={router} />
|
|
176
|
+
</TerpProvider>,
|
|
177
|
+
);
|
|
178
|
+
await waitFor(() =>
|
|
179
|
+
expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
|
|
180
|
+
);
|
|
181
|
+
cleanup();
|
|
182
|
+
|
|
183
|
+
function WrongParamView() {
|
|
184
|
+
const nope = useRouteParam("thisParamDoesNotExist");
|
|
185
|
+
return <Page title={`Wrong ${nope}`}>wrong body</Page>;
|
|
186
|
+
}
|
|
187
|
+
const wrongRouter = buildAppRouter(
|
|
188
|
+
[{ name: "things", routes: [{ path: "/things/:thingId", view: "Thing" }], nav: [] }],
|
|
189
|
+
{
|
|
190
|
+
views: { Thing: WrongParamView },
|
|
191
|
+
title: "Terp",
|
|
192
|
+
history: createMemoryHistory({ initialEntries: ["/things/abc"] }),
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
render(
|
|
196
|
+
<TerpProvider baseUrl="https://api.test">
|
|
197
|
+
<LogInOnMount />
|
|
198
|
+
<RouterProvider router={wrongRouter} />
|
|
199
|
+
</TerpProvider>,
|
|
200
|
+
);
|
|
201
|
+
// The view throws before it can render; the screen never shows the wrong body.
|
|
202
|
+
await waitFor(() => expect(console.error).toHaveBeenCalled());
|
|
203
|
+
expect(screen.queryByRole("heading", { name: /Wrong/ })).not.toBeInTheDocument();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("useRouteParams reads a whole declared route's params, and refuses a stale one", async () => {
|
|
207
|
+
// The exact read (ADR 0092): keyed by the manifest path, every param that path
|
|
208
|
+
// declares must be present. A generated table that no longer matches the manifest
|
|
209
|
+
// therefore fails closed naming the param, instead of leaking undefined into a request.
|
|
210
|
+
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
211
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
212
|
+
|
|
213
|
+
function PairView() {
|
|
214
|
+
const { spaceId, itemId } = useRouteParams("/spaces/:spaceId/items/:itemId");
|
|
215
|
+
return <Page title={`${spaceId}/${itemId}`}>pair body</Page>;
|
|
216
|
+
}
|
|
217
|
+
render(
|
|
218
|
+
<TerpProvider baseUrl="https://api.test">
|
|
219
|
+
<LogInOnMount />
|
|
220
|
+
<RouterProvider
|
|
221
|
+
router={buildAppRouter(
|
|
222
|
+
[{ name: "spaces", routes: [{ path: "/spaces/:spaceId/items/:itemId", view: "Pair" }] }],
|
|
223
|
+
{
|
|
224
|
+
views: { Pair: PairView },
|
|
225
|
+
title: "Terp",
|
|
226
|
+
history: createMemoryHistory({ initialEntries: ["/spaces/s1/items/i9"] }),
|
|
227
|
+
},
|
|
228
|
+
)}
|
|
229
|
+
/>
|
|
230
|
+
</TerpProvider>,
|
|
231
|
+
);
|
|
232
|
+
await waitFor(() =>
|
|
233
|
+
expect(screen.getByRole("heading", { name: "s1/i9" })).toBeInTheDocument(),
|
|
234
|
+
);
|
|
235
|
+
cleanup();
|
|
236
|
+
|
|
237
|
+
// The same view under a route that declares only one of the two params.
|
|
238
|
+
render(
|
|
239
|
+
<TerpProvider baseUrl="https://api.test">
|
|
240
|
+
<LogInOnMount />
|
|
241
|
+
<RouterProvider
|
|
242
|
+
router={buildAppRouter(
|
|
243
|
+
[{ name: "spaces", routes: [{ path: "/spaces/:spaceId", view: "Pair" }] }],
|
|
244
|
+
{
|
|
245
|
+
views: { Pair: PairView },
|
|
246
|
+
title: "Terp",
|
|
247
|
+
history: createMemoryHistory({ initialEntries: ["/spaces/s1"] }),
|
|
248
|
+
},
|
|
249
|
+
)}
|
|
250
|
+
/>
|
|
251
|
+
</TerpProvider>,
|
|
252
|
+
);
|
|
253
|
+
await waitFor(() => expect(console.error).toHaveBeenCalled());
|
|
254
|
+
expect(screen.queryByRole("heading", { name: /s1/ })).not.toBeInTheDocument();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("useTerpNavigate navigates by the manifest's path spelling, carrying params", async () => {
|
|
258
|
+
// The manifest spells a param `:id`; TanStack wants `$id`. useTerpNavigate takes the
|
|
259
|
+
// manifest spelling (what the generated table is keyed by) and translates, so a caller
|
|
260
|
+
// never holds two spellings — and the params ride along.
|
|
261
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
262
|
+
|
|
263
|
+
function ListView() {
|
|
264
|
+
const navigate = useTerpNavigate();
|
|
265
|
+
return (
|
|
266
|
+
<Page title="Things view">
|
|
267
|
+
<button type="button" onClick={() => void navigate({ to: "/things/:thingId", params: { thingId: "abc" } })}>
|
|
268
|
+
open abc
|
|
269
|
+
</button>
|
|
270
|
+
</Page>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
function DetailView() {
|
|
274
|
+
return <Page title={`Thing ${useRouteParam("thingId")}`}>thing body</Page>;
|
|
275
|
+
}
|
|
276
|
+
render(
|
|
277
|
+
<TerpProvider baseUrl="https://api.test">
|
|
278
|
+
<LogInOnMount />
|
|
279
|
+
<RouterProvider
|
|
280
|
+
router={buildAppRouter(
|
|
281
|
+
[
|
|
282
|
+
{
|
|
283
|
+
name: "things",
|
|
284
|
+
routes: [
|
|
285
|
+
{ path: "/things", view: "ThingsList" },
|
|
286
|
+
{ path: "/things/:thingId", view: "ThingDetail" },
|
|
287
|
+
],
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
{
|
|
291
|
+
views: { ThingsList: ListView, ThingDetail: DetailView },
|
|
292
|
+
title: "Terp",
|
|
293
|
+
history: createMemoryHistory({ initialEntries: ["/things"] }),
|
|
294
|
+
},
|
|
295
|
+
)}
|
|
296
|
+
/>
|
|
297
|
+
</TerpProvider>,
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
fireEvent.click(await screen.findByRole("button", { name: "open abc" }));
|
|
301
|
+
await waitFor(() =>
|
|
302
|
+
expect(screen.getByRole("heading", { name: "Thing abc" })).toBeInTheDocument(),
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
129
306
|
it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
|
|
130
307
|
// A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
|
|
131
308
|
// page reload, silently, with nothing to catch it. Inside a Terp router the default
|
package/src/router.tsx
CHANGED
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
createRouter,
|
|
5
5
|
Link,
|
|
6
6
|
Outlet,
|
|
7
|
+
useNavigate,
|
|
8
|
+
useParams,
|
|
7
9
|
useRouter,
|
|
8
10
|
type AnyRoute,
|
|
9
11
|
type RouterHistory,
|
|
@@ -14,6 +16,12 @@ import type { ModuleManifest } from "@terpjs/contract";
|
|
|
14
16
|
|
|
15
17
|
import { AppShell } from "./AppShell";
|
|
16
18
|
import { ProfileView } from "./ProfileView";
|
|
19
|
+
import type {
|
|
20
|
+
TerpNavigateTarget,
|
|
21
|
+
TerpRouteParamName,
|
|
22
|
+
TerpRouteParams,
|
|
23
|
+
TerpRoutePath,
|
|
24
|
+
} from "./routeTypes";
|
|
17
25
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
18
26
|
import { visibleNav } from "./nav";
|
|
19
27
|
import { NavLinkContext } from "./navLink";
|
|
@@ -47,6 +55,123 @@ export function routerPath(path: string): string {
|
|
|
47
55
|
return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
|
|
48
56
|
}
|
|
49
57
|
|
|
58
|
+
/** Read the router's params bag, whatever route matched (a hook: it reads router state). */
|
|
59
|
+
function useCurrentParams(): Record<string, string | undefined> {
|
|
60
|
+
return useParams({ strict: false }) as Record<string, string | undefined>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The shared refusal both param reads raise, phrased as a directive. */
|
|
64
|
+
function missingParam(name: string, params: Record<string, string | undefined>): Error {
|
|
65
|
+
const seen = Object.keys(params);
|
|
66
|
+
return new Error(
|
|
67
|
+
`Route param "${name}" is not present on the current route (params seen: ` +
|
|
68
|
+
`${seen.length > 0 ? seen.join(", ") : "none"}). A param is declared in the ` +
|
|
69
|
+
`module manifest's route path (e.g. "/records/:${name}") and must be read ` +
|
|
70
|
+
"under that route — check the name against the manifest, and run `terp routes` " +
|
|
71
|
+
"if the manifest changed.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read one route param by name, fail closed — the untyped core.
|
|
77
|
+
*
|
|
78
|
+
* Internal on purpose. The app-facing {@link useRouteParam} checks the name against the
|
|
79
|
+
* app's generated route table, and this package's own packaged screens (the admin area)
|
|
80
|
+
* must NOT be checked against it: their routes come from this package's manifest, not
|
|
81
|
+
* from the app's, so constraining them to the app's declared params would fail an app
|
|
82
|
+
* that simply declares no params of its own. A packaged screen's route correctness is
|
|
83
|
+
* this package's own test surface.
|
|
84
|
+
*/
|
|
85
|
+
export function useDeclaredParam(name: string): string {
|
|
86
|
+
const params = useCurrentParams();
|
|
87
|
+
const value = params[name];
|
|
88
|
+
if (value === undefined) {
|
|
89
|
+
throw missingParam(name, params);
|
|
90
|
+
}
|
|
91
|
+
return value;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read one route param, fail closed when it is absent.
|
|
96
|
+
*
|
|
97
|
+
* The name is checked against the app's generated route table when there is one
|
|
98
|
+
* ({@link TerpRouteParamName} — every param name any manifest route declares), and is
|
|
99
|
+
* a plain `string` before `terp routes` has generated. Either way a name the *current*
|
|
100
|
+
* route did not declare throws a directive error instead of silently yielding
|
|
101
|
+
* `undefined`, which is what the raw `useParams({ strict: false }) as {…}` cast did.
|
|
102
|
+
*
|
|
103
|
+
* Reach for {@link useRouteParams} when you want the exact, per-route check: keyed by
|
|
104
|
+
* the route path, it refuses a param that route does not declare, not merely one no
|
|
105
|
+
* route declares.
|
|
106
|
+
*/
|
|
107
|
+
export function useRouteParam(name: TerpRouteParamName): string {
|
|
108
|
+
return useDeclaredParam(name);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Read a declared route's params as a typed object — the exact read (ADR 0092).
|
|
113
|
+
*
|
|
114
|
+
* ```tsx
|
|
115
|
+
* const { recordId } = useRouteParams("/records/:recordId");
|
|
116
|
+
* ```
|
|
117
|
+
*
|
|
118
|
+
* Once `terp routes` has generated the app's route table, the path must be one the
|
|
119
|
+
* manifests declare and the returned object carries exactly that route's params, so a
|
|
120
|
+
* typo in either the path or a param name is a typecheck error rather than a runtime
|
|
121
|
+
* surprise. Before generating, the path is a plain `string` and the result is a
|
|
122
|
+
* string-keyed record.
|
|
123
|
+
*
|
|
124
|
+
* The path is the manifest's stack-agnostic spelling (`:recordId`). Every declared
|
|
125
|
+
* param must be present at runtime; a missing one fails closed, which is how a stale
|
|
126
|
+
* generated table (manifest changed, `terp routes` not re-run) surfaces as an error
|
|
127
|
+
* naming the param instead of `undefined` flowing into a request.
|
|
128
|
+
*/
|
|
129
|
+
export function useRouteParams<P extends TerpRoutePath>(path: P): TerpRouteParams<P> {
|
|
130
|
+
const params = useCurrentParams();
|
|
131
|
+
const declared = declaredParamNames(path);
|
|
132
|
+
const resolved: Record<string, string> = {};
|
|
133
|
+
for (const name of declared) {
|
|
134
|
+
const value = params[name];
|
|
135
|
+
if (value === undefined) {
|
|
136
|
+
throw missingParam(name, params);
|
|
137
|
+
}
|
|
138
|
+
resolved[name] = value;
|
|
139
|
+
}
|
|
140
|
+
return resolved as TerpRouteParams<P>;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The param names a manifest path declares, in declaration order (`:name` segments). */
|
|
144
|
+
function declaredParamNames(path: string): string[] {
|
|
145
|
+
return [...path.matchAll(/(?:^|\/)[:$]([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1]!);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Navigate to a declared route, with that route's params (ADR 0092).
|
|
150
|
+
*
|
|
151
|
+
* ```tsx
|
|
152
|
+
* const navigate = useTerpNavigate();
|
|
153
|
+
* void navigate({ to: "/records/:recordId", params: { recordId: row.id } });
|
|
154
|
+
* ```
|
|
155
|
+
*
|
|
156
|
+
* The reason to prefer this over the router's own `navigate`: once the route table is
|
|
157
|
+
* generated, an undeclared path is a typecheck error, a parameterised route *requires*
|
|
158
|
+
* its params, and the param names are the manifest's. A typo'd path was previously a
|
|
159
|
+
* dead link that shipped green — nothing checked it, because the route tree is built at
|
|
160
|
+
* runtime. Paths are written in the manifest spelling and translated to the router's
|
|
161
|
+
* dialect here ({@link routerPath}), so callers never hold two spellings.
|
|
162
|
+
*/
|
|
163
|
+
export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void> {
|
|
164
|
+
const navigate = useNavigate();
|
|
165
|
+
return (target: TerpNavigateTarget) =>
|
|
166
|
+
navigate({
|
|
167
|
+
to: routerPath(target.to),
|
|
168
|
+
// The reducer form, not a bare object: on a router whose route tree is built at
|
|
169
|
+
// runtime TanStack types `params` as a reducer (or `true`), and merging over the
|
|
170
|
+
// previous params is also the honest semantic for an in-place param change.
|
|
171
|
+
params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
50
175
|
export interface BuildAppRouterOptions {
|
|
51
176
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
52
177
|
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
|
+
});
|