@terpjs/react-core 0.9.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 +56 -20
- package/package.json +6 -5
- package/src/AppShell.test.tsx +314 -0
- package/src/AppShell.tsx +384 -63
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.tsx +17 -4
- package/src/ModuleNav.test.tsx +17 -10
- package/src/ModuleNav.tsx +35 -3
- package/src/Page.tsx +23 -1
- package/src/ProfileView.test.tsx +1 -1
- package/src/ProfileView.tsx +2 -4
- 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 -7
- package/src/admin/GroupCreate.tsx +17 -3
- package/src/admin/GroupDetail.tsx +48 -13
- package/src/admin/GroupsAdmin.tsx +13 -5
- package/src/admin/UserCreate.tsx +40 -11
- package/src/admin/UserDetail.tsx +4 -1
- package/src/admin/UsersAdmin.tsx +14 -6
- package/src/admin/admin.test.tsx +212 -8
- 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/format.test.tsx +213 -0
- package/src/format.ts +150 -0
- package/src/icons.tsx +67 -5
- package/src/index.ts +56 -6
- 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 +25 -5
- 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/router.test.tsx +575 -2
- package/src/router.tsx +202 -19
- package/src/styles.test.ts +483 -24
- package/src/styles.ts +956 -85
- 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 +69 -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/admin/admin.test.tsx
CHANGED
|
@@ -8,14 +8,15 @@ import type { ComponentType } from "react";
|
|
|
8
8
|
|
|
9
9
|
import { withAdminArea } from "../bootstrap";
|
|
10
10
|
import type { AdminAreaSections } from "../bootstrap";
|
|
11
|
+
import { formatDateTime } from "../format";
|
|
11
12
|
import { buildAppRouter } from "../router";
|
|
12
13
|
import { Page } from "../Page";
|
|
13
14
|
import { TerpProvider, useAuth } from "../TerpProvider";
|
|
14
15
|
import { ToastProvider } from "../toast";
|
|
15
16
|
|
|
16
|
-
function jsonResponse(body: unknown): Response {
|
|
17
|
+
function jsonResponse(body: unknown, status = 200): Response {
|
|
17
18
|
return new Response(JSON.stringify(body), {
|
|
18
|
-
status
|
|
19
|
+
status,
|
|
19
20
|
headers: { "content-type": "application/json" },
|
|
20
21
|
});
|
|
21
22
|
}
|
|
@@ -313,7 +314,27 @@ function stubAdminFetch() {
|
|
|
313
314
|
});
|
|
314
315
|
}
|
|
315
316
|
if (path.endsWith("/api/v1/audit/")) {
|
|
316
|
-
|
|
317
|
+
// One row, and it earns its place rather than padding the fixture: the audit screen's
|
|
318
|
+
// expanded panel is the only place the payload renders, so with an empty page that
|
|
319
|
+
// <pre> — and the `tabIndex` that keeps its scroll container reachable — could not be
|
|
320
|
+
// asserted anywhere. The comment below this fixture used to say exactly that.
|
|
321
|
+
return jsonResponse({
|
|
322
|
+
items: [
|
|
323
|
+
{
|
|
324
|
+
id: "e1",
|
|
325
|
+
created_at: "2026-08-21T09:30:00Z",
|
|
326
|
+
action: "update",
|
|
327
|
+
target_type: "sync_definition",
|
|
328
|
+
target_id: "4d2c1b7e-0000-4000-8000-000000000001",
|
|
329
|
+
actor_id: "9f2c1b7e-0000-4000-8000-000000000002",
|
|
330
|
+
request_id: "req_01HQ8ZK4",
|
|
331
|
+
payload: { window: "02:00-04:00 UTC", retention_days: 90 },
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
total: 1,
|
|
335
|
+
skip: 0,
|
|
336
|
+
limit: 25,
|
|
337
|
+
});
|
|
317
338
|
}
|
|
318
339
|
return jsonResponse(emptyPage);
|
|
319
340
|
});
|
|
@@ -440,6 +461,48 @@ describe("the packaged admin area", () => {
|
|
|
440
461
|
})).toBe(true);
|
|
441
462
|
});
|
|
442
463
|
|
|
464
|
+
it("puts a 422's reason under the field it names instead of floating it in a toast", async () => {
|
|
465
|
+
// The failure path had no test at all, which is how the framework shipped `Field.error` with
|
|
466
|
+
// no production consumer for two releases: the rendering half was gated, the producing half
|
|
467
|
+
// did not exist, and nothing exercised the seam between them.
|
|
468
|
+
//
|
|
469
|
+
// The two assertions are deliberately different strings. `Field` shows the server's bare
|
|
470
|
+
// `msg`; the toast shows the joined `path: msg` sentence that `unwrap` has always produced.
|
|
471
|
+
// Asserting only the first would stay green if BOTH appeared, which is the failure mode worth
|
|
472
|
+
// guarding — three channels for one problem is what this commit set out to stop.
|
|
473
|
+
const { fetchMock } = renderAdminApp("/admin/users/new");
|
|
474
|
+
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
475
|
+
// A uniqueness violation, not a malformed address, and the choice is not incidental: the
|
|
476
|
+
// browser rejects a malformed one before any request leaves, so `type="email"` would have
|
|
477
|
+
// caught it and the POST would never happen (jsdom enforces that too, which is how the first
|
|
478
|
+
// draft of this test failed). What is left over after the four HTML constraint attributes
|
|
479
|
+
// have done their work is exactly what the server alone knows, and that is the class of
|
|
480
|
+
// reason this whole channel exists to carry.
|
|
481
|
+
const passthrough = fetchMock.getMockImplementation()!;
|
|
482
|
+
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
483
|
+
const request = input as Request;
|
|
484
|
+
if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
|
|
485
|
+
return jsonResponse(
|
|
486
|
+
{ detail: [{ loc: ["body", "email"], msg: "Email address is already registered" }] },
|
|
487
|
+
422,
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return passthrough(input, init);
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "taken@example.com" } });
|
|
494
|
+
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
|
|
495
|
+
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
496
|
+
|
|
497
|
+
const shown = await screen.findByText("Email address is already registered");
|
|
498
|
+
expect(shown.getAttribute("data-terp")).toBe("field-error");
|
|
499
|
+
expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
|
|
500
|
+
expect(screen.queryByText("email: Email address is already registered")).toBeNull();
|
|
501
|
+
// Still on the create page: a rejected submit must not navigate away from the input it is
|
|
502
|
+
// asking the user to fix.
|
|
503
|
+
expect(screen.getByRole("heading", { level: 1, name: "Provision user" })).toBeInTheDocument();
|
|
504
|
+
});
|
|
505
|
+
|
|
443
506
|
it("confirms lifecycle mutations from the user detail action slot", async () => {
|
|
444
507
|
const { fetchMock } = renderAdminApp("/admin/users/u1");
|
|
445
508
|
await screen.findByRole("heading", { level: 1, name: "jane.doe@example.com" });
|
|
@@ -519,16 +582,126 @@ describe("the packaged admin area", () => {
|
|
|
519
582
|
);
|
|
520
583
|
});
|
|
521
584
|
|
|
585
|
+
it("still says something when the 422 names a field the form does not render", async () => {
|
|
586
|
+
// The hole in the obvious version of this pattern: a non-empty `fields` suppressed the toast
|
|
587
|
+
// on the way out, and if no key matched an input, the state it set was read by nothing. The
|
|
588
|
+
// user pressed Save and NOTHING happened — no field lit up, no message, no navigation. A
|
|
589
|
+
// failed write that reports nothing is worse than the floating toast it replaced.
|
|
590
|
+
//
|
|
591
|
+
// This also proves the sibling test's `queryByText(...).toBeNull()` is not vacuous: the toast
|
|
592
|
+
// really does render the joined `path: msg` sentence as findable text, so an assertion that
|
|
593
|
+
// it is absent is an assertion about something that could otherwise have been there.
|
|
594
|
+
const { fetchMock } = renderAdminApp("/admin/users/new");
|
|
595
|
+
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
596
|
+
const passthrough = fetchMock.getMockImplementation()!;
|
|
597
|
+
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
598
|
+
const request = input as Request;
|
|
599
|
+
if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
|
|
600
|
+
return jsonResponse(
|
|
601
|
+
{ detail: [{ loc: ["body", "organization_id"], msg: "Not allowed for this tenant" }] },
|
|
602
|
+
422,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
return passthrough(input, init);
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
fireEvent.change(screen.getByLabelText("Email"), {
|
|
609
|
+
target: { value: "new.account@example.com" },
|
|
610
|
+
});
|
|
611
|
+
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
|
|
612
|
+
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
613
|
+
|
|
614
|
+
expect(
|
|
615
|
+
await screen.findByText("organization_id: Not allowed for this tenant"),
|
|
616
|
+
).toBeInTheDocument();
|
|
617
|
+
// And nothing was marked invalid, because none of these inputs is the one the server meant.
|
|
618
|
+
expect(screen.getByLabelText("Email")).not.toHaveAttribute("aria-invalid");
|
|
619
|
+
expect(screen.getByLabelText("Password")).not.toHaveAttribute("aria-invalid");
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("shows the field it can and still toasts the reason it cannot, when a 422 names both", async () => {
|
|
623
|
+
// The mixed envelope is the case the `leftover` flag exists for, and the only one that
|
|
624
|
+
// distinguishes it: with no renderable reason at all the toast fires anyway because `shown` is
|
|
625
|
+
// empty, so a mutation neutering `leftover` stayed green until this test existed. Here `email`
|
|
626
|
+
// finds its input and `organization_id` does not, and BOTH have to reach the user.
|
|
627
|
+
const { fetchMock } = renderAdminApp("/admin/users/new");
|
|
628
|
+
await screen.findByRole("heading", { level: 1, name: "Provision user" });
|
|
629
|
+
const passthrough = fetchMock.getMockImplementation()!;
|
|
630
|
+
fetchMock.mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
631
|
+
const request = input as Request;
|
|
632
|
+
if (request.method === "POST" && request.url.endsWith("/api/v1/users/")) {
|
|
633
|
+
return jsonResponse(
|
|
634
|
+
{
|
|
635
|
+
detail: [
|
|
636
|
+
{ loc: ["body", "email"], msg: "Email address is already registered" },
|
|
637
|
+
{ loc: ["body", "organization_id"], msg: "Not allowed for this tenant" },
|
|
638
|
+
],
|
|
639
|
+
},
|
|
640
|
+
422,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
return passthrough(input, init);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
fireEvent.change(screen.getByLabelText("Email"), { target: { value: "taken@example.com" } });
|
|
647
|
+
fireEvent.change(screen.getByLabelText("Password"), { target: { value: "strong-password" } });
|
|
648
|
+
fireEvent.click(screen.getByRole("button", { name: "Provision user" }));
|
|
649
|
+
|
|
650
|
+
const shown = await screen.findByText("Email address is already registered");
|
|
651
|
+
expect(shown.getAttribute("data-terp")).toBe("field-error");
|
|
652
|
+
expect(screen.getByLabelText("Email")).toHaveAttribute("aria-invalid", "true");
|
|
653
|
+
// The reason with nowhere to go is still said out loud, in the joined sentence.
|
|
654
|
+
expect(
|
|
655
|
+
screen.getByText(
|
|
656
|
+
"email: Email address is already registered; organization_id: Not allowed for this tenant",
|
|
657
|
+
),
|
|
658
|
+
).toBeInTheDocument();
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("renders its dates through the framework helper, not the built-in", async () => {
|
|
662
|
+
// What this gates is the CONVERSION, not the locale channel, and the distinction is worth
|
|
663
|
+
// naming: `renderAdminApp` mounts no `LocaleProvider`, so `useFormatDateTime` resolves an
|
|
664
|
+
// undefined locale and both sides of the assertion below say `undefined`. That the hook reads
|
|
665
|
+
// a provider at all is gated in format.test.tsx, by rendering two locales against each other.
|
|
666
|
+
//
|
|
667
|
+
// Here the teeth are in the negative half. Both spellings render the same instant and differ
|
|
668
|
+
// only in shape, so asserting the new one alone would stay green on a host whose default
|
|
669
|
+
// happened to agree with it. Asserting the old one is absent cannot be satisfied that way.
|
|
670
|
+
//
|
|
671
|
+
// Only one admin screen has a specimen (`admin-user-create`) and it renders no date, so
|
|
672
|
+
// nothing pictures these cells and nothing asserted their text before this — which is how
|
|
673
|
+
// seven of them sat on a locale-less built-in through two releases.
|
|
674
|
+
const when = "2026-08-21T09:30:00Z";
|
|
675
|
+
renderAdminApp("/admin/audit");
|
|
676
|
+
expect(await screen.findByText(formatDateTime(when, undefined))).toBeInTheDocument();
|
|
677
|
+
expect(screen.queryByText(new Date(when).toLocaleString())).toBeNull();
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
it("puts an unresolvable email under the member input rather than in a toast", async () => {
|
|
681
|
+
// Not a 422 — the directory simply has no match — but it is a statement about the email the
|
|
682
|
+
// user just typed, on a form whose only input is that email. Answering the server's reasons
|
|
683
|
+
// on the field and this one above it would be a distinction the user cannot perceive, so the
|
|
684
|
+
// routing follows what the message is about rather than where it came from.
|
|
685
|
+
renderAdminApp("/admin/groups/g1");
|
|
686
|
+
await screen.findByRole("heading", { level: 1, name: "Finance" });
|
|
687
|
+
fireEvent.change(screen.getByPlaceholderText("Email"), {
|
|
688
|
+
target: { value: "nobody@example.com" },
|
|
689
|
+
});
|
|
690
|
+
fireEvent.submit(screen.getByRole("button", { name: "Add member" }).closest("form")!);
|
|
691
|
+
const shown = await screen.findByText("No account matches that email.");
|
|
692
|
+
expect(shown.getAttribute("data-terp")).toBe("field-error");
|
|
693
|
+
});
|
|
694
|
+
|
|
522
695
|
it("marks the detail sections it no longer styles inline", async () => {
|
|
523
696
|
// The second of the three surfaces the admin views used to style at the call site. Same
|
|
524
697
|
// reasoning as the create form above: markers plus the absence of a style attribute,
|
|
525
698
|
// because no admin screen has a specimen.
|
|
526
699
|
//
|
|
527
|
-
// The third, the audit payload, has
|
|
528
|
-
// than
|
|
529
|
-
// expand and the <pre> never
|
|
530
|
-
//
|
|
531
|
-
//
|
|
700
|
+
// The third, the audit payload, has its own test below now. It had none for a while, and
|
|
701
|
+
// the reason was recorded here rather than left implicit: the audit fixture served an empty
|
|
702
|
+
// page, so no row existed to expand and the <pre> never rendered in any test. That stayed
|
|
703
|
+
// true until the payload gained a `tabIndex` — a claim about the real component that no
|
|
704
|
+
// workbench specimen can make, because the specimen writes its own markup.
|
|
532
705
|
renderAdminApp("/admin/groups/g1");
|
|
533
706
|
const headings = await waitFor(() => {
|
|
534
707
|
const found = document.querySelectorAll('[data-terp="admin-section-title"]');
|
|
@@ -541,6 +714,37 @@ describe("the packaged admin area", () => {
|
|
|
541
714
|
}
|
|
542
715
|
});
|
|
543
716
|
|
|
717
|
+
it("keeps the audit payload's scroll container reachable by keyboard", async () => {
|
|
718
|
+
// The gate for the SC 2.1.1 fix, and it has to be here rather than in the workbench.
|
|
719
|
+
// `code-block` declares `overflow-x: auto`, so a wide payload is a scroll container, and a
|
|
720
|
+
// scroll container no keyboard can reach is what axe reports as
|
|
721
|
+
// `scrollable-region-focusable`. The workbench specimen renders its own markup, so every lane
|
|
722
|
+
// there would stay green with the attribute gone from what this screen renders — the specimen
|
|
723
|
+
// would be asserting its own fixture back to itself. This reads the packaged screen.
|
|
724
|
+
//
|
|
725
|
+
// It also pins the substitution: the audit panel renders `Code block` now rather than a
|
|
726
|
+
// hand-rolled <pre> under a marker of its own, and if that reverted this would find the wrong
|
|
727
|
+
// marker rather than nothing at all.
|
|
728
|
+
renderAdminApp("/admin/audit");
|
|
729
|
+
// Expanding the row is what renders the panel; the trigger is the row's own expand control.
|
|
730
|
+
const expand = await waitFor(() => {
|
|
731
|
+
const found = document.querySelector('[data-terp="dataview-expand-cell"] button');
|
|
732
|
+
expect(found).not.toBeNull();
|
|
733
|
+
return found as HTMLButtonElement;
|
|
734
|
+
});
|
|
735
|
+
fireEvent.click(expand);
|
|
736
|
+
const payload = await waitFor(() => {
|
|
737
|
+
const found = document.querySelector('[data-terp="code-block"]');
|
|
738
|
+
expect(found).not.toBeNull();
|
|
739
|
+
return found as HTMLElement;
|
|
740
|
+
});
|
|
741
|
+
expect(payload.tagName).toBe("PRE");
|
|
742
|
+
expect(payload.tabIndex).toBe(0);
|
|
743
|
+
// And still no inline style — the third of the three surfaces the admin views used to
|
|
744
|
+
// style at the call site, which is what the note above refers to.
|
|
745
|
+
expect(payload.getAttribute("style")).toBeNull();
|
|
746
|
+
});
|
|
747
|
+
|
|
544
748
|
it("clears group destructive state when navigating between detail records in place", async () => {
|
|
545
749
|
const { router } = renderAdminApp("/admin/groups/g1");
|
|
546
750
|
await screen.findByRole("heading", { level: 1, name: "Finance" });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { ApiError } from "../unwrap";
|
|
2
|
+
|
|
3
|
+
/** What a form should do with a failure: what to put on inputs, and whether anything is left over. */
|
|
4
|
+
export interface RoutedFieldErrors {
|
|
5
|
+
/** Reasons naming a field this form actually renders, ready for `Field`'s `error` prop. */
|
|
6
|
+
shown: Record<string, string>;
|
|
7
|
+
/** At least one reason named something this form has no input for. */
|
|
8
|
+
leftover: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Split a failure's per-field reasons into the ones a form can show and the ones it cannot.
|
|
13
|
+
*
|
|
14
|
+
* The naive version of this — "if `fields` is non-empty, set them and return" — has a hole that is
|
|
15
|
+
* invisible until it happens: a reason naming a field the form does not render sets state nobody
|
|
16
|
+
* reads and suppresses the toast on the way out, so the user presses Save and *nothing appears*.
|
|
17
|
+
* No field lights up, no message, no navigation. A failed write that reports nothing is worse than
|
|
18
|
+
* the floating toast this replaced.
|
|
19
|
+
*
|
|
20
|
+
* The packaged forms cannot hit it today, because FastAPI's 422 can only name a key of the body
|
|
21
|
+
* they submitted and they render every one of those. That is a fact about these three endpoints,
|
|
22
|
+
* not about the pattern: a `terp.core.AppError` carrying `details` addresses whatever the rule
|
|
23
|
+
* checked, an app's form is free to submit a field it does not display, and this is the shape apps
|
|
24
|
+
* copy. `GroupDetail` already had it right by naming its one key and falling through to the toast
|
|
25
|
+
* when the server named a different one; this is that discipline, for a form with several.
|
|
26
|
+
*
|
|
27
|
+
* Not exported from the package. It becomes public API when something outside `admin/` needs it.
|
|
28
|
+
*/
|
|
29
|
+
export function routeFieldErrors(
|
|
30
|
+
error: unknown,
|
|
31
|
+
rendered: readonly string[],
|
|
32
|
+
): RoutedFieldErrors {
|
|
33
|
+
const shown: Record<string, string> = {};
|
|
34
|
+
let leftover = false;
|
|
35
|
+
if (error instanceof ApiError) {
|
|
36
|
+
for (const [key, message] of Object.entries(error.fields)) {
|
|
37
|
+
if (rendered.includes(key)) {
|
|
38
|
+
shown[key] = message;
|
|
39
|
+
} else {
|
|
40
|
+
leftover = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { shown, leftover };
|
|
45
|
+
}
|
package/src/bootstrap.test.tsx
CHANGED
|
@@ -64,4 +64,212 @@ describe("renderTerpApp", () => {
|
|
|
64
64
|
);
|
|
65
65
|
root.remove();
|
|
66
66
|
});
|
|
67
|
+
|
|
68
|
+
it("forwards the layout declaration to buildAppRouter", () => {
|
|
69
|
+
// Same shape as the navGroups test below, for the same reason: the forwarding is one line in
|
|
70
|
+
// a hand-written enumeration, and a missing line leaves every gate on the declaration green
|
|
71
|
+
// while the one-call entry point — the one apps actually use — ignores the file entirely.
|
|
72
|
+
//
|
|
73
|
+
// Gated through a refusal only `buildAppRouter` can raise, and that took a second attempt.
|
|
74
|
+
// The first version asserted the resolver's enum refusal — which `renderTerpApp` now raises
|
|
75
|
+
// ITSELF, from its own resolve call, before `buildAppRouter` is reached. So it threw with
|
|
76
|
+
// the forwarding line deleted and reported green: a test that passes with the bug. An
|
|
77
|
+
// UNKNOWN CONTRACT is refused only inside `buildAppRouter`, against its own table, so the
|
|
78
|
+
// throw here observes the one line under test and nothing else.
|
|
79
|
+
//
|
|
80
|
+
// Mutation: delete `layout: options.layout` from the buildAppRouter call, and this stops
|
|
81
|
+
// throwing.
|
|
82
|
+
const root = document.createElement("div");
|
|
83
|
+
document.body.appendChild(root);
|
|
84
|
+
try {
|
|
85
|
+
expect(() =>
|
|
86
|
+
renderTerpApp({
|
|
87
|
+
title: "Test",
|
|
88
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
89
|
+
layout: { contract: "bespoke" },
|
|
90
|
+
rootElement: root,
|
|
91
|
+
}),
|
|
92
|
+
).toThrow(/Unknown layout contract "bespoke"/);
|
|
93
|
+
} finally {
|
|
94
|
+
root.remove();
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("opens on the palette the declaration names, and on the option when it does not", async () => {
|
|
99
|
+
// The forwarding tests above gate their line through a THROW, because a signed-out mount
|
|
100
|
+
// renders no shell to read. The palette is the exception and gets the stronger assertion:
|
|
101
|
+
// `ThemeProvider` wraps `RequireAuth`, so it mounts signed out, and the attribute it writes
|
|
102
|
+
// on <html> is the actual observable effect an app ships. A throw here would only prove the
|
|
103
|
+
// file was validated, not that the value reached anything.
|
|
104
|
+
//
|
|
105
|
+
// Mutations, both red, and both by reading NULL rather than "midnight": `"system"` is the
|
|
106
|
+
// provider's default and it REMOVES the attribute, so neither mutation can produce the
|
|
107
|
+
// string. (An earlier version of this comment said the first one reads "system", which is
|
|
108
|
+
// not a value `data-theme` ever holds.) Pass `options.defaultTheme` to `ThemeProvider`
|
|
109
|
+
// instead of `layout.defaultTheme`, or drop `defaultTheme` from the explicit set handed to
|
|
110
|
+
// the resolver, and the corresponding assertion reads null.
|
|
111
|
+
for (const [label, options] of [
|
|
112
|
+
["from the file", { layout: { defaultTheme: "midnight" } }],
|
|
113
|
+
["from the option", { defaultTheme: "midnight" as const }],
|
|
114
|
+
] as const) {
|
|
115
|
+
// A stored choice outranks the default — that is the whole point of a *default* — so the
|
|
116
|
+
// assertion would be about localStorage rather than about the declaration without this.
|
|
117
|
+
window.localStorage.clear();
|
|
118
|
+
document.documentElement.removeAttribute("data-theme");
|
|
119
|
+
const root = document.createElement("div");
|
|
120
|
+
document.body.appendChild(root);
|
|
121
|
+
try {
|
|
122
|
+
renderTerpApp({
|
|
123
|
+
title: "Test",
|
|
124
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
125
|
+
rootElement: root,
|
|
126
|
+
...options,
|
|
127
|
+
});
|
|
128
|
+
await waitFor(() =>
|
|
129
|
+
expect(
|
|
130
|
+
document.documentElement.getAttribute("data-theme"),
|
|
131
|
+
`${label}: the declared palette must reach <html>`,
|
|
132
|
+
).toBe("midnight"),
|
|
133
|
+
);
|
|
134
|
+
} finally {
|
|
135
|
+
root.remove();
|
|
136
|
+
document.documentElement.removeAttribute("data-theme");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("refuses the palette named in the file and passed as an option", () => {
|
|
142
|
+
// Non-exclusive per ADR 0009 — either source alone is complete, as the test above shows —
|
|
143
|
+
// but not both, and not even when they agree. Reachable only through `renderTerpApp`,
|
|
144
|
+
// because `buildAppRouter` has no palette option to conflict with.
|
|
145
|
+
const root = document.createElement("div");
|
|
146
|
+
document.body.appendChild(root);
|
|
147
|
+
try {
|
|
148
|
+
expect(() =>
|
|
149
|
+
renderTerpApp({
|
|
150
|
+
title: "Test",
|
|
151
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
152
|
+
layout: { defaultTheme: "midnight" },
|
|
153
|
+
defaultTheme: "midnight",
|
|
154
|
+
rootElement: root,
|
|
155
|
+
}),
|
|
156
|
+
).toThrow(/both declare "defaultTheme" \(file: "midnight", code: "midnight"\)/);
|
|
157
|
+
} finally {
|
|
158
|
+
root.remove();
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("names every doubly-declared key at once, groups included", async () => {
|
|
163
|
+
// The property the resolver's `conflicts.join("; ")` exists for, asserted through the entry
|
|
164
|
+
// point apps use — and it was broken here. `renderTerpApp` resolves the declaration itself
|
|
165
|
+
// (the palette mounts outside the router) and its explicit set omitted `navGroups`, so a
|
|
166
|
+
// groups conflict was invisible from this side: the author fixed the density conflict,
|
|
167
|
+
// re-ran, and hit a second, different refusal. Mutation: drop `navGroups: options.navGroups`
|
|
168
|
+
// from the resolve call at the top of `renderTerpApp` and this names only the density.
|
|
169
|
+
const root = document.createElement("div");
|
|
170
|
+
document.body.appendChild(root);
|
|
171
|
+
let message = "";
|
|
172
|
+
try {
|
|
173
|
+
renderTerpApp({
|
|
174
|
+
title: "Test",
|
|
175
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
176
|
+
layout: {
|
|
177
|
+
shell: {
|
|
178
|
+
density: "comfortable",
|
|
179
|
+
navGroups: [{ id: "work", label: "Workspace" }],
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
density: "compact",
|
|
183
|
+
navGroups: [{ id: "admin", label: "Admin" }],
|
|
184
|
+
rootElement: root,
|
|
185
|
+
});
|
|
186
|
+
} catch (error) {
|
|
187
|
+
message = (error as Error).message;
|
|
188
|
+
} finally {
|
|
189
|
+
root.remove();
|
|
190
|
+
}
|
|
191
|
+
expect(message).toContain('"shell.density"');
|
|
192
|
+
expect(message).toContain('"shell.navGroups"');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses the navigation groups named in the file and passed as an option", () => {
|
|
196
|
+
// The array key travelling the same seam as the scalars, end to end: the resolver sees the
|
|
197
|
+
// file, `buildAppRouter` hands the resolved list to the shell, and declaring the groups in
|
|
198
|
+
// both places is one fact declared twice. Named by id, because a list of objects stringified
|
|
199
|
+
// into an error message tells the reader nothing they can match up.
|
|
200
|
+
const root = document.createElement("div");
|
|
201
|
+
document.body.appendChild(root);
|
|
202
|
+
try {
|
|
203
|
+
expect(() =>
|
|
204
|
+
renderTerpApp({
|
|
205
|
+
title: "Test",
|
|
206
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
207
|
+
layout: { shell: { navGroups: [{ id: "work", label: "Workspace" }] } },
|
|
208
|
+
navGroups: [{ id: "admin", label: "Admin" }],
|
|
209
|
+
rootElement: root,
|
|
210
|
+
}),
|
|
211
|
+
).toThrow(/both declare "shell\.navGroups" \(file: "work", code: "admin"\)/);
|
|
212
|
+
} finally {
|
|
213
|
+
root.remove();
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("refuses duplicate ids declared in the file, through the router's own check", () => {
|
|
218
|
+
// The check lives in `buildAppRouter` and now reads the RESOLVED list, so this is the proof
|
|
219
|
+
// that a duplicate reaches it from the file and not only from the option. Mutation: point
|
|
220
|
+
// the check back at `options.navGroups` and this stops throwing while the option-side test
|
|
221
|
+
// above stays green.
|
|
222
|
+
const root = document.createElement("div");
|
|
223
|
+
document.body.appendChild(root);
|
|
224
|
+
try {
|
|
225
|
+
expect(() =>
|
|
226
|
+
renderTerpApp({
|
|
227
|
+
title: "Test",
|
|
228
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
229
|
+
layout: {
|
|
230
|
+
shell: {
|
|
231
|
+
navGroups: [
|
|
232
|
+
{ id: "work", label: "Workspace" },
|
|
233
|
+
{ id: "work", label: "Again" },
|
|
234
|
+
],
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
rootElement: root,
|
|
238
|
+
}),
|
|
239
|
+
).toThrow(/duplicate id\(s\): work/);
|
|
240
|
+
} finally {
|
|
241
|
+
root.remove();
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("forwards navGroups to buildAppRouter", () => {
|
|
246
|
+
// `renderTerpApp` hands its options to `buildAppRouter` through a hand-written enumeration of
|
|
247
|
+
// names, and nothing covered it — `navPlacement`, `contentWidth` and `density` all travel that
|
|
248
|
+
// same list with no assertion that any of them arrives. A missing line there leaves every
|
|
249
|
+
// other gate on groups green while the feature does nothing for every app using the one-call
|
|
250
|
+
// entry point, which is the entry point apps actually use.
|
|
251
|
+
//
|
|
252
|
+
// Gated through the duplicate-id refusal rather than through a rendered label, and that is
|
|
253
|
+
// the point of doing it this way: this test mounts the SIGNED-OUT app, so the shell never
|
|
254
|
+
// renders and there is no nav to read. The throw only happens if the option was forwarded, so
|
|
255
|
+
// it observes the one line under test and nothing else. Mutation: delete
|
|
256
|
+
// `navGroups: options.navGroups` from the buildAppRouter call, and this stops throwing.
|
|
257
|
+
const root = document.createElement("div");
|
|
258
|
+
document.body.appendChild(root);
|
|
259
|
+
try {
|
|
260
|
+
expect(() =>
|
|
261
|
+
renderTerpApp({
|
|
262
|
+
title: "Test",
|
|
263
|
+
modules: { "./modules/notes/module.tsx": notesModule },
|
|
264
|
+
navGroups: [
|
|
265
|
+
{ id: "work", label: "Werkruimte" },
|
|
266
|
+
{ id: "work", label: "Weer werkruimte" },
|
|
267
|
+
],
|
|
268
|
+
rootElement: root,
|
|
269
|
+
}),
|
|
270
|
+
).toThrow(/duplicate id\(s\): work/);
|
|
271
|
+
} finally {
|
|
272
|
+
root.remove();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
67
275
|
});
|