@terpjs/react-core 0.8.0 → 0.9.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 +6 -2
- package/package.json +2 -2
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/LoginView.tsx +22 -75
- package/src/ModuleNav.test.tsx +19 -0
- package/src/ModuleNav.tsx +10 -35
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +15 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +8 -33
- package/src/ResourceList.tsx +13 -24
- package/src/admin/AuditLogAdmin.tsx +1 -10
- package/src/admin/GroupCreate.tsx +1 -1
- package/src/admin/GroupDetail.tsx +2 -2
- package/src/admin/UserCreate.tsx +1 -1
- package/src/admin/admin.test.tsx +31 -0
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/index.ts +7 -1
- package/src/markers.test.ts +118 -12
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +191 -1
- package/src/router.tsx +75 -9
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +37 -5
- package/src/styles.ts +356 -6
- package/src/ui/Button.test.tsx +5 -3
package/src/markers.test.ts
CHANGED
|
@@ -39,6 +39,9 @@ const STYLESHEET = "./styles.ts";
|
|
|
39
39
|
* stylesheet and the layout contract, so it belongs in a release note.
|
|
40
40
|
*/
|
|
41
41
|
const MARKERS = [
|
|
42
|
+
"admin-form",
|
|
43
|
+
"admin-payload",
|
|
44
|
+
"admin-section-title",
|
|
42
45
|
"alert",
|
|
43
46
|
"alert-body",
|
|
44
47
|
"alert-icon",
|
|
@@ -159,6 +162,15 @@ const MARKERS = [
|
|
|
159
162
|
"language-switcher-label",
|
|
160
163
|
"loading-state",
|
|
161
164
|
"loading-state-spinner",
|
|
165
|
+
"login-brand",
|
|
166
|
+
"login-card",
|
|
167
|
+
"login-error",
|
|
168
|
+
"login-form",
|
|
169
|
+
"login-separator",
|
|
170
|
+
"login-separator-rule",
|
|
171
|
+
"login-sso",
|
|
172
|
+
"login-title",
|
|
173
|
+
"login-view",
|
|
162
174
|
"markdown",
|
|
163
175
|
"menu",
|
|
164
176
|
"menu-item",
|
|
@@ -166,16 +178,32 @@ const MARKERS = [
|
|
|
166
178
|
"menu-item-icon",
|
|
167
179
|
"menu-trigger",
|
|
168
180
|
"module-nav",
|
|
181
|
+
"module-nav-link",
|
|
182
|
+
"module-nav-list",
|
|
169
183
|
"nav-icon",
|
|
170
184
|
"nav-icon-fallback",
|
|
185
|
+
"page",
|
|
171
186
|
"page-actions",
|
|
187
|
+
"page-breadcrumbs",
|
|
188
|
+
"page-header",
|
|
189
|
+
"page-heading",
|
|
190
|
+
"page-title",
|
|
172
191
|
"popover",
|
|
173
192
|
"popover-panel",
|
|
193
|
+
"profile-avatar",
|
|
194
|
+
"profile-card",
|
|
195
|
+
"profile-email",
|
|
196
|
+
"profile-role",
|
|
174
197
|
"radio",
|
|
175
198
|
"radio-group",
|
|
176
199
|
"radio-group-legend",
|
|
177
200
|
"radio-group-options",
|
|
178
201
|
"resource-list",
|
|
202
|
+
"resource-list-create",
|
|
203
|
+
"resource-list-empty",
|
|
204
|
+
"resource-list-error",
|
|
205
|
+
"resource-list-items",
|
|
206
|
+
"resource-list-row",
|
|
179
207
|
"spinner-ring",
|
|
180
208
|
"stack",
|
|
181
209
|
"switch",
|
|
@@ -254,6 +282,17 @@ const MARKERS = [
|
|
|
254
282
|
* exactly this. A marker plus a `display: none` rule would have put a component with no visual
|
|
255
283
|
* design into the sheet and offered an app the chance to un-hide it.
|
|
256
284
|
*
|
|
285
|
+
* `DetailPage` and `OverviewPage` have left the same way, and they were on this list by
|
|
286
|
+
* mistake rather than by migration: each is a `LayoutSlotContext.Provider` wrapped around
|
|
287
|
+
* `Page` and renders no element of its own at all, so they are view compositions — which the
|
|
288
|
+
* paragraph above already excludes. There is nothing to mark without inventing a box, and the
|
|
289
|
+
* box is the one thing that must not exist here: `Page`'s slot check reads `article.children`,
|
|
290
|
+
* so a wrapper around the body — `display: contents` included, since that check is a DOM
|
|
291
|
+
* traversal and the node is still in the collection — becomes the sole body-slot child, is in
|
|
292
|
+
* no allow table, and fails every governed page closed. Their archetype identity is a context
|
|
293
|
+
* value, which is the right place for it: `Page` renders the only box either of them has, and
|
|
294
|
+
* it is marked.
|
|
295
|
+
*
|
|
257
296
|
* Worth knowing about the shape of this list, because it flatters two files: it names files
|
|
258
297
|
* with NO marker at all, so one marker on one element exempts the rest of the file. `toast.tsx`
|
|
259
298
|
* and `ConfirmDialog.tsx` were never on it despite styling five and four unreachable elements
|
|
@@ -262,13 +301,14 @@ const MARKERS = [
|
|
|
262
301
|
*
|
|
263
302
|
* `Field` has graduated: it renders a root plus label, label text, hint and error markers,
|
|
264
303
|
* so each part of a form field is addressable from the sheet.
|
|
304
|
+
*
|
|
305
|
+
* THE LIST IS EMPTY. `LoginView` was the last entry, and it is kept rather than deleted for
|
|
306
|
+
* the same reason as the ledger below: this is where a new unmarked styled surface has to
|
|
307
|
+
* argue for itself. Note what an empty list does NOT say, because the shape above already
|
|
308
|
+
* flatters two files — it names modules with NO marker at all, so one marker anywhere in a
|
|
309
|
+
* file exempts the rest of it.
|
|
265
310
|
*/
|
|
266
|
-
const UNMARKED_STYLED_SURFACES = [
|
|
267
|
-
"./DetailPage.tsx",
|
|
268
|
-
"./LoginView.tsx",
|
|
269
|
-
"./OverviewPage.tsx",
|
|
270
|
-
"./ProfileView.tsx",
|
|
271
|
-
];
|
|
311
|
+
const UNMARKED_STYLED_SURFACES: string[] = [];
|
|
272
312
|
|
|
273
313
|
/**
|
|
274
314
|
* How many module-scope base style objects each file still declares — the migration's own
|
|
@@ -291,13 +331,64 @@ const UNMARKED_STYLED_SURFACES = [
|
|
|
291
331
|
* `Popover` positions its panel from a rect it measured — ADR 0094 §3 puts all three on the
|
|
292
332
|
* inline side of the line permanently, so counting them would make this list unable to reach
|
|
293
333
|
* zero and therefore unable to mean anything.
|
|
334
|
+
*
|
|
335
|
+
* IT HAS REACHED ZERO, and the honest reading of that is narrow. The detector matches a
|
|
336
|
+
* module-scope declaration annotated `CSSProperties`, which means a call-site literal and an
|
|
337
|
+
* unannotated module-scope object are both invisible to it. Four of the built-in admin views
|
|
338
|
+
* carried five base styles through this entire migration for exactly that reason — four as
|
|
339
|
+
* call-site object literals (two of them the same `<h2>` declaration, two the same form
|
|
340
|
+
* measure) and one as an unannotated `payloadStyle` — while both ratchets read clean.
|
|
341
|
+
* The next commit widens the detector and migrates them, so that zero means what it looks
|
|
342
|
+
* like it means.
|
|
294
343
|
*/
|
|
295
|
-
const INLINE_BASE_STYLES: Record<string, number> = {
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
344
|
+
const INLINE_BASE_STYLES: Record<string, number> = {};
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Every module that renders an inline `style={}` at all, and how many times.
|
|
348
|
+
*
|
|
349
|
+
* This closes the gap the ledger above leaves, and the gap was not theoretical. That one
|
|
350
|
+
* matches a module-scope declaration annotated `CSSProperties`, so a call-site literal and
|
|
351
|
+
* an unannotated module-scope object are both invisible to it — and four of the built-in
|
|
352
|
+
* admin views carried five base styles through the entire styling migration for exactly
|
|
353
|
+
* that reason, with both ratchets reading clean the whole time. Widening the annotation
|
|
354
|
+
* pattern would not have found them either. What finds them is counting the SITES.
|
|
355
|
+
*
|
|
356
|
+
* So this list is the complete one, and every entry has to be a value ADR 0094 §3 puts on
|
|
357
|
+
* the inline side permanently. There are only two such kinds, and a new entry is a claim
|
|
358
|
+
* that there is a third:
|
|
359
|
+
*
|
|
360
|
+
* - A measured value the sheet has no business owning. `icons.tsx` and `LoadingState.tsx`
|
|
361
|
+
* size a box from a prop taking any CSS length; `layout.tsx` passes Stack's `align` /
|
|
362
|
+
* `justify`, an open vocabulary CSS already has; `ui/Popover.tsx` positions its panel
|
|
363
|
+
* from a rect measured at runtime; `dataview/DataViewTable.tsx` carries a column's width
|
|
364
|
+
* while it is being dragged.
|
|
365
|
+
* - The caller's own `style` prop, forwarded to a root. `ui/Checkbox.tsx`, `ui/Radio.tsx`,
|
|
366
|
+
* `ui/Switch.tsx` and `ui/Combobox.tsx` do this and nothing else — it is not the
|
|
367
|
+
* component styling itself, it is the escape a framework component owes its caller, and
|
|
368
|
+
* `ui/Button.test.tsx` asserts it keeps working.
|
|
369
|
+
*
|
|
370
|
+
* Exact equality in both directions, like the ledger above: a new site fails, and a removed
|
|
371
|
+
* one fails until this record is corrected. Comments are stripped first, because this file's
|
|
372
|
+
* own prose says `style={}` repeatedly and the measure is otherwise a grep of its own
|
|
373
|
+
* documentation.
|
|
374
|
+
*
|
|
375
|
+
* Counted with a regex rather than by splitting on the literal `style={`, and that is not
|
|
376
|
+
* tidying. JSX permits whitespace around the `=`, so `style = {{ … }}` and a value on the
|
|
377
|
+
* next line are both valid and both invisible to a literal split — either would have let a
|
|
378
|
+
* base style back in past a green gate, which is the exact failure this list exists to
|
|
379
|
+
* prevent. Verified: the two forms count 0 under the split and 1 under the regex, while
|
|
380
|
+
* every file above counts identically either way.
|
|
381
|
+
*/
|
|
382
|
+
const INLINE_STYLE_SITES: Record<string, number> = {
|
|
383
|
+
"./LoadingState.tsx": 1,
|
|
384
|
+
"./dataview/DataViewTable.tsx": 1,
|
|
385
|
+
"./icons.tsx": 1,
|
|
386
|
+
"./layout.tsx": 1,
|
|
387
|
+
"./ui/Checkbox.tsx": 1,
|
|
388
|
+
"./ui/Combobox.tsx": 1,
|
|
389
|
+
"./ui/Popover.tsx": 1,
|
|
390
|
+
"./ui/Radio.tsx": 1,
|
|
391
|
+
"./ui/Switch.tsx": 1,
|
|
301
392
|
};
|
|
302
393
|
|
|
303
394
|
/**
|
|
@@ -436,6 +527,21 @@ describe("data-terp markers", () => {
|
|
|
436
527
|
expect(declared).toEqual(INLINE_BASE_STYLES);
|
|
437
528
|
});
|
|
438
529
|
|
|
530
|
+
it("accounts for every inline style site, not only the annotated ones", () => {
|
|
531
|
+
// The measure the admin views escaped. A base style declared as a call-site literal, or
|
|
532
|
+
// as a module-scope object nobody annotated, is a base style either way — and the ledger
|
|
533
|
+
// above cannot see either. This one counts sites, so the only way to stay out of it is to
|
|
534
|
+
// render no inline style at all.
|
|
535
|
+
const sites: Record<string, number> = {};
|
|
536
|
+
for (const [file, text] of production) {
|
|
537
|
+
const count = stripComments(text).match(/style\s*=\s*\{/g)?.length ?? 0;
|
|
538
|
+
if (count > 0) {
|
|
539
|
+
sites[file] = count;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
expect(sites).toEqual(INLINE_STYLE_SITES);
|
|
543
|
+
});
|
|
544
|
+
|
|
439
545
|
it("injects the sheet from every module that owns a rule, or is reachable from one that does", () => {
|
|
440
546
|
// Twelve marker-rendering modules never call injectTerpStyles and do not need to: the
|
|
441
547
|
// package publishes ONE entry point and declares no `sideEffects`, so importing anything
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import type { ModuleManifest } from "@terpjs/contract";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The runtime half of a route's declared query-string keys (ADR 0096).
|
|
7
|
+
*
|
|
8
|
+
* The generated `routes.gen.d.ts` is types only — it vanishes at runtime — so
|
|
9
|
+
* `useRouteSearch` needs the same declarations as data. They come from the manifests the
|
|
10
|
+
* router was built from, published through a context rather than a module-level table:
|
|
11
|
+
* a table would be shared by every router composed in one process, so an app embedding
|
|
12
|
+
* another (or a test file composing two) would read declarations it never mounted.
|
|
13
|
+
*/
|
|
14
|
+
export const RouteSearchContext = createContext<ReadonlyMap<string, readonly string[]> | null>(
|
|
15
|
+
null,
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/** The manifest spelling (`:id`), so a `$id` manifest and a `:id` read agree on one key. */
|
|
19
|
+
export function canonicalRoutePath(path: string): string {
|
|
20
|
+
return path.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Index every manifest route's declared search keys by its canonical path.
|
|
25
|
+
*
|
|
26
|
+
* Two manifests mounting one path union their keys, matching what the generator emits into
|
|
27
|
+
* the type table — so the runtime read and the compile-time check agree.
|
|
28
|
+
*/
|
|
29
|
+
export function indexSearchKeys(
|
|
30
|
+
manifests: readonly ModuleManifest[],
|
|
31
|
+
): ReadonlyMap<string, readonly string[]> {
|
|
32
|
+
const index = new Map<string, readonly string[]>();
|
|
33
|
+
for (const manifest of manifests) {
|
|
34
|
+
for (const route of manifest.routes) {
|
|
35
|
+
const key = canonicalRoutePath(route.path);
|
|
36
|
+
index.set(key, [...new Set([...(index.get(key) ?? []), ...(route.search ?? [])])]);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return index;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The search keys declared for *path*, or a refusal naming what is mounted.
|
|
44
|
+
*
|
|
45
|
+
* A path the router never mounted is a programming error, not an empty result: returning
|
|
46
|
+
* `{}` would hand a screen `undefined` for every key it asked for, which reads as "no
|
|
47
|
+
* filters applied" — the silent-wrong-answer this refusal replaces.
|
|
48
|
+
*/
|
|
49
|
+
export function declaredSearchKeys(
|
|
50
|
+
index: ReadonlyMap<string, readonly string[]> | null,
|
|
51
|
+
path: string,
|
|
52
|
+
): readonly string[] {
|
|
53
|
+
if (index === null) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`useRouteSearch("${path}") was called outside a Terp router. It reads the declarations ` +
|
|
56
|
+
"the router was built from, so it only works under a view mounted by buildAppRouter.",
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const declared = index.get(canonicalRoutePath(path));
|
|
60
|
+
if (declared === undefined) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`Route "${path}" is not a mounted route, so its search keys are unknown (mounted: ` +
|
|
63
|
+
`${[...index.keys()].join(", ") || "none"}). useRouteSearch takes the path as written ` +
|
|
64
|
+
"in the module manifest — check it there, and run `terp routes` if the manifest changed.",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return declared;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The mounted routes' search-key index, or null outside a Terp router. */
|
|
71
|
+
export function useRouteSearchIndex(): ReadonlyMap<string, readonly string[]> | null {
|
|
72
|
+
return useContext(RouteSearchContext);
|
|
73
|
+
}
|
package/src/routeTypes.ts
CHANGED
|
@@ -26,6 +26,24 @@
|
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
|
|
27
27
|
export interface TerpRouteTable {}
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* The query-string half of the same table (ADR 0096): path -> its declared search keys.
|
|
31
|
+
*
|
|
32
|
+
* Params were checked and search keys were not, which sounds like a gap and behaves like
|
|
33
|
+
* a hole: a list screen's filters, its sort and its page cursor all live in the query
|
|
34
|
+
* string, so *every* screen with a filter had to leave the typed seam and reach for the
|
|
35
|
+
* router's own `useNavigate` / `useSearch` — losing path checking too, on the majority of
|
|
36
|
+
* screens. Declaring the keys in the manifest (`search: ["status", "page"]`) is what lets
|
|
37
|
+
* navigation and reads stay inside the checked seam.
|
|
38
|
+
*
|
|
39
|
+
* Keyed only for routes that declare keys, so a route with none refuses `search` outright
|
|
40
|
+
* rather than accepting anything. Values are `string | undefined`: a query parameter is
|
|
41
|
+
* text and is absent until set — parsing is the screen's business, and the declaration is
|
|
42
|
+
* what stops the *key* being a typo.
|
|
43
|
+
*/
|
|
44
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type -- augmentation target
|
|
45
|
+
export interface TerpRouteSearchTable {}
|
|
46
|
+
|
|
29
47
|
/** True when no `routes.gen.d.ts` has augmented {@link TerpRouteTable}. */
|
|
30
48
|
type Ungenerated = keyof TerpRouteTable extends never ? true : false;
|
|
31
49
|
|
|
@@ -50,14 +68,40 @@ export type TerpRouteParamName = Ungenerated extends true
|
|
|
50
68
|
: { [P in keyof TerpRouteTable]: keyof TerpRouteTable[P] }[keyof TerpRouteTable] & string;
|
|
51
69
|
|
|
52
70
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* the
|
|
71
|
+
* One route's declared query-string keys, all optional.
|
|
72
|
+
*
|
|
73
|
+
* Three cases, and the middle one is the point. Before `terp routes` has generated, the
|
|
74
|
+
* shape is loose (like {@link TerpRouteParams}) so an app that has not adopted keeps
|
|
75
|
+
* today's behavior. Once generated, a route that declared keys gets exactly those; a
|
|
76
|
+
* route that declared **none** gets `Record<never, never>`, so passing `search` to it is a
|
|
77
|
+
* typecheck error rather than a value silently dropped into the URL.
|
|
78
|
+
*/
|
|
79
|
+
export type TerpRouteSearch<P extends TerpRoutePath> = P extends keyof TerpRouteSearchTable
|
|
80
|
+
? TerpRouteSearchTable[P]
|
|
81
|
+
: Ungenerated extends true
|
|
82
|
+
? Record<string, string | undefined>
|
|
83
|
+
: Record<never, never>;
|
|
84
|
+
|
|
85
|
+
/** Every search key any route declares — or `string` before generating. */
|
|
86
|
+
export type TerpRouteSearchKey = Ungenerated extends true
|
|
87
|
+
? string
|
|
88
|
+
: { [P in keyof TerpRouteSearchTable]: keyof TerpRouteSearchTable[P] }[keyof TerpRouteSearchTable] &
|
|
89
|
+
string;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A navigation target: a declared path, that path's params when it takes any, and that
|
|
93
|
+
* path's declared search keys when it reads any.
|
|
94
|
+
*
|
|
95
|
+
* A paramless route refuses a `params` object; a parameterised one requires it, with the
|
|
96
|
+
* names the manifest declared. `search` is always optional (a route may be visited with
|
|
97
|
+
* no filters applied) but its *keys* are the declared ones, so a typo is a typecheck
|
|
98
|
+
* error and a route that declares no search keys refuses the property. Before generating,
|
|
99
|
+
* this is the loose shape.
|
|
56
100
|
*/
|
|
57
101
|
export type TerpNavigateTarget = Ungenerated extends true
|
|
58
|
-
? { to: string; params?: Record<string, string> }
|
|
102
|
+
? { to: string; params?: Record<string, string>; search?: Record<string, string | undefined> }
|
|
59
103
|
: {
|
|
60
|
-
[P in keyof TerpRouteTable & string]: keyof TerpRouteTable[P] extends never
|
|
104
|
+
[P in keyof TerpRouteTable & string]: (keyof TerpRouteTable[P] extends never
|
|
61
105
|
? { to: P; params?: undefined }
|
|
62
|
-
: { to: P; params: TerpRouteTable[P] };
|
|
106
|
+
: { to: P; params: TerpRouteTable[P] }) & { search?: TerpRouteSearch<P> };
|
|
63
107
|
}[keyof TerpRouteTable & string];
|
package/src/router.test.tsx
CHANGED
|
@@ -5,7 +5,13 @@ 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 {
|
|
8
|
+
import {
|
|
9
|
+
buildAppRouter,
|
|
10
|
+
useRouteParam,
|
|
11
|
+
useRouteParams,
|
|
12
|
+
useRouteSearch,
|
|
13
|
+
useTerpNavigate,
|
|
14
|
+
} from "./router";
|
|
9
15
|
import { Page } from "./Page";
|
|
10
16
|
import { TerpProvider, useAuth } from "./TerpProvider";
|
|
11
17
|
|
|
@@ -303,6 +309,190 @@ describe("buildAppRouter", () => {
|
|
|
303
309
|
);
|
|
304
310
|
});
|
|
305
311
|
|
|
312
|
+
it("useRouteSearch reads the route's declared query-string keys, absent ones as undefined", async () => {
|
|
313
|
+
// The hole this closes: a list screen's filters live in the query string, so before
|
|
314
|
+
// search was declarable EVERY filtered screen left the typed seam for the router's own
|
|
315
|
+
// useSearch — losing path and param checking too, on the majority of screens.
|
|
316
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
317
|
+
|
|
318
|
+
function ListView() {
|
|
319
|
+
const { status, page } = useRouteSearch("/records");
|
|
320
|
+
return (
|
|
321
|
+
<Page title="Records">
|
|
322
|
+
<p>{`status=${status ?? "-"} page=${page ?? "-"}`}</p>
|
|
323
|
+
</Page>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
render(
|
|
327
|
+
<TerpProvider baseUrl="https://api.test">
|
|
328
|
+
<LogInOnMount />
|
|
329
|
+
<RouterProvider
|
|
330
|
+
router={buildAppRouter(
|
|
331
|
+
[
|
|
332
|
+
{
|
|
333
|
+
name: "records",
|
|
334
|
+
routes: [{ path: "/records", view: "List", search: ["status", "page"] }],
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
{
|
|
338
|
+
views: { List: ListView },
|
|
339
|
+
title: "Terp",
|
|
340
|
+
history: createMemoryHistory({ initialEntries: ["/records?status=open"] }),
|
|
341
|
+
},
|
|
342
|
+
)}
|
|
343
|
+
/>
|
|
344
|
+
</TerpProvider>,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
// `status` came from the URL; `page` is declared but unset, which is `undefined` rather
|
|
348
|
+
// than a missing key a screen has to guard.
|
|
349
|
+
expect(await screen.findByText("status=open page=-")).toBeInTheDocument();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("useRouteSearch returns only declared keys, so a stray URL key cannot reach a screen", async () => {
|
|
353
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
354
|
+
|
|
355
|
+
function ListView() {
|
|
356
|
+
const search = useRouteSearch("/records") as Record<string, string | undefined>;
|
|
357
|
+
return <Page title="Records">{`keys=${Object.keys(search).join(",") || "none"}`}</Page>;
|
|
358
|
+
}
|
|
359
|
+
render(
|
|
360
|
+
<TerpProvider baseUrl="https://api.test">
|
|
361
|
+
<LogInOnMount />
|
|
362
|
+
<RouterProvider
|
|
363
|
+
router={buildAppRouter(
|
|
364
|
+
[{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
|
|
365
|
+
{
|
|
366
|
+
views: { List: ListView },
|
|
367
|
+
title: "Terp",
|
|
368
|
+
history: createMemoryHistory({
|
|
369
|
+
initialEntries: ["/records?status=open&smuggled=yes"],
|
|
370
|
+
}),
|
|
371
|
+
},
|
|
372
|
+
)}
|
|
373
|
+
/>
|
|
374
|
+
</TerpProvider>,
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
expect(await screen.findByText("keys=status")).toBeInTheDocument();
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("useRouteSearch refuses a path the router never mounted, naming what is mounted", async () => {
|
|
381
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
382
|
+
|
|
383
|
+
function ListView() {
|
|
384
|
+
// A silently empty bag would hand the screen `undefined` for every key it asked
|
|
385
|
+
// for, which reads as "no filters applied" — the failure this refusal replaces.
|
|
386
|
+
let message = "no refusal";
|
|
387
|
+
try {
|
|
388
|
+
(useRouteSearch as (path: string) => unknown)("/typo");
|
|
389
|
+
} catch (error) {
|
|
390
|
+
message = (error as Error).message;
|
|
391
|
+
}
|
|
392
|
+
return <Page title="Records">{message}</Page>;
|
|
393
|
+
}
|
|
394
|
+
render(
|
|
395
|
+
<TerpProvider baseUrl="https://api.test">
|
|
396
|
+
<LogInOnMount />
|
|
397
|
+
<RouterProvider
|
|
398
|
+
router={buildAppRouter([{ name: "records", routes: [{ path: "/records", view: "List" }] }], {
|
|
399
|
+
views: { List: ListView },
|
|
400
|
+
title: "Terp",
|
|
401
|
+
history: createMemoryHistory({ initialEntries: ["/records"] }),
|
|
402
|
+
})}
|
|
403
|
+
/>
|
|
404
|
+
</TerpProvider>,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
expect(await screen.findByText(/is not a mounted route/)).toBeInTheDocument();
|
|
408
|
+
expect(screen.getByText(/mounted: \/records/)).toBeInTheDocument();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("useTerpNavigate carries search onto the URL, and clearing a key removes it", async () => {
|
|
412
|
+
// Replace, not merge (ADR 0096): clearing a filter means sending the key as
|
|
413
|
+
// undefined, and a merge would keep the old value — so "clear" would not clear.
|
|
414
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
415
|
+
|
|
416
|
+
function ListView() {
|
|
417
|
+
const navigate = useTerpNavigate();
|
|
418
|
+
const { status } = useRouteSearch("/records");
|
|
419
|
+
return (
|
|
420
|
+
<Page title="Records">
|
|
421
|
+
<p>{`status=${status ?? "-"}`}</p>
|
|
422
|
+
<button
|
|
423
|
+
type="button"
|
|
424
|
+
onClick={() => void navigate({ to: "/records", search: { status: "open" } })}
|
|
425
|
+
>
|
|
426
|
+
filter open
|
|
427
|
+
</button>
|
|
428
|
+
<button
|
|
429
|
+
type="button"
|
|
430
|
+
onClick={() => void navigate({ to: "/records", search: { status: undefined } })}
|
|
431
|
+
>
|
|
432
|
+
clear
|
|
433
|
+
</button>
|
|
434
|
+
</Page>
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
render(
|
|
438
|
+
<TerpProvider baseUrl="https://api.test">
|
|
439
|
+
<LogInOnMount />
|
|
440
|
+
<RouterProvider
|
|
441
|
+
router={buildAppRouter(
|
|
442
|
+
[{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
|
|
443
|
+
{
|
|
444
|
+
views: { List: ListView },
|
|
445
|
+
title: "Terp",
|
|
446
|
+
history: createMemoryHistory({ initialEntries: ["/records"] }),
|
|
447
|
+
},
|
|
448
|
+
)}
|
|
449
|
+
/>
|
|
450
|
+
</TerpProvider>,
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
fireEvent.click(await screen.findByRole("button", { name: "filter open" }));
|
|
454
|
+
await waitFor(() => expect(screen.getByText("status=open")).toBeInTheDocument());
|
|
455
|
+
|
|
456
|
+
fireEvent.click(screen.getByRole("button", { name: "clear" }));
|
|
457
|
+
await waitFor(() => expect(screen.getByText("status=-")).toBeInTheDocument());
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it("a second composed router does not inherit the first one's declared searches", async () => {
|
|
461
|
+
// The declarations are published per router through a context, not a module-level
|
|
462
|
+
// table: a shared table would let one app embedding another (or one test process
|
|
463
|
+
// composing two) read routes it never mounted.
|
|
464
|
+
vi.stubGlobal("fetch", sessionFetch());
|
|
465
|
+
buildAppRouter([{ name: "a", routes: [{ path: "/a", view: "V", search: ["x"] }] }], {
|
|
466
|
+
views: { V: () => <Page title="A">a</Page> },
|
|
467
|
+
title: "Terp",
|
|
468
|
+
history: createMemoryHistory({ initialEntries: ["/a"] }),
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
function BView() {
|
|
472
|
+
let message = "no refusal";
|
|
473
|
+
try {
|
|
474
|
+
(useRouteSearch as (path: string) => unknown)("/a");
|
|
475
|
+
} catch (error) {
|
|
476
|
+
message = (error as Error).message;
|
|
477
|
+
}
|
|
478
|
+
return <Page title="B">{message}</Page>;
|
|
479
|
+
}
|
|
480
|
+
render(
|
|
481
|
+
<TerpProvider baseUrl="https://api.test">
|
|
482
|
+
<LogInOnMount />
|
|
483
|
+
<RouterProvider
|
|
484
|
+
router={buildAppRouter([{ name: "b", routes: [{ path: "/b", view: "V" }] }], {
|
|
485
|
+
views: { V: BView },
|
|
486
|
+
title: "Terp",
|
|
487
|
+
history: createMemoryHistory({ initialEntries: ["/b"] }),
|
|
488
|
+
})}
|
|
489
|
+
/>
|
|
490
|
+
</TerpProvider>,
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
expect(await screen.findByText(/"\/a" is not a mounted route/)).toBeInTheDocument();
|
|
494
|
+
});
|
|
495
|
+
|
|
306
496
|
it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
|
|
307
497
|
// A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
|
|
308
498
|
// page reload, silently, with nothing to catch it. Inside a Terp router the default
|
package/src/router.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useNavigate,
|
|
8
8
|
useParams,
|
|
9
9
|
useRouter,
|
|
10
|
+
useSearch,
|
|
10
11
|
type AnyRoute,
|
|
11
12
|
type RouterHistory,
|
|
12
13
|
} from "@tanstack/react-router";
|
|
@@ -21,11 +22,18 @@ import type {
|
|
|
21
22
|
TerpRouteParamName,
|
|
22
23
|
TerpRouteParams,
|
|
23
24
|
TerpRoutePath,
|
|
25
|
+
TerpRouteSearch,
|
|
24
26
|
} from "./routeTypes";
|
|
25
27
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
26
28
|
import { visibleNav } from "./nav";
|
|
27
29
|
import { NavLinkContext } from "./navLink";
|
|
28
30
|
import { PageMarkerContext } from "./pageMarker";
|
|
31
|
+
import {
|
|
32
|
+
RouteSearchContext,
|
|
33
|
+
declaredSearchKeys,
|
|
34
|
+
indexSearchKeys,
|
|
35
|
+
useRouteSearchIndex,
|
|
36
|
+
} from "./routeSearch";
|
|
29
37
|
import { useAuth } from "./TerpProvider";
|
|
30
38
|
import { UserMenu } from "./UserMenu";
|
|
31
39
|
import { useStrings } from "./uiText";
|
|
@@ -169,9 +177,56 @@ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void>
|
|
|
169
177
|
// runtime TanStack types `params` as a reducer (or `true`), and merging over the
|
|
170
178
|
// previous params is also the honest semantic for an in-place param change.
|
|
171
179
|
params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
|
|
180
|
+
// Search is REPLACED, not merged (ADR 0096). Merging reads as convenient and is the
|
|
181
|
+
// wrong default for the case this exists to serve: clearing a filter means sending
|
|
182
|
+
// the key as undefined, and a merge would keep the old value instead — so "clear"
|
|
183
|
+
// would silently not clear. A screen that wants to keep other keys passes them,
|
|
184
|
+
// which is also the only form that stays checkable against the declared key set.
|
|
185
|
+
search: dropUndefined(target.search),
|
|
172
186
|
});
|
|
173
187
|
}
|
|
174
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Drop `undefined` values so a cleared filter leaves the URL instead of appearing as
|
|
191
|
+
* `?status=undefined`, and an all-cleared search yields a bare path.
|
|
192
|
+
*/
|
|
193
|
+
function dropUndefined(
|
|
194
|
+
search: Record<string, string | undefined> | undefined,
|
|
195
|
+
): Record<string, string> {
|
|
196
|
+
const kept: Record<string, string> = {};
|
|
197
|
+
for (const [key, value] of Object.entries(search ?? {})) {
|
|
198
|
+
if (value !== undefined) {
|
|
199
|
+
kept[key] = value;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return kept;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Read the current route's declared query-string keys (ADR 0096).
|
|
207
|
+
*
|
|
208
|
+
* ```tsx
|
|
209
|
+
* const { status, page } = useRouteSearch("/records");
|
|
210
|
+
* ```
|
|
211
|
+
*
|
|
212
|
+
* Every key is `string | undefined`, because a query parameter is text and is absent
|
|
213
|
+
* until someone sets it — so a screen destructures with defaults rather than branching on
|
|
214
|
+
* a bag of `unknown`. Reading a key the route did not declare is a typecheck error once
|
|
215
|
+
* `terp routes` has generated; before that the shape is loose, exactly like the params
|
|
216
|
+
* helpers. Undeclared keys present in the URL are **not** returned: the declaration is the
|
|
217
|
+
* surface, so a stray key someone hand-typed cannot leak into a screen's logic.
|
|
218
|
+
*/
|
|
219
|
+
export function useRouteSearch<P extends TerpRoutePath>(path: P): TerpRouteSearch<P> {
|
|
220
|
+
const search = useSearch({ strict: false }) as Record<string, unknown>;
|
|
221
|
+
const declared = declaredSearchKeys(useRouteSearchIndex(), path);
|
|
222
|
+
const resolved: Record<string, string | undefined> = {};
|
|
223
|
+
for (const name of declared) {
|
|
224
|
+
const value = search[name];
|
|
225
|
+
resolved[name] = typeof value === "string" ? value : undefined;
|
|
226
|
+
}
|
|
227
|
+
return resolved as TerpRouteSearch<P>;
|
|
228
|
+
}
|
|
229
|
+
|
|
175
230
|
export interface BuildAppRouterOptions {
|
|
176
231
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
177
232
|
views: Record<string, ComponentType>;
|
|
@@ -251,6 +306,15 @@ export function buildAppRouter(
|
|
|
251
306
|
);
|
|
252
307
|
}
|
|
253
308
|
|
|
309
|
+
// The runtime half of the search declaration (ADR 0096): the generated table is types
|
|
310
|
+
// only, so `useRouteSearch` reads the keys from the manifests this router was built from,
|
|
311
|
+
// published per router through a context (never a module-level table, which every router
|
|
312
|
+
// in the process would share).
|
|
313
|
+
const searchKeys = new Map(indexSearchKeys(manifests));
|
|
314
|
+
if (!searchKeys.has(PROFILE_PATH)) {
|
|
315
|
+
searchKeys.set(PROFILE_PATH, []);
|
|
316
|
+
}
|
|
317
|
+
|
|
254
318
|
function Shell() {
|
|
255
319
|
const router = useRouter();
|
|
256
320
|
const rank = useAuth().currentUser()?.role_rank ?? null;
|
|
@@ -330,15 +394,17 @@ export function buildAppRouter(
|
|
|
330
394
|
return <Unauthorized />;
|
|
331
395
|
}
|
|
332
396
|
return (
|
|
333
|
-
<
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
397
|
+
<RouteSearchContext.Provider value={searchKeys}>
|
|
398
|
+
<LayoutContractContext.Provider value={layoutContract}>
|
|
399
|
+
<PageMarkerContext.Provider
|
|
400
|
+
value={() => {
|
|
401
|
+
marked.current = true;
|
|
402
|
+
}}
|
|
403
|
+
>
|
|
404
|
+
<View />
|
|
405
|
+
</PageMarkerContext.Provider>
|
|
406
|
+
</LayoutContractContext.Provider>
|
|
407
|
+
</RouteSearchContext.Provider>
|
|
342
408
|
);
|
|
343
409
|
}
|
|
344
410
|
return createRoute({
|