@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.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
@@ -5,9 +5,16 @@ 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, useRouteParams, useTerpNavigate } from "./router";
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";
17
+ import { useNavLink } from "./navLink";
11
18
 
12
19
  function jsonResponse(body: unknown): Response {
13
20
  return new Response(JSON.stringify(body), {
@@ -303,6 +310,190 @@ describe("buildAppRouter", () => {
303
310
  );
304
311
  });
305
312
 
313
+ it("useRouteSearch reads the route's declared query-string keys, absent ones as undefined", async () => {
314
+ // The hole this closes: a list screen's filters live in the query string, so before
315
+ // search was declarable EVERY filtered screen left the typed seam for the router's own
316
+ // useSearch — losing path and param checking too, on the majority of screens.
317
+ vi.stubGlobal("fetch", sessionFetch());
318
+
319
+ function ListView() {
320
+ const { status, page } = useRouteSearch("/records");
321
+ return (
322
+ <Page title="Records">
323
+ <p>{`status=${status ?? "-"} page=${page ?? "-"}`}</p>
324
+ </Page>
325
+ );
326
+ }
327
+ render(
328
+ <TerpProvider baseUrl="https://api.test">
329
+ <LogInOnMount />
330
+ <RouterProvider
331
+ router={buildAppRouter(
332
+ [
333
+ {
334
+ name: "records",
335
+ routes: [{ path: "/records", view: "List", search: ["status", "page"] }],
336
+ },
337
+ ],
338
+ {
339
+ views: { List: ListView },
340
+ title: "Terp",
341
+ history: createMemoryHistory({ initialEntries: ["/records?status=open"] }),
342
+ },
343
+ )}
344
+ />
345
+ </TerpProvider>,
346
+ );
347
+
348
+ // `status` came from the URL; `page` is declared but unset, which is `undefined` rather
349
+ // than a missing key a screen has to guard.
350
+ expect(await screen.findByText("status=open page=-")).toBeInTheDocument();
351
+ });
352
+
353
+ it("useRouteSearch returns only declared keys, so a stray URL key cannot reach a screen", async () => {
354
+ vi.stubGlobal("fetch", sessionFetch());
355
+
356
+ function ListView() {
357
+ const search = useRouteSearch("/records") as Record<string, string | undefined>;
358
+ return <Page title="Records">{`keys=${Object.keys(search).join(",") || "none"}`}</Page>;
359
+ }
360
+ render(
361
+ <TerpProvider baseUrl="https://api.test">
362
+ <LogInOnMount />
363
+ <RouterProvider
364
+ router={buildAppRouter(
365
+ [{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
366
+ {
367
+ views: { List: ListView },
368
+ title: "Terp",
369
+ history: createMemoryHistory({
370
+ initialEntries: ["/records?status=open&smuggled=yes"],
371
+ }),
372
+ },
373
+ )}
374
+ />
375
+ </TerpProvider>,
376
+ );
377
+
378
+ expect(await screen.findByText("keys=status")).toBeInTheDocument();
379
+ });
380
+
381
+ it("useRouteSearch refuses a path the router never mounted, naming what is mounted", async () => {
382
+ vi.stubGlobal("fetch", sessionFetch());
383
+
384
+ function ListView() {
385
+ // A silently empty bag would hand the screen `undefined` for every key it asked
386
+ // for, which reads as "no filters applied" — the failure this refusal replaces.
387
+ let message = "no refusal";
388
+ try {
389
+ (useRouteSearch as (path: string) => unknown)("/typo");
390
+ } catch (error) {
391
+ message = (error as Error).message;
392
+ }
393
+ return <Page title="Records">{message}</Page>;
394
+ }
395
+ render(
396
+ <TerpProvider baseUrl="https://api.test">
397
+ <LogInOnMount />
398
+ <RouterProvider
399
+ router={buildAppRouter([{ name: "records", routes: [{ path: "/records", view: "List" }] }], {
400
+ views: { List: ListView },
401
+ title: "Terp",
402
+ history: createMemoryHistory({ initialEntries: ["/records"] }),
403
+ })}
404
+ />
405
+ </TerpProvider>,
406
+ );
407
+
408
+ expect(await screen.findByText(/is not a mounted route/)).toBeInTheDocument();
409
+ expect(screen.getByText(/mounted: \/records/)).toBeInTheDocument();
410
+ });
411
+
412
+ it("useTerpNavigate carries search onto the URL, and clearing a key removes it", async () => {
413
+ // Replace, not merge (ADR 0096): clearing a filter means sending the key as
414
+ // undefined, and a merge would keep the old value — so "clear" would not clear.
415
+ vi.stubGlobal("fetch", sessionFetch());
416
+
417
+ function ListView() {
418
+ const navigate = useTerpNavigate();
419
+ const { status } = useRouteSearch("/records");
420
+ return (
421
+ <Page title="Records">
422
+ <p>{`status=${status ?? "-"}`}</p>
423
+ <button
424
+ type="button"
425
+ onClick={() => void navigate({ to: "/records", search: { status: "open" } })}
426
+ >
427
+ filter open
428
+ </button>
429
+ <button
430
+ type="button"
431
+ onClick={() => void navigate({ to: "/records", search: { status: undefined } })}
432
+ >
433
+ clear
434
+ </button>
435
+ </Page>
436
+ );
437
+ }
438
+ render(
439
+ <TerpProvider baseUrl="https://api.test">
440
+ <LogInOnMount />
441
+ <RouterProvider
442
+ router={buildAppRouter(
443
+ [{ name: "records", routes: [{ path: "/records", view: "List", search: ["status"] }] }],
444
+ {
445
+ views: { List: ListView },
446
+ title: "Terp",
447
+ history: createMemoryHistory({ initialEntries: ["/records"] }),
448
+ },
449
+ )}
450
+ />
451
+ </TerpProvider>,
452
+ );
453
+
454
+ fireEvent.click(await screen.findByRole("button", { name: "filter open" }));
455
+ await waitFor(() => expect(screen.getByText("status=open")).toBeInTheDocument());
456
+
457
+ fireEvent.click(screen.getByRole("button", { name: "clear" }));
458
+ await waitFor(() => expect(screen.getByText("status=-")).toBeInTheDocument());
459
+ });
460
+
461
+ it("a second composed router does not inherit the first one's declared searches", async () => {
462
+ // The declarations are published per router through a context, not a module-level
463
+ // table: a shared table would let one app embedding another (or one test process
464
+ // composing two) read routes it never mounted.
465
+ vi.stubGlobal("fetch", sessionFetch());
466
+ buildAppRouter([{ name: "a", routes: [{ path: "/a", view: "V", search: ["x"] }] }], {
467
+ views: { V: () => <Page title="A">a</Page> },
468
+ title: "Terp",
469
+ history: createMemoryHistory({ initialEntries: ["/a"] }),
470
+ });
471
+
472
+ function BView() {
473
+ let message = "no refusal";
474
+ try {
475
+ (useRouteSearch as (path: string) => unknown)("/a");
476
+ } catch (error) {
477
+ message = (error as Error).message;
478
+ }
479
+ return <Page title="B">{message}</Page>;
480
+ }
481
+ render(
482
+ <TerpProvider baseUrl="https://api.test">
483
+ <LogInOnMount />
484
+ <RouterProvider
485
+ router={buildAppRouter([{ name: "b", routes: [{ path: "/b", view: "V" }] }], {
486
+ views: { V: BView },
487
+ title: "Terp",
488
+ history: createMemoryHistory({ initialEntries: ["/b"] }),
489
+ })}
490
+ />
491
+ </TerpProvider>,
492
+ );
493
+
494
+ expect(await screen.findByText(/"\/a" is not a mounted route/)).toBeInTheDocument();
495
+ });
496
+
306
497
  it("gives breadcrumbs and hub cards the router's link without being asked", async () => {
307
498
  // A crumb rendered without `renderLink` used to fall back to a raw <a href>: a full
308
499
  // page reload, silently, with nothing to catch it. Inside a Terp router the default
@@ -412,7 +603,7 @@ describe("buildAppRouter", () => {
412
603
 
413
604
  // The sidebar's user menu shows the signed-in user's email; sign-out lives inside it.
414
605
  await waitFor(() => expect(screen.getByText("editor@example.com")).toBeInTheDocument());
415
- fireEvent.click(screen.getByRole("button", { name: "Account menu" }));
606
+ fireEvent.click(screen.getByRole("button", { name: /editor@example\.com/ }));
416
607
  // Clicking sign-out revokes the token server-side (ADR 0031).
417
608
  fireEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
418
609
  await waitFor(() =>
@@ -535,7 +726,7 @@ describe("buildAppRouter", () => {
535
726
  );
536
727
 
537
728
  await waitFor(() => expect(screen.getByText("editor@example.com")).toBeInTheDocument());
538
- fireEvent.click(screen.getByRole("button", { name: "Account menu" }));
729
+ fireEvent.click(screen.getByRole("button", { name: /editor@example\.com/ }));
539
730
  fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
540
731
 
541
732
  await waitFor(() =>
@@ -576,3 +767,575 @@ describe("buildAppRouter", () => {
576
767
  );
577
768
  });
578
769
  });
770
+
771
+ describe("buildAppRouter — which nav item is current (ADR 0097 §6, amended in 4e)", () => {
772
+ // A nav where one destination is an ancestor of another. This is not a contrived shape: the
773
+ // packaged admin module ships `/admin` and the example app adds `/admin/grants` and
774
+ // `/admin/webhooks` beside it, so every generated app has it.
775
+ const nested: ModuleManifest[] = [
776
+ { name: "home", routes: [{ path: "/", view: "Home" }], nav: [{ label: "Home", to: "/" }] },
777
+ {
778
+ name: "settings",
779
+ routes: [
780
+ { path: "/settings", view: "Settings" },
781
+ { path: "/settings/users", view: "SettingsUsers" },
782
+ { path: "/settings/appearance", view: "SettingsAppearance" },
783
+ ],
784
+ nav: [
785
+ { label: "Settings", to: "/settings" },
786
+ { label: "Members", to: "/settings/users" },
787
+ ],
788
+ },
789
+ ];
790
+ const nestedViews = {
791
+ Home: () => <Page title="Home view">home</Page>,
792
+ Settings: () => <Page title="Settings view">settings</Page>,
793
+ SettingsUsers: () => <Page title="Members view">members</Page>,
794
+ SettingsAppearance: () => <Page title="Appearance view">appearance</Page>,
795
+ };
796
+
797
+ async function renderAt(path: string) {
798
+ vi.stubGlobal("fetch", sessionFetch());
799
+ const router = buildAppRouter(nested, {
800
+ views: nestedViews,
801
+ title: "Terp",
802
+ history: createMemoryHistory({ initialEntries: [path] }),
803
+ });
804
+ const result = render(
805
+ <TerpProvider baseUrl="https://api.test">
806
+ <LogInOnMount />
807
+ <RouterProvider router={router} />
808
+ </TerpProvider>,
809
+ );
810
+ await waitFor(() =>
811
+ expect(document.querySelector('[data-terp="appshell-nav"] a')).not.toBeNull(),
812
+ );
813
+ return result;
814
+ }
815
+
816
+ /** Every nav link the shell rendered that claims to be the current page. */
817
+ function currentLinks(): string[] {
818
+ return [...document.querySelectorAll('[data-terp="appshell-nav"] a[aria-current="page"]')].map(
819
+ (a) => a.getAttribute("href") ?? "",
820
+ );
821
+ }
822
+
823
+ it("names exactly one current item where a per-link predicate names two", async () => {
824
+ // The defect. The router decides `isActive` per link with no knowledge of siblings, and
825
+ // before 4e the adapter left every item prefix-matching — so at /settings/users BOTH
826
+ // /settings and /settings/users carried aria-current="page", both painted, and a screen
827
+ // reader announced two current pages.
828
+ await renderAt("/settings/users");
829
+ await waitFor(() => expect(currentLinks()).toEqual(["/settings/users"]));
830
+ });
831
+
832
+ it("keeps the ancestor current on a page that is not itself a nav item", async () => {
833
+ // The other half, and why per-item `exact` is not the fix: /settings/appearance is a real
834
+ // route with no nav entry, so making /settings exact would leave the sidebar with nothing
835
+ // current at all.
836
+ await renderAt("/settings/appearance");
837
+ await waitFor(() => expect(currentLinks()).toEqual(["/settings"]));
838
+ });
839
+
840
+ it("does not let the root item claim a page it does not own", async () => {
841
+ // `/` prefixes every path as a string. The adapter used to work around that with a
842
+ // hand-written `exact: item.to === "/"`; the predicate matches on segments instead, so the
843
+ // workaround is gone and the behaviour is unchanged.
844
+ await renderAt("/settings");
845
+ await waitFor(() => expect(currentLinks()).toEqual(["/settings"]));
846
+ });
847
+
848
+ it("keeps the router from volunteering a second current item", async () => {
849
+ // The mechanism, asserted directly rather than through its effect. `data-status` is the one
850
+ // attribute only the router writes, so it shows what the router thought independently of
851
+ // what the shell told it. With activeOptions.exact it can only agree.
852
+ await renderAt("/settings/users");
853
+ await waitFor(() =>
854
+ expect(
855
+ document.querySelectorAll('[data-terp="appshell-nav"] a[data-status="active"]'),
856
+ ).toHaveLength(1),
857
+ );
858
+ });
859
+ });
860
+
861
+ describe("AppShell without activePath", () => {
862
+ it("claims nothing when nobody tells it where it is", async () => {
863
+ // The absent-prop half of the density idiom. A bare shell must not invent a current item —
864
+ // and the nine workbench specimens depend on this, because they supply aria-current from
865
+ // their own renderLink and would otherwise fight the shell.
866
+ const { AppShell } = await import("./AppShell");
867
+ render(
868
+ <AppShell
869
+ title="Terp"
870
+ nav={[{ label: "Notes", to: "/notes" }]}
871
+ renderLink={(item, children, { active }) => (
872
+ <a href={item.to} aria-current={active ? "page" : undefined}>
873
+ {children}
874
+ </a>
875
+ )}
876
+ >
877
+ <p>body</p>
878
+ </AppShell>,
879
+ );
880
+ expect(document.querySelectorAll('[aria-current="page"]')).toHaveLength(0);
881
+ });
882
+ });
883
+
884
+ describe("buildAppRouter — the shell's navigation subscription", () => {
885
+ it("keeps the published link renderer stable across a navigation", async () => {
886
+ // A gate for a fix, not for a feature. Reading the pathname makes `Shell` re-render on every
887
+ // navigation, where before it re-rendered only when the session changed. `Outlet` is
888
+ // memoised with no props, so a plain re-render stops at that boundary — but a CONTEXT VALUE
889
+ // punches through a memo bailout, and this value is used AS A COMPONENT (Breadcrumbs and
890
+ // HubCard render it through useNavLink). An unstable identity therefore remounts every
891
+ // in-app link in the tree on each navigation, which is worse than the re-render it looks
892
+ // like. The assertion is reference equality, because that is the actual contract.
893
+ vi.stubGlobal("fetch", sessionFetch());
894
+ const seen: unknown[] = [];
895
+ function Probe() {
896
+ seen.push(useNavLink());
897
+ return null;
898
+ }
899
+ const probeManifests: ModuleManifest[] = [
900
+ {
901
+ name: "home",
902
+ routes: [
903
+ { path: "/", view: "Home" },
904
+ { path: "/notes", view: "NotesList" },
905
+ ],
906
+ nav: [
907
+ { label: "Home", to: "/" },
908
+ { label: "Notes", to: "/notes" },
909
+ ],
910
+ },
911
+ ];
912
+ const router = buildAppRouter(probeManifests, {
913
+ views: {
914
+ Home: () => (
915
+ <Page title="Home view">
916
+ <Probe />
917
+ </Page>
918
+ ),
919
+ NotesList: () => (
920
+ <Page title="Notes view">
921
+ <Probe />
922
+ </Page>
923
+ ),
924
+ },
925
+ title: "Terp",
926
+ history: createMemoryHistory({ initialEntries: ["/"] }),
927
+ });
928
+
929
+ render(
930
+ <TerpProvider baseUrl="https://api.test">
931
+ <LogInOnMount />
932
+ <RouterProvider router={router} />
933
+ </TerpProvider>,
934
+ );
935
+ await waitFor(() => expect(seen.length).toBeGreaterThan(0));
936
+ const before = seen.at(-1);
937
+
938
+ await router.navigate({ to: "/notes" });
939
+ await waitFor(() =>
940
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
941
+ );
942
+
943
+ expect(seen.at(-1)).toBe(before);
944
+ expect(new Set(seen).size).toBe(1);
945
+ });
946
+ });
947
+
948
+ describe("buildAppRouter — a declared permission gates the ROUTE, not just the link", () => {
949
+ // The asymmetry this exists to prevent: hiding a nav link while leaving its route reachable by
950
+ // URL is not a weaker form of authorization, it is the appearance of one. `role` has never had
951
+ // that gap — it is declared on both NavItem and ModuleRoute — and `permission` does not get to
952
+ // introduce it. The mutation that motivated this test (gate the route on `role` alone) left the
953
+ // whole suite green.
954
+ const gated: ModuleManifest[] = [
955
+ {
956
+ name: "billing",
957
+ routes: [{ path: "/export", view: "Export", permission: "billing.export" }],
958
+ nav: [{ label: "Export", to: "/export", permission: "billing.export" }],
959
+ },
960
+ ];
961
+ const gatedViews = { Export: () => <Page title="Export view">export body</Page> };
962
+
963
+ function sessionWithPermissions(permissions: string[]) {
964
+ return vi.fn<typeof fetch>(async (input) => {
965
+ const url = (input as Request).url;
966
+ if (url.endsWith("/api/v1/auth/login")) {
967
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
968
+ }
969
+ return jsonResponse({
970
+ id: "1",
971
+ email: "editor@example.com",
972
+ role_rank: 20,
973
+ role_name: "editor",
974
+ permissions,
975
+ });
976
+ });
977
+ }
978
+
979
+ async function renderWith(permissions: string[]) {
980
+ vi.stubGlobal("fetch", sessionWithPermissions(permissions));
981
+ const router = buildAppRouter(gated, {
982
+ views: gatedViews,
983
+ title: "Terp",
984
+ history: createMemoryHistory({ initialEntries: ["/export"] }),
985
+ });
986
+ render(
987
+ <TerpProvider baseUrl="https://api.test">
988
+ <LogInOnMount />
989
+ <RouterProvider router={router} />
990
+ </TerpProvider>,
991
+ );
992
+ }
993
+
994
+ it("renders the view when the grant is held", async () => {
995
+ await renderWith(["billing.export"]);
996
+ await waitFor(() =>
997
+ expect(screen.getByRole("heading", { name: "Export view" })).toBeInTheDocument(),
998
+ );
999
+ expect(screen.getByRole("link", { name: "Export" })).toBeInTheDocument();
1000
+ });
1001
+
1002
+ it("refuses the route when the grant is missing, even though the rank clears", async () => {
1003
+ // rank 20 clears every role floor this manifest declares — the ONLY thing withheld is the
1004
+ // named grant, so a route gated on `role` alone would render the page in full.
1005
+ await renderWith([]);
1006
+ await waitFor(() =>
1007
+ expect(screen.getByText("You do not have access to this page.")).toBeInTheDocument(),
1008
+ );
1009
+ expect(screen.queryByRole("heading", { name: "Export view" })).not.toBeInTheDocument();
1010
+ // ...and the link is gone too, which is the half that was already working.
1011
+ expect(screen.queryByRole("link", { name: "Export" })).not.toBeInTheDocument();
1012
+ });
1013
+ });
1014
+
1015
+ describe("buildAppRouter navGroups", () => {
1016
+ it("passes the app's declared groups through to the shell", async () => {
1017
+ // The pass-through is a hand-written enumeration of option names, and nothing covered it:
1018
+ // `navPlacement`, `contentWidth` and `density` all reach the shell through the same list and
1019
+ // no test asserts any of them arrives. So every other gate on groups can be green while the
1020
+ // one line that carries them to the shell is missing, and every app using the sanctioned
1021
+ // entry point gets nothing. Mutation: delete `navGroups={options.navGroups}` from the
1022
+ // AppShell element, and the labelled list loses its name.
1023
+ vi.stubGlobal(
1024
+ "fetch",
1025
+ vi.fn<typeof fetch>(async (input) => {
1026
+ const url = (input as Request).url;
1027
+ if (url.endsWith("/api/v1/auth/login")) {
1028
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
1029
+ }
1030
+ return jsonResponse({
1031
+ id: "1",
1032
+ email: "editor@example.com",
1033
+ role_rank: 20,
1034
+ role_name: "editor",
1035
+ });
1036
+ }),
1037
+ );
1038
+
1039
+ const router = buildAppRouter(
1040
+ [
1041
+ {
1042
+ name: "notes",
1043
+ routes: [{ path: "/notes", view: "NotesList" }],
1044
+ nav: [{ label: "Notes", to: "/notes", group: "work" }],
1045
+ },
1046
+ ],
1047
+ {
1048
+ views: { NotesList: views.NotesList },
1049
+ title: "Terp",
1050
+ navGroups: [{ id: "work", label: "Werkruimte" }],
1051
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
1052
+ },
1053
+ );
1054
+
1055
+ render(
1056
+ <TerpProvider baseUrl="https://api.test">
1057
+ <LogInOnMount />
1058
+ <RouterProvider router={router} />
1059
+ </TerpProvider>,
1060
+ );
1061
+
1062
+ await waitFor(() =>
1063
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
1064
+ );
1065
+ // Asserted by accessible NAME, which is the only form that survives both a dropped
1066
+ // attribute and a dangling IDREF — see AppShell.test.tsx for why no lane catches either.
1067
+ expect(screen.getByRole("list", { name: "Werkruimte" })).toBeInTheDocument();
1068
+ });
1069
+
1070
+ it("carries a DECLARED density all the way to the shell root", async () => {
1071
+ // The gap the two comments above name, closed for the key that arrives by a different
1072
+ // route. `density` reaches the shell through the same hand-written enumeration, and now it
1073
+ // can also come from the app's checked-in layout-contract.json — so there are two ways for
1074
+ // it to go missing and this covers the new one end to end: file -> resolver -> AppShell
1075
+ // prop -> rendered attribute.
1076
+ //
1077
+ // Mutations, both red: change `density={layout.density}` back to `options.density` in the
1078
+ // AppShell element (the resolver runs and its answer is thrown away), or drop
1079
+ // `density: options.density` from the resolver call (the option path dies instead).
1080
+ vi.stubGlobal(
1081
+ "fetch",
1082
+ vi.fn<typeof fetch>(async (input) => {
1083
+ const url = (input as Request).url;
1084
+ if (url.endsWith("/api/v1/auth/login")) {
1085
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
1086
+ }
1087
+ return jsonResponse({
1088
+ id: "1",
1089
+ email: "editor@example.com",
1090
+ role_rank: 20,
1091
+ role_name: "editor",
1092
+ });
1093
+ }),
1094
+ );
1095
+
1096
+ const router = buildAppRouter(
1097
+ [{ name: "notes", routes: [{ path: "/notes", view: "NotesList" }] }],
1098
+ {
1099
+ views: { NotesList: views.NotesList },
1100
+ title: "Terp",
1101
+ layout: { shell: { density: "compact" } },
1102
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
1103
+ },
1104
+ );
1105
+
1106
+ render(
1107
+ <TerpProvider baseUrl="https://api.test">
1108
+ <LogInOnMount />
1109
+ <RouterProvider router={router} />
1110
+ </TerpProvider>,
1111
+ );
1112
+
1113
+ await waitFor(() =>
1114
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
1115
+ );
1116
+ expect(document.querySelector('[data-terp="appshell"]')).toHaveAttribute(
1117
+ "data-density",
1118
+ "compact",
1119
+ );
1120
+ });
1121
+
1122
+ it("carries DECLARED groups all the way to the rendered navigation", async () => {
1123
+ // The array key end to end: file -> resolver -> AppShell prop -> the list's accessible name.
1124
+ // The sibling test above proves the option path and this proves the file path, because they
1125
+ // are two different lines and either can go missing on its own.
1126
+ //
1127
+ // The empty label is the interesting half. The document spells "render no label at all" as
1128
+ // `""` and NavGroup spells it `null`, so the resolver maps between them — and a resolver
1129
+ // that passed `""` straight through would give the shell a labelled list whose name is the
1130
+ // empty string, which is a list with no accessible name at all and no error anywhere.
1131
+ //
1132
+ // Mutations, both red: `navGroups={options.navGroups}` in the AppShell element, or removing
1133
+ // the `"" -> null` map. NOT the third one this comment used to name — dropping
1134
+ // `navGroups: options.navGroups` from the resolver call — because this test passes no
1135
+ // groups OPTION, so that key is already undefined here and removing it changes nothing
1136
+ // observable. That line is gated in bootstrap.test.tsx, by the conflict it exists to raise.
1137
+ vi.stubGlobal(
1138
+ "fetch",
1139
+ vi.fn<typeof fetch>(async (input) => {
1140
+ const url = (input as Request).url;
1141
+ if (url.endsWith("/api/v1/auth/login")) {
1142
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
1143
+ }
1144
+ return jsonResponse({
1145
+ id: "1",
1146
+ email: "editor@example.com",
1147
+ role_rank: 20,
1148
+ role_name: "editor",
1149
+ });
1150
+ }),
1151
+ );
1152
+
1153
+ const router = buildAppRouter(
1154
+ [
1155
+ {
1156
+ name: "notes",
1157
+ routes: [
1158
+ { path: "/notes", view: "NotesList" },
1159
+ { path: "/pinned", view: "NotesList" },
1160
+ ],
1161
+ nav: [
1162
+ { label: "Notes", to: "/notes", group: "work" },
1163
+ { label: "Pinned", to: "/pinned", group: "quiet" },
1164
+ ],
1165
+ },
1166
+ ],
1167
+ {
1168
+ views: { NotesList: views.NotesList },
1169
+ title: "Terp",
1170
+ layout: {
1171
+ shell: {
1172
+ // Declared in the OPPOSITE order to the one they must render in. Written the
1173
+ // obvious way round first, and the ordering assertion below was then vacuous:
1174
+ // with the sort key dropped every group ties at 0 and the stable sort keeps
1175
+ // declaration order, which was already the answer being asserted. Only a
1176
+ // declaration order the sort has to undo can observe the sort.
1177
+ navGroups: [
1178
+ { id: "work", label: "Werkruimte", order: 2 },
1179
+ { id: "quiet", label: "", order: 1 },
1180
+ ],
1181
+ },
1182
+ },
1183
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
1184
+ },
1185
+ );
1186
+
1187
+ render(
1188
+ <TerpProvider baseUrl="https://api.test">
1189
+ <LogInOnMount />
1190
+ <RouterProvider router={router} />
1191
+ </TerpProvider>,
1192
+ );
1193
+
1194
+ await waitFor(() =>
1195
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
1196
+ );
1197
+ expect(screen.getByRole("list", { name: "Werkruimte" })).toBeInTheDocument();
1198
+ // Exactly ONE group label element, which is what proves the `"" -> null` map ran. This
1199
+ // assertion was written the wrong way first — comparing the lists' `aria-label` and
1200
+ // `aria-labelledby` ATTRIBUTES against `""` — and the mutation walked straight past it,
1201
+ // because an unmapped `""` does not produce an empty attribute. It produces a label element
1202
+ // containing nothing and an `aria-labelledby` pointing at it, so the list claims a name and
1203
+ // has none: a defect no attribute check and no role-name query can see, since a list with
1204
+ // an empty name and a list with no name are indistinguishable to both. Counting the label
1205
+ // elements is the form that sees it.
1206
+ expect(document.querySelectorAll('[data-terp="appshell-nav-group-label"]')).toHaveLength(1);
1207
+ // And the declared sort key put the unlabelled group first: `order: 1` against `order: 2`,
1208
+ // rather than whichever order they happened to render in. `getAllByRole` is document order.
1209
+ const first = screen.getAllByRole("list")[0]!;
1210
+ expect(first.getAttribute("aria-labelledby")).toBeNull();
1211
+ });
1212
+
1213
+ it("renders a DECLARED mark, and refuses it declared twice", async () => {
1214
+ // End to end for the third key that could only be said in code: file -> resolver -> the
1215
+ // AppShell slot -> an <img> in the rendered header. `alt=""` on purpose — the shell renders
1216
+ // the app's title beside the mark, so a name here announces the app twice.
1217
+ //
1218
+ // Mutations, both red: hand `options.logo` to AppShell instead of the resolved path, or
1219
+ // drop `brand` from the resolver's output.
1220
+ vi.stubGlobal(
1221
+ "fetch",
1222
+ vi.fn<typeof fetch>(async (input) => {
1223
+ const url = (input as Request).url;
1224
+ if (url.endsWith("/api/v1/auth/login")) {
1225
+ return jsonResponse({ access_token: "t", token_type: "bearer" });
1226
+ }
1227
+ return jsonResponse({
1228
+ id: "1",
1229
+ email: "editor@example.com",
1230
+ role_rank: 20,
1231
+ role_name: "editor",
1232
+ });
1233
+ }),
1234
+ );
1235
+
1236
+ const router = buildAppRouter(
1237
+ [{ name: "notes", routes: [{ path: "/notes", view: "NotesList" }] }],
1238
+ {
1239
+ views: { NotesList: views.NotesList },
1240
+ title: "Terp",
1241
+ layout: { shell: { brand: { logo: "/logo.png" } } },
1242
+ history: createMemoryHistory({ initialEntries: ["/notes"] }),
1243
+ },
1244
+ );
1245
+
1246
+ render(
1247
+ <TerpProvider baseUrl="https://api.test">
1248
+ <LogInOnMount />
1249
+ <RouterProvider router={router} />
1250
+ </TerpProvider>,
1251
+ );
1252
+
1253
+ await waitFor(() =>
1254
+ expect(screen.getByRole("heading", { name: "Notes view" })).toBeInTheDocument(),
1255
+ );
1256
+ const mark = document.querySelector('[data-terp="appshell-mark"] img');
1257
+ expect(mark).toHaveAttribute("src", "/logo.png");
1258
+ expect(mark).toHaveAttribute("alt", "");
1259
+ });
1260
+
1261
+ it("refuses a mark declared in the file and passed as an option", () => {
1262
+ // Refused in the router rather than the resolver, because a path and a rendered element are
1263
+ // not comparable values — so the message names the slots rather than pretending to compare.
1264
+ expect(() =>
1265
+ buildAppRouter([], {
1266
+ views: {},
1267
+ title: "Terp",
1268
+ layout: { shell: { brand: { logo: "/logo.png", logoDark: "/logo-dark.png" } } },
1269
+ logo: "x",
1270
+ logoDark: "y",
1271
+ }),
1272
+ ).toThrow(
1273
+ /declares shell\.brand\.logo and shell\.brand\.logoDark and the bootstrap options pass logo and logoDark/,
1274
+ );
1275
+ // And only the slot that is actually declared twice.
1276
+ expect(() =>
1277
+ buildAppRouter([], {
1278
+ views: {},
1279
+ title: "Terp",
1280
+ layout: { shell: { brand: { logo: "/logo.png" } } },
1281
+ logoDark: "y",
1282
+ }),
1283
+ ).not.toThrow();
1284
+ });
1285
+
1286
+ it("refuses the app's declaration and its options declaring one fact twice", () => {
1287
+ // Compose time, beside the duplicate-group refusal below and for the same reason: an
1288
+ // authoring error with no legitimate transient form. The message names both sources
1289
+ // because the reader otherwise cannot tell which value they are looking at.
1290
+ expect(() =>
1291
+ buildAppRouter([], {
1292
+ views: {},
1293
+ title: "Terp",
1294
+ layoutContract: "standard",
1295
+ layout: { contract: "standard" },
1296
+ }),
1297
+ ).toThrow(/both declare "contract" \(file: "standard", code: "standard"\)/);
1298
+ expect(() =>
1299
+ buildAppRouter([], {
1300
+ views: {},
1301
+ title: "Terp",
1302
+ layout: { shell: { density: "compakt" as "compact" } },
1303
+ }),
1304
+ ).toThrow(/shell\.density is "compakt"/);
1305
+ });
1306
+
1307
+ it("refuses a duplicate group id when the router is composed", () => {
1308
+ // An authoring error with no legitimate transient form, so it is refused once here rather
1309
+ // than absorbed on every render. Deliberately NOT symmetrical with an item naming an
1310
+ // undeclared group, which is the normal state of an app mid-adoption and falls open.
1311
+ expect(() =>
1312
+ buildAppRouter([], {
1313
+ views: {},
1314
+ title: "Terp",
1315
+ navGroups: [
1316
+ { id: "work", label: "Werkruimte" },
1317
+ { id: "work", label: "Weer werkruimte" },
1318
+ ],
1319
+ }),
1320
+ ).toThrow(/duplicate id\(s\): work/);
1321
+ });
1322
+
1323
+ it("does not refuse an item naming a group nobody declared", () => {
1324
+ // The counterpart to the row above, and the reason the throw is narrow: a module ships on
1325
+ // its own schedule, so an undeclared id is ordinary rather than wrong. The link must still
1326
+ // be reachable. Mutation: widen the composition check to unreferenced/undeclared ids and
1327
+ // this throws.
1328
+ expect(() =>
1329
+ buildAppRouter(
1330
+ [
1331
+ {
1332
+ name: "notes",
1333
+ routes: [{ path: "/notes", view: "NotesList" }],
1334
+ nav: [{ label: "Notes", to: "/notes", group: "not-declared-yet" }],
1335
+ },
1336
+ ],
1337
+ { views: { NotesList: views.NotesList }, title: "Terp", navGroups: [] },
1338
+ ),
1339
+ ).not.toThrow();
1340
+ });
1341
+ });