@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.test.tsx
CHANGED
|
@@ -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 {
|
|
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:
|
|
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:
|
|
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
|
+
});
|