@terpjs/react-core 0.8.0 → 0.10.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 +62 -22
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Authorized.test.tsx +63 -1
- package/src/Authorized.tsx +35 -2
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +35 -75
- package/src/ModuleNav.test.tsx +26 -0
- package/src/ModuleNav.tsx +45 -38
- package/src/Page.test.tsx +9 -6
- package/src/Page.tsx +37 -39
- package/src/ProfileView.test.tsx +15 -0
- package/src/ProfileView.tsx +9 -36
- package/src/ResourceList.tsx +13 -24
- package/src/SettingsPage.tsx +50 -0
- package/src/SplitPage.tsx +150 -0
- package/src/UserMenu.test.tsx +28 -5
- package/src/UserMenu.tsx +15 -9
- package/src/admin/AuditLogAdmin.tsx +21 -16
- package/src/admin/GroupCreate.tsx +18 -4
- package/src/admin/GroupDetail.tsx +50 -15
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +41 -12
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +238 -3
- package/src/admin/fieldErrors.ts +45 -0
- package/src/bootstrap.test.tsx +208 -0
- package/src/bootstrap.tsx +121 -5
- package/src/breakpoints.ts +41 -0
- package/src/dataview/DataView.tsx +12 -5
- package/src/dataview/DataViewCardList.tsx +8 -7
- package/src/dataview/DataViewPagination.tsx +15 -8
- package/src/dataview/DataViewTable.tsx +32 -21
- package/src/dataview/README.md +13 -2
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +31 -1
- package/src/dataview/types.ts +26 -3
- package/src/download.test.tsx +153 -0
- package/src/download.tsx +132 -0
- package/src/files.tsx +2 -11
- package/src/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +63 -7
- package/src/layout.manifest.json +118 -0
- package/src/layout.manifest.test.ts +205 -0
- package/src/layout.test.tsx +198 -1
- package/src/layout.tsx +208 -11
- package/src/layoutContract.test.tsx +311 -2
- package/src/layoutContract.ts +44 -3
- package/src/layoutDeclaration.test.ts +435 -0
- package/src/layoutDeclaration.ts +531 -0
- package/src/locale.tsx +3 -0
- package/src/markers.test.ts +141 -15
- package/src/nav.test.ts +234 -4
- package/src/nav.ts +180 -6
- package/src/navActive.test.ts +115 -0
- package/src/navActive.ts +119 -0
- package/src/navLink.tsx +20 -2
- package/src/previewBridge.test.ts +327 -0
- package/src/previewBridge.ts +278 -0
- package/src/raw.d.ts +14 -2
- package/src/review.test.tsx +272 -0
- package/src/routeSearch.ts +73 -0
- package/src/routeTypes.ts +50 -6
- package/src/router.test.tsx +766 -3
- package/src/router.tsx +277 -28
- package/src/sso.test.tsx +6 -3
- package/src/styles.test.ts +518 -27
- package/src/styles.ts +1287 -66
- package/src/theme.test.tsx +29 -0
- package/src/theme.themes.test.ts +13 -7
- package/src/theme.tsx +30 -33
- package/src/themes.ts +54 -0
- package/src/toast.tsx +2 -1
- package/src/tokens.guard.test.ts +192 -0
- package/src/typography.test.tsx +213 -0
- package/src/typography.tsx +255 -0
- package/src/ui/Avatar.test.tsx +63 -0
- package/src/ui/Avatar.tsx +65 -0
- package/src/ui/Button.test.tsx +71 -3
- package/src/ui/Button.tsx +57 -4
- package/src/ui/Card.test.tsx +13 -0
- package/src/ui/Card.tsx +28 -1
- package/src/ui/Checkbox.tsx +10 -2
- package/src/ui/Combobox.test.tsx +49 -0
- package/src/ui/Combobox.tsx +8 -2
- package/src/ui/DatePicker.tsx +28 -5
- package/src/ui/Input.test.tsx +123 -0
- package/src/ui/Input.tsx +65 -2
- package/src/ui/Menu.tsx +16 -5
- package/src/ui/Popover.tsx +13 -0
- package/src/ui/Radio.tsx +10 -5
- package/src/ui/Select.test.tsx +232 -0
- package/src/ui/Select.tsx +177 -8
- package/src/ui/Switch.tsx +10 -2
- package/src/ui/Tabs.tsx +16 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.tsx +9 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/router.tsx
CHANGED
|
@@ -7,12 +7,14 @@ import {
|
|
|
7
7
|
useNavigate,
|
|
8
8
|
useParams,
|
|
9
9
|
useRouter,
|
|
10
|
+
useRouterState,
|
|
11
|
+
useSearch,
|
|
10
12
|
type AnyRoute,
|
|
11
13
|
type RouterHistory,
|
|
12
14
|
} from "@tanstack/react-router";
|
|
13
15
|
import type { ComponentType, ReactNode } from "react";
|
|
14
|
-
import { useEffect, useRef, useState } from "react";
|
|
15
|
-
import type { ModuleManifest } from "@terpjs/contract";
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
17
|
+
import type { ModuleManifest, NavGroup } from "@terpjs/contract";
|
|
16
18
|
|
|
17
19
|
import { AppShell } from "./AppShell";
|
|
18
20
|
import { ProfileView } from "./ProfileView";
|
|
@@ -21,11 +23,24 @@ import type {
|
|
|
21
23
|
TerpRouteParamName,
|
|
22
24
|
TerpRouteParams,
|
|
23
25
|
TerpRoutePath,
|
|
26
|
+
TerpRouteSearch,
|
|
24
27
|
} from "./routeTypes";
|
|
25
28
|
import { LAYOUT_CONTRACTS, LayoutContractContext } from "./layoutContract";
|
|
26
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
BRAND_FIELDS,
|
|
31
|
+
resolveLayoutDeclaration,
|
|
32
|
+
type LayoutDeclaration,
|
|
33
|
+
} from "./layoutDeclaration";
|
|
34
|
+
import { isDeclarationVisible, visibleNav } from "./nav";
|
|
27
35
|
import { NavLinkContext } from "./navLink";
|
|
36
|
+
import type { NavLinkRenderer } from "./navLink";
|
|
28
37
|
import { PageMarkerContext } from "./pageMarker";
|
|
38
|
+
import {
|
|
39
|
+
RouteSearchContext,
|
|
40
|
+
declaredSearchKeys,
|
|
41
|
+
indexSearchKeys,
|
|
42
|
+
useRouteSearchIndex,
|
|
43
|
+
} from "./routeSearch";
|
|
29
44
|
import { useAuth } from "./TerpProvider";
|
|
30
45
|
import { UserMenu } from "./UserMenu";
|
|
31
46
|
import { useStrings } from "./uiText";
|
|
@@ -169,9 +184,56 @@ export function useTerpNavigate(): (target: TerpNavigateTarget) => Promise<void>
|
|
|
169
184
|
// runtime TanStack types `params` as a reducer (or `true`), and merging over the
|
|
170
185
|
// previous params is also the honest semantic for an in-place param change.
|
|
171
186
|
params: (previous: Record<string, unknown>) => ({ ...previous, ...(target.params ?? {}) }),
|
|
187
|
+
// Search is REPLACED, not merged (ADR 0096). Merging reads as convenient and is the
|
|
188
|
+
// wrong default for the case this exists to serve: clearing a filter means sending
|
|
189
|
+
// the key as undefined, and a merge would keep the old value instead — so "clear"
|
|
190
|
+
// would silently not clear. A screen that wants to keep other keys passes them,
|
|
191
|
+
// which is also the only form that stays checkable against the declared key set.
|
|
192
|
+
search: dropUndefined(target.search),
|
|
172
193
|
});
|
|
173
194
|
}
|
|
174
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Drop `undefined` values so a cleared filter leaves the URL instead of appearing as
|
|
198
|
+
* `?status=undefined`, and an all-cleared search yields a bare path.
|
|
199
|
+
*/
|
|
200
|
+
function dropUndefined(
|
|
201
|
+
search: Record<string, string | undefined> | undefined,
|
|
202
|
+
): Record<string, string> {
|
|
203
|
+
const kept: Record<string, string> = {};
|
|
204
|
+
for (const [key, value] of Object.entries(search ?? {})) {
|
|
205
|
+
if (value !== undefined) {
|
|
206
|
+
kept[key] = value;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return kept;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read the current route's declared query-string keys (ADR 0096).
|
|
214
|
+
*
|
|
215
|
+
* ```tsx
|
|
216
|
+
* const { status, page } = useRouteSearch("/records");
|
|
217
|
+
* ```
|
|
218
|
+
*
|
|
219
|
+
* Every key is `string | undefined`, because a query parameter is text and is absent
|
|
220
|
+
* until someone sets it — so a screen destructures with defaults rather than branching on
|
|
221
|
+
* a bag of `unknown`. Reading a key the route did not declare is a typecheck error once
|
|
222
|
+
* `terp routes` has generated; before that the shape is loose, exactly like the params
|
|
223
|
+
* helpers. Undeclared keys present in the URL are **not** returned: the declaration is the
|
|
224
|
+
* surface, so a stray key someone hand-typed cannot leak into a screen's logic.
|
|
225
|
+
*/
|
|
226
|
+
export function useRouteSearch<P extends TerpRoutePath>(path: P): TerpRouteSearch<P> {
|
|
227
|
+
const search = useSearch({ strict: false }) as Record<string, unknown>;
|
|
228
|
+
const declared = declaredSearchKeys(useRouteSearchIndex(), path);
|
|
229
|
+
const resolved: Record<string, string | undefined> = {};
|
|
230
|
+
for (const name of declared) {
|
|
231
|
+
const value = search[name];
|
|
232
|
+
resolved[name] = typeof value === "string" ? value : undefined;
|
|
233
|
+
}
|
|
234
|
+
return resolved as TerpRouteSearch<P>;
|
|
235
|
+
}
|
|
236
|
+
|
|
175
237
|
export interface BuildAppRouterOptions {
|
|
176
238
|
/** Maps a manifest route's `view` id to the component that renders it. */
|
|
177
239
|
views: Record<string, ComponentType>;
|
|
@@ -179,8 +241,51 @@ export interface BuildAppRouterOptions {
|
|
|
179
241
|
title: string;
|
|
180
242
|
/** Brand mark in the sidebar (any rendered node); default: the placeholder TerpMark. */
|
|
181
243
|
logo?: ReactNode;
|
|
244
|
+
/**
|
|
245
|
+
* The dark-theme brand mark ({@link AppShell.logoDark}); the stylesheet picks per appearance.
|
|
246
|
+
*
|
|
247
|
+
* Forwarded because it was not, which made it the third slot to exist on the shell and be
|
|
248
|
+
* unreachable from the entry points every app uses — after `headerActions`, which this ADR's
|
|
249
|
+
* own Context complains about. This one was worse than unreachable: the project template
|
|
250
|
+
* instructs every new app to pass `logoDark` to `renderTerpApp`, so the documented example
|
|
251
|
+
* did not typecheck.
|
|
252
|
+
*/
|
|
253
|
+
logoDark?: ReactNode;
|
|
254
|
+
/** Extra header content, rendered before the theme / language controls. */
|
|
255
|
+
headerActions?: ReactNode;
|
|
182
256
|
/** Footer line under the content; default: a muted line with the app title. */
|
|
183
257
|
footer?: ReactNode;
|
|
258
|
+
/**
|
|
259
|
+
* Cap routed content at the published measure, with each page's header on the full track
|
|
260
|
+
* ({@link AppShell.contentWidth}); default `"full"`, which changes nothing.
|
|
261
|
+
*/
|
|
262
|
+
contentWidth?: "full" | "measured";
|
|
263
|
+
/**
|
|
264
|
+
* App-wide density ({@link AppShell.density}). **No default** — omitting it stamps nothing, so
|
|
265
|
+
* an app's own `data-density` on `<html>` still reaches the tree.
|
|
266
|
+
*/
|
|
267
|
+
density?: "comfortable" | "compact";
|
|
268
|
+
/**
|
|
269
|
+
* Where the primary navigation lives on desktop ({@link AppShell.navPlacement}): the
|
|
270
|
+
* full-height `"sidebar"` (default, and what every shell renders today) or `"header"`, a
|
|
271
|
+
* horizontal row in the header with no sidebar at all. Below the mobile breakpoint both are
|
|
272
|
+
* the drawer.
|
|
273
|
+
*/
|
|
274
|
+
navPlacement?: "sidebar" | "header";
|
|
275
|
+
/**
|
|
276
|
+
* The app's navigation groups ({@link AppShell.navGroups}), which manifest items reference by
|
|
277
|
+
* `NavItem.group`. Omit for the flat, unlabelled sidebar every app renders today.
|
|
278
|
+
*
|
|
279
|
+
* Prefer {@link BuildAppRouterOptions.layout}: `shell.navGroups` in the app's own
|
|
280
|
+
* `frontend/layout-contract.json` says the same thing in the one document a tool can read and
|
|
281
|
+
* rewrite. Declaring the groups in both places is refused rather than silently resolved.
|
|
282
|
+
*
|
|
283
|
+
* A duplicate id is refused here rather than tolerated: it is an authoring error with no
|
|
284
|
+
* legitimate transient form, and composition time is where it can be reported once instead of
|
|
285
|
+
* on every render. An item naming an *undeclared* group is the opposite case and is not an
|
|
286
|
+
* error at all — modules ship independently of the app, so `groupNav` lets it fall open.
|
|
287
|
+
*/
|
|
288
|
+
navGroups?: readonly NavGroup[];
|
|
184
289
|
/** Role-name -> minimum rank; an unknown role is denied (fail closed). */
|
|
185
290
|
roleRanks?: Record<string, number>;
|
|
186
291
|
/** Rendered when the current user may not access a route (default: a simple message). */
|
|
@@ -189,10 +294,32 @@ export interface BuildAppRouterOptions {
|
|
|
189
294
|
* Opt into a slot-typed layout contract (ADR 0079), e.g. `"standard"`: every routed
|
|
190
295
|
* archetype's body slot then accepts only the components the contract allows there,
|
|
191
296
|
* verified at runtime (fail closed) with the same directive message the
|
|
192
|
-
* `terp/layout-contract` lint rule phrases.
|
|
193
|
-
*
|
|
297
|
+
* `terp/layout-contract` lint rule phrases.
|
|
298
|
+
*
|
|
299
|
+
* Prefer {@link BuildAppRouterOptions.layout}. This option used to carry the instruction
|
|
300
|
+
* "keep it in sync with the app's checked-in `layout-contract.json`", which is a defect
|
|
301
|
+
* written as advice and, since this function began reading that file, worse than stale:
|
|
302
|
+
* doing what the sentence said is now refused as one fact declared twice, even when the two
|
|
303
|
+
* agree. Omit both for today's archetype-only behavior.
|
|
194
304
|
*/
|
|
195
305
|
layoutContract?: string;
|
|
306
|
+
/**
|
|
307
|
+
* The app's checked-in layout declaration (`frontend/layout-contract.json`, imported).
|
|
308
|
+
*
|
|
309
|
+
* The file the `terp/layout-contract` lint rule already reads, now read by the runtime half
|
|
310
|
+
* too, so `contract` is declared once instead of once per half. It also carries the palette
|
|
311
|
+
* the app opens on and the shell's own shape — `density`, `navPlacement`, `contentWidth` and
|
|
312
|
+
* the `navGroups` a module's `NavItem.group` names by id — all of which shipped as options
|
|
313
|
+
* here and were therefore out of reach of anything that edits files rather than code.
|
|
314
|
+
*
|
|
315
|
+
* The authoritative list is `TOP_LEVEL_KEYS` and `SHELL_KEYS` in
|
|
316
|
+
* {@link ./layoutDeclaration}, and the published `layout.manifest.json` beside them. This
|
|
317
|
+
* sentence is a restatement and has already drifted once.
|
|
318
|
+
*
|
|
319
|
+
* Declaring a key here AND passing the matching option is refused — see
|
|
320
|
+
* {@link resolveLayoutDeclaration}.
|
|
321
|
+
*/
|
|
322
|
+
layout?: LayoutDeclaration;
|
|
196
323
|
/** Router history (e.g. `createMemoryHistory`); omit for the browser history. */
|
|
197
324
|
history?: RouterHistory;
|
|
198
325
|
}
|
|
@@ -226,13 +353,33 @@ function DefaultUnauthorized() {
|
|
|
226
353
|
* built-in {@link ProfileView} mounts at {@link PROFILE_PATH} unless a manifest claims that
|
|
227
354
|
* path. Wrap the returned router in `<TerpProvider><RouterProvider router={router}/></TerpProvider>`.
|
|
228
355
|
*/
|
|
356
|
+
/**
|
|
357
|
+
* A declared path as the element the shell's slot takes.
|
|
358
|
+
*
|
|
359
|
+
* `alt=""` because the mark is decorative here: the shell renders the app's title beside it,
|
|
360
|
+
* so a name on the image would have a screen reader announce the app twice. That is also what
|
|
361
|
+
* the template's own commented example does, and what an app hand-writing the option should do.
|
|
362
|
+
*/
|
|
363
|
+
function brandMark(path: string | undefined) {
|
|
364
|
+
return path === undefined ? undefined : <img src={path} alt="" />;
|
|
365
|
+
}
|
|
366
|
+
|
|
229
367
|
export function buildAppRouter(
|
|
230
368
|
manifests: readonly ModuleManifest[],
|
|
231
369
|
options: BuildAppRouterOptions,
|
|
232
370
|
) {
|
|
233
371
|
const roleRanks = options.roleRanks ?? DEFAULT_ROLE_RANKS;
|
|
234
372
|
const Unauthorized = options.unauthorized ?? DefaultUnauthorized;
|
|
235
|
-
|
|
373
|
+
// The declaration and the options collapse into one set before anything reads them, so
|
|
374
|
+
// every consumer below sees a single answer per key and cannot pick a different precedence.
|
|
375
|
+
const layout = resolveLayoutDeclaration(options.layout, {
|
|
376
|
+
contract: options.layoutContract,
|
|
377
|
+
density: options.density,
|
|
378
|
+
navPlacement: options.navPlacement,
|
|
379
|
+
contentWidth: options.contentWidth,
|
|
380
|
+
navGroups: options.navGroups,
|
|
381
|
+
});
|
|
382
|
+
const layoutContract = layout.contract ?? null;
|
|
236
383
|
if (layoutContract !== null && LAYOUT_CONTRACTS[layoutContract] === undefined) {
|
|
237
384
|
throw new Error(
|
|
238
385
|
`Unknown layout contract "${layoutContract}"; known contracts: ` +
|
|
@@ -240,6 +387,40 @@ export function buildAppRouter(
|
|
|
240
387
|
".",
|
|
241
388
|
);
|
|
242
389
|
}
|
|
390
|
+
// An authoring error with no legitimate transient form, so it is refused here rather than
|
|
391
|
+
// absorbed. `groupNav` itself stays total — first declaration wins — because it runs on every
|
|
392
|
+
// render and a render must not be able to throw; this runs once, when the app is composed.
|
|
393
|
+
// Deliberately NOT symmetrical with an item naming an undeclared group, which is not an error
|
|
394
|
+
// at all: a module ships on its own schedule, so that is the normal state of an app mid-adoption
|
|
395
|
+
// and it falls open.
|
|
396
|
+
// Over the RESOLVED list, so one refusal covers a group declared in the file and a group
|
|
397
|
+
// passed as an option alike — which is also why `resolveLayoutDeclaration` does not restate it.
|
|
398
|
+
// The brand's two slots, each declarable as a checked-in PATH or passed as a rendered
|
|
399
|
+
// element. Refused together for the reason every other doubly-declared key is, and refused
|
|
400
|
+
// HERE rather than in the resolver because a path and an element are not comparable values:
|
|
401
|
+
// the resolver's message names both, and there is nothing to name on the code side but the
|
|
402
|
+
// fact that something was passed.
|
|
403
|
+
const brandConflicts = BRAND_FIELDS.filter(
|
|
404
|
+
(slot) => layout.brand?.[slot] !== undefined && options[slot] !== undefined,
|
|
405
|
+
);
|
|
406
|
+
if (brandConflicts.length > 0) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`frontend/layout-contract.json declares shell.brand.${brandConflicts.join(" and shell.brand.")}` +
|
|
409
|
+
` and the bootstrap options pass ${brandConflicts.join(" and ")}. Declare each mark in ` +
|
|
410
|
+
"one place: the file is what a tool can read and rewrite, so prefer it and drop the option.",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const duplicateGroups = (layout.navGroups ?? [])
|
|
415
|
+
.map((group) => group.id)
|
|
416
|
+
.filter((id, index, ids) => ids.indexOf(id) !== index);
|
|
417
|
+
if (duplicateGroups.length > 0) {
|
|
418
|
+
throw new Error(
|
|
419
|
+
"Terp navGroups declare duplicate id(s): " +
|
|
420
|
+
[...new Set(duplicateGroups)].join(", ") +
|
|
421
|
+
". Each group id is referenced by NavItem.group and must be declared once.",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
243
424
|
const missingViews = manifests.flatMap((manifest) =>
|
|
244
425
|
manifest.routes
|
|
245
426
|
.filter((route) => options.views[route.view] === undefined)
|
|
@@ -251,29 +432,86 @@ export function buildAppRouter(
|
|
|
251
432
|
);
|
|
252
433
|
}
|
|
253
434
|
|
|
435
|
+
// The runtime half of the search declaration (ADR 0096): the generated table is types
|
|
436
|
+
// only, so `useRouteSearch` reads the keys from the manifests this router was built from,
|
|
437
|
+
// published per router through a context (never a module-level table, which every router
|
|
438
|
+
// in the process would share).
|
|
439
|
+
const searchKeys = new Map(indexSearchKeys(manifests));
|
|
440
|
+
if (!searchKeys.has(PROFILE_PATH)) {
|
|
441
|
+
searchKeys.set(PROFILE_PATH, []);
|
|
442
|
+
}
|
|
443
|
+
|
|
254
444
|
function Shell() {
|
|
255
445
|
const router = useRouter();
|
|
256
|
-
const
|
|
257
|
-
const
|
|
446
|
+
const user = useAuth().currentUser();
|
|
447
|
+
const rank = user?.role_rank ?? null;
|
|
448
|
+
const nav = visibleNav(manifests, {
|
|
449
|
+
canSeeRole: (role) => allows(roleRanks, rank, role),
|
|
450
|
+
permissions: user?.permissions ?? [],
|
|
451
|
+
});
|
|
452
|
+
// The shell decides which nav item is current and needs the path to do it. Selected, so the
|
|
453
|
+
// subscription re-renders only when the pathname itself changes — the same mechanism
|
|
454
|
+
// ModuleNav already uses.
|
|
455
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
456
|
+
// Memoised, and this is a fix for a regression the line above introduces rather than
|
|
457
|
+
// tidying. `Shell` used to re-render only when `useAuth()` changed; it now re-renders on
|
|
458
|
+
// every navigation. `Outlet` is memoised with no props, so a re-render alone bails out at
|
|
459
|
+
// that boundary and the routed subtree is untouched — but a CONTEXT VALUE punches straight
|
|
460
|
+
// through a memo bailout, and this value is used as a component (Breadcrumbs and HubCard
|
|
461
|
+
// render it through useNavLink), so an unstable identity is worse than a re-render: it
|
|
462
|
+
// remounts every in-app link in the tree on each navigation.
|
|
463
|
+
const renderNavLink = useCallback<NavLinkRenderer>(
|
|
464
|
+
({ to, children, attributes }) => (
|
|
465
|
+
<Link to={to} {...attributes}>
|
|
466
|
+
{children}
|
|
467
|
+
</Link>
|
|
468
|
+
),
|
|
469
|
+
[],
|
|
470
|
+
);
|
|
258
471
|
return (
|
|
259
472
|
// Publish the router's Link so every layout component that renders an in-app link
|
|
260
473
|
// (Breadcrumbs, HubCard) navigates client-side by default. Forgetting `renderLink`
|
|
261
474
|
// used to degrade the app silently: a raw anchor, a full page reload, no error.
|
|
262
|
-
<NavLinkContext.Provider value={
|
|
475
|
+
<NavLinkContext.Provider value={renderNavLink}>
|
|
263
476
|
<AppShell
|
|
264
477
|
title={options.title}
|
|
265
|
-
logo={options.logo}
|
|
478
|
+
logo={brandMark(layout.brand?.logo) ?? options.logo}
|
|
479
|
+
logoDark={brandMark(layout.brand?.logoDark) ?? options.logoDark}
|
|
480
|
+
headerActions={options.headerActions}
|
|
266
481
|
footer={options.footer}
|
|
482
|
+
contentWidth={layout.contentWidth}
|
|
483
|
+
density={layout.density}
|
|
484
|
+
navPlacement={layout.navPlacement}
|
|
485
|
+
activePath={pathname}
|
|
267
486
|
nav={nav}
|
|
487
|
+
navGroups={layout.navGroups}
|
|
268
488
|
renderBrandLink={({ to, children }) => (
|
|
269
489
|
<Link to={to} data-terp="appshell-brand">
|
|
270
490
|
{children}
|
|
271
491
|
</Link>
|
|
272
492
|
)}
|
|
273
|
-
// No style objects and no activeProps: the shell's stylesheet owns the link
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
493
|
+
// No style objects and no activeProps: the shell's stylesheet owns the link geometry
|
|
494
|
+
// and keys the active route on aria-current="page".
|
|
495
|
+
//
|
|
496
|
+
// The shell supplies that attribute now, and `exact: true` is what makes the router
|
|
497
|
+
// agree instead of arguing. Two facts combine. `aria-current` is not among the props
|
|
498
|
+
// useLinkProps destructures, so a value passed here survives into the rendered anchor;
|
|
499
|
+
// and the router's own active props are spread LAST, but only when it considers the link
|
|
500
|
+
// active. With exact matching, "the router considers it active" implies the link's path
|
|
501
|
+
// equals the URL — which is the longest possible match, so it is always the same item
|
|
502
|
+
// the shell picked. The router can therefore only ever agree, never add a second
|
|
503
|
+
// current item.
|
|
504
|
+
//
|
|
505
|
+
// Prefix matching is what broke that: it marked every ancestor active, so `/settings`
|
|
506
|
+
// and `/settings/users` were both current at `/settings/users`. The old
|
|
507
|
+
// `exact: item.to === "/"` was a workaround for the same thing at the root, and it is
|
|
508
|
+
// gone because the shell's predicate matches on segments and `/` claims nothing else.
|
|
509
|
+
renderLink={(item, children, { active }) => (
|
|
510
|
+
<Link
|
|
511
|
+
to={item.to}
|
|
512
|
+
activeOptions={{ exact: true }}
|
|
513
|
+
aria-current={active ? "page" : undefined}
|
|
514
|
+
>
|
|
277
515
|
{children}
|
|
278
516
|
</Link>
|
|
279
517
|
)}
|
|
@@ -295,12 +533,21 @@ export function buildAppRouter(
|
|
|
295
533
|
function guardedRoute(
|
|
296
534
|
path: string,
|
|
297
535
|
View: ComponentType,
|
|
298
|
-
|
|
536
|
+
declaration: { role?: string; permission?: string },
|
|
299
537
|
viewName: string,
|
|
300
538
|
): AnyRoute {
|
|
301
539
|
function RouteComponent() {
|
|
302
|
-
const
|
|
303
|
-
const
|
|
540
|
+
const user = useAuth().currentUser();
|
|
541
|
+
const rank = user?.role_rank ?? null;
|
|
542
|
+
// The same resolution the sidebar uses, and using it here is what stops `permission` from
|
|
543
|
+
// becoming a cosmetic gate: hiding a link while leaving its route reachable by URL is not
|
|
544
|
+
// a weaker version of authorization, it is the appearance of it. `role` has never had that
|
|
545
|
+
// asymmetry — it is declared on both NavItem and ModuleRoute — so `permission` does not
|
|
546
|
+
// get to introduce one.
|
|
547
|
+
const allowed = isDeclarationVisible(declaration, {
|
|
548
|
+
canSeeRole: (role) => allows(roleRanks, rank, role),
|
|
549
|
+
permissions: user?.permissions ?? [],
|
|
550
|
+
});
|
|
304
551
|
// The runtime half of the "every routed view is a page archetype" control: Page
|
|
305
552
|
// (composed by OverviewPage / DetailPage / HubPage) marks the render; a routed view
|
|
306
553
|
// that mounted without any archetype in its tree is refused, fail closed. The check
|
|
@@ -330,15 +577,17 @@ export function buildAppRouter(
|
|
|
330
577
|
return <Unauthorized />;
|
|
331
578
|
}
|
|
332
579
|
return (
|
|
333
|
-
<
|
|
334
|
-
<
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
580
|
+
<RouteSearchContext.Provider value={searchKeys}>
|
|
581
|
+
<LayoutContractContext.Provider value={layoutContract}>
|
|
582
|
+
<PageMarkerContext.Provider
|
|
583
|
+
value={() => {
|
|
584
|
+
marked.current = true;
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
<View />
|
|
588
|
+
</PageMarkerContext.Provider>
|
|
589
|
+
</LayoutContractContext.Provider>
|
|
590
|
+
</RouteSearchContext.Provider>
|
|
342
591
|
);
|
|
343
592
|
}
|
|
344
593
|
return createRoute({
|
|
@@ -350,14 +599,14 @@ export function buildAppRouter(
|
|
|
350
599
|
|
|
351
600
|
const childRoutes: AnyRoute[] = manifests.flatMap((manifest) =>
|
|
352
601
|
manifest.routes.map((route) =>
|
|
353
|
-
guardedRoute(route.path, options.views[route.view]!, route
|
|
602
|
+
guardedRoute(route.path, options.views[route.view]!, route, route.view),
|
|
354
603
|
),
|
|
355
604
|
);
|
|
356
605
|
const profileClaimed = manifests.some((manifest) =>
|
|
357
606
|
manifest.routes.some((route) => route.path === PROFILE_PATH),
|
|
358
607
|
);
|
|
359
608
|
if (!profileClaimed) {
|
|
360
|
-
childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView,
|
|
609
|
+
childRoutes.push(guardedRoute(PROFILE_PATH, ProfileView, {}, "profile"));
|
|
361
610
|
}
|
|
362
611
|
|
|
363
612
|
const routeTree = rootRoute.addChildren(childRoutes);
|
package/src/sso.test.tsx
CHANGED
|
@@ -120,9 +120,12 @@ describe("SSO login (ADR 0058)", () => {
|
|
|
120
120
|
</TerpProvider>,
|
|
121
121
|
);
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
123
|
+
// Announced, not merely displayed. This is the one screen whose user is not signed in
|
|
124
|
+
// yet, so a failure they cannot see leaves them with no signal at all that the sign-in
|
|
125
|
+
// did not happen — the form simply sits there. ResourceList's error has carried
|
|
126
|
+
// role="alert" since it existed; this one carried nothing.
|
|
127
|
+
const failure = await screen.findByRole("alert");
|
|
128
|
+
expect(failure).toHaveTextContent("Single sign-on failed. Try again.");
|
|
126
129
|
expect(window.location.pathname).toBe("/");
|
|
127
130
|
});
|
|
128
131
|
});
|