@terpjs/react-core 0.5.10 → 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 +37 -1
- package/package.json +2 -2
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserDetail.tsx +2 -2
- package/src/index.ts +17 -1
- package/src/routeTypes.ts +63 -0
- package/src/router.test.tsx +117 -1
- package/src/router.tsx +115 -18
package/README.md
CHANGED
|
@@ -72,10 +72,46 @@ runtime, fail closed (ADR 0059), so every screen keeps the breadcrumb/title/erro
|
|
|
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
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`.
|
|
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`. |
|
|
76
78
|
| `ModuleNav` | Secondary horizontal tabs for intra-module sub-pages (real routes, not state). |
|
|
77
79
|
| `PageActions` | Primary action + overflow menu for a page header. |
|
|
78
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
|
+
|
|
79
115
|
### Slot-typed layout contracts (opt-in, ADR 0079)
|
|
80
116
|
|
|
81
117
|
An app can ratchet the archetype control further with a named **layout contract**:
|
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",
|
|
@@ -12,7 +12,7 @@ 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
16
|
import { useRecord } from "../useRecord";
|
|
17
17
|
import { useToast } from "../toast";
|
|
18
18
|
import { Button } from "../ui/Button";
|
|
@@ -40,7 +40,7 @@ const SEARCH_DEBOUNCE_MS = 250;
|
|
|
40
40
|
* backend resolved for them.
|
|
41
41
|
*/
|
|
42
42
|
export function GroupDetail() {
|
|
43
|
-
const groupId =
|
|
43
|
+
const groupId = useDeclaredParam("groupId");
|
|
44
44
|
const client = useTerpClient();
|
|
45
45
|
const navigate = useNavigate();
|
|
46
46
|
const toast = useToast();
|
package/src/admin/UserDetail.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import { Field } from "../Field";
|
|
|
7
7
|
import { Icon } from "../icons";
|
|
8
8
|
import { DetailList } from "../layout";
|
|
9
9
|
import { PageActions } from "../PageActions";
|
|
10
|
-
import {
|
|
10
|
+
import { useDeclaredParam } from "../router";
|
|
11
11
|
import { useTerpClient } from "../TerpProvider";
|
|
12
12
|
import { useRecord } from "../useRecord";
|
|
13
13
|
import { useToast } from "../toast";
|
|
@@ -27,7 +27,7 @@ type PendingLifecycle =
|
|
|
27
27
|
|
|
28
28
|
/** Dedicated account detail and lifecycle page (`/admin/users/$userId`). */
|
|
29
29
|
export function UserDetail() {
|
|
30
|
-
const userId =
|
|
30
|
+
const userId = useDeclaredParam("userId");
|
|
31
31
|
const client = useTerpClient();
|
|
32
32
|
const strings = useStrings();
|
|
33
33
|
const toast = useToast();
|
package/src/index.ts
CHANGED
|
@@ -116,8 +116,24 @@ export { Field } from "./Field";
|
|
|
116
116
|
export type { FieldProps } from "./Field";
|
|
117
117
|
export { Stack, DetailList } from "./layout";
|
|
118
118
|
export type { StackProps, DetailListProps, DetailItem, SpaceToken } from "./layout";
|
|
119
|
-
export {
|
|
119
|
+
export {
|
|
120
|
+
buildAppRouter,
|
|
121
|
+
DEFAULT_ROLE_RANKS,
|
|
122
|
+
PROFILE_PATH,
|
|
123
|
+
useRouteParam,
|
|
124
|
+
useRouteParams,
|
|
125
|
+
useTerpNavigate,
|
|
126
|
+
} from "./router";
|
|
120
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";
|
|
121
137
|
export { LoginView } from "./LoginView";
|
|
122
138
|
export type { DevCredentials, LoginViewProps } from "./LoginView";
|
|
123
139
|
export {
|
|
@@ -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, useRouteParam } 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();
|
|
@@ -187,6 +203,106 @@ describe("buildAppRouter", () => {
|
|
|
187
203
|
expect(screen.queryByRole("heading", { name: /Wrong/ })).not.toBeInTheDocument();
|
|
188
204
|
});
|
|
189
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
|
+
|
|
190
306
|
it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
|
|
191
307
|
// A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
|
|
192
308
|
// 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
|
+
useNavigate,
|
|
7
8
|
useParams,
|
|
8
9
|
useRouter,
|
|
9
10
|
type AnyRoute,
|
|
@@ -15,6 +16,12 @@ import type { ModuleManifest } from "@terpjs/contract";
|
|
|
15
16
|
|
|
16
17
|
import { AppShell } from "./AppShell";
|
|
17
18
|
import { ProfileView } from "./ProfileView";
|
|
19
|
+
import type {
|
|
20
|
+
TerpNavigateTarget,
|
|
21
|
+
TerpRouteParamName,
|
|
22
|
+
TerpRouteParams,
|
|
23
|
+
TerpRoutePath,
|
|
24
|
+
} from "./routeTypes";
|
|
18
25
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
19
26
|
import { visibleNav } from "./nav";
|
|
20
27
|
import { NavLinkContext } from "./navLink";
|
|
@@ -48,33 +55,123 @@ export function routerPath(path: string): string {
|
|
|
48
55
|
return path.replace(/(^|\/):([A-Za-z_][A-Za-z0-9_]*)/g, "$1$$$2");
|
|
49
56
|
}
|
|
50
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
|
+
|
|
51
75
|
/**
|
|
52
|
-
* Read one route param, fail closed
|
|
76
|
+
* Read one route param by name, fail closed — the untyped core.
|
|
53
77
|
*
|
|
54
|
-
* {@link
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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.
|
|
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.
|
|
62
84
|
*/
|
|
63
|
-
export function
|
|
64
|
-
const params =
|
|
85
|
+
export function useDeclaredParam(name: string): string {
|
|
86
|
+
const params = useCurrentParams();
|
|
65
87
|
const value = params[name];
|
|
66
88
|
if (value === undefined) {
|
|
67
|
-
|
|
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
|
-
);
|
|
89
|
+
throw missingParam(name, params);
|
|
74
90
|
}
|
|
75
91
|
return value;
|
|
76
92
|
}
|
|
77
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
|
+
|
|
78
175
|
export interface BuildAppRouterOptions {
|
|
79
176
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
80
177
|
views: Record<string, ComponentType>;
|