@terpjs/react-core 0.9.0 → 0.11.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 +57 -21
- package/package.json +6 -5
- package/src/AppShell.test.tsx +323 -4
- package/src/AppShell.tsx +401 -66
- package/src/EmptyState.test.tsx +30 -0
- package/src/EmptyState.tsx +23 -3
- package/src/Field.test.tsx +30 -0
- package/src/Field.tsx +36 -8
- package/src/FormPage.tsx +54 -0
- package/src/LoginView.test.tsx +34 -2
- package/src/LoginView.tsx +43 -18
- 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 +12 -0
- package/src/markers.test.ts +27 -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 +212 -19
- package/src/styles.test.ts +535 -58
- package/src/styles.ts +1130 -111
- 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 +239 -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 +139 -0
- package/src/ui/Combobox.tsx +255 -43
- package/src/ui/DatePicker.tsx +44 -12
- 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.test.tsx +28 -0
- package/src/ui/Tabs.tsx +30 -6
- package/src/ui/Tooltip.test.tsx +56 -1
- package/src/ui/Tooltip.tsx +69 -6
- package/src/uiText.literals.test.ts +199 -0
- package/src/uiText.tsx +36 -0
- package/src/unwrap.test.ts +132 -0
- package/src/unwrap.ts +118 -32
package/src/admin/UserCreate.tsx
CHANGED
|
@@ -16,10 +16,14 @@ import { useStrings } from "../uiText";
|
|
|
16
16
|
import { unwrap } from "../unwrap";
|
|
17
17
|
|
|
18
18
|
import { adminCrumb, renderAdminCrumb } from "./crumbs";
|
|
19
|
+
import { routeFieldErrors } from "./fieldErrors";
|
|
19
20
|
import { adminRoleOptions } from "./roles";
|
|
20
21
|
|
|
21
22
|
const FORM_ID = "terp-admin-user-create";
|
|
22
23
|
|
|
24
|
+
/** The inputs this form renders, and so the only reasons it can put anywhere the user will see. */
|
|
25
|
+
const RENDERED_FIELDS = ["email", "password", "role"] as const;
|
|
26
|
+
|
|
23
27
|
/** Dedicated account-provisioning page (`/admin/users/new`). */
|
|
24
28
|
export function UserCreate() {
|
|
25
29
|
const client = useTerpClient();
|
|
@@ -30,11 +34,16 @@ export function UserCreate() {
|
|
|
30
34
|
const [password, setPassword] = useState("");
|
|
31
35
|
const [role, setRole] = useState("10");
|
|
32
36
|
const [creating, setCreating] = useState(false);
|
|
37
|
+
const [fieldErrors, setFieldErrors] = useState<Readonly<Record<string, string>>>({});
|
|
33
38
|
const roles = adminRoleOptions(strings);
|
|
34
39
|
|
|
35
40
|
async function onSubmit(event: FormEvent) {
|
|
36
41
|
event.preventDefault();
|
|
37
42
|
setCreating(true);
|
|
43
|
+
// Cleared on every attempt, not merged: the server re-validates the whole body, so its
|
|
44
|
+
// answer is the complete set of what is wrong. Keeping a stale key would leave an error
|
|
45
|
+
// under a field the user just fixed.
|
|
46
|
+
setFieldErrors({});
|
|
38
47
|
try {
|
|
39
48
|
const user = unwrap(
|
|
40
49
|
await client.POST("/api/v1/users/", {
|
|
@@ -47,7 +56,15 @@ export function UserCreate() {
|
|
|
47
56
|
params: { userId: user.id },
|
|
48
57
|
});
|
|
49
58
|
} catch (error) {
|
|
50
|
-
|
|
59
|
+
// A reason that names a field belongs on that field, not floating above the form in a
|
|
60
|
+
// toast the user has to hold in their head while looking for the input it means. Anything
|
|
61
|
+
// the server did not attribute — or attributed to something this form has no input for —
|
|
62
|
+
// stays a toast, which is where the delete and revoke handlers correctly leave it.
|
|
63
|
+
const { shown, leftover } = routeFieldErrors(error, RENDERED_FIELDS);
|
|
64
|
+
setFieldErrors(shown);
|
|
65
|
+
if (Object.keys(shown).length === 0 || leftover) {
|
|
66
|
+
toast.warning(error instanceof Error ? error.message : strings.requestFailed);
|
|
67
|
+
}
|
|
51
68
|
} finally {
|
|
52
69
|
setCreating(false);
|
|
53
70
|
}
|
|
@@ -83,7 +100,7 @@ export function UserCreate() {
|
|
|
83
100
|
>
|
|
84
101
|
<div data-terp="admin-form">
|
|
85
102
|
<Stack id={FORM_ID} as="form" gap={4} onSubmit={onSubmit}>
|
|
86
|
-
<Field label={strings.email}>
|
|
103
|
+
<Field label={strings.email} error={fieldErrors.email}>
|
|
87
104
|
<Input
|
|
88
105
|
type="email"
|
|
89
106
|
value={email}
|
|
@@ -91,22 +108,34 @@ export function UserCreate() {
|
|
|
91
108
|
required
|
|
92
109
|
/>
|
|
93
110
|
</Field>
|
|
94
|
-
<Field label={strings.password}>
|
|
111
|
+
<Field label={strings.password} error={fieldErrors.password}>
|
|
95
112
|
<Input
|
|
96
113
|
type="password"
|
|
114
|
+
// The admin is setting someone else's password, so this is never the browser's saved
|
|
115
|
+
// credential: `new-password` stops a manager offering the admin's own.
|
|
116
|
+
autoComplete="new-password"
|
|
97
117
|
value={password}
|
|
98
118
|
onChange={(event) => setPassword(event.target.value)}
|
|
99
119
|
required
|
|
100
120
|
/>
|
|
101
121
|
</Field>
|
|
102
|
-
<Field label={strings.role}>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
122
|
+
<Field label={strings.role} error={fieldErrors.role}>
|
|
123
|
+
{/* `options` + `onValueChange` rather than an `<option>` per rank and a raw
|
|
124
|
+
change event: the same three rows, as data. No cast is removed here — this file
|
|
125
|
+
never had one, because `role` was already a plain string — and `T` degrades to
|
|
126
|
+
`string` for the same reason, since the option values are `String(rank)` rather
|
|
127
|
+
than members of a declared union. What this conversion buys is the list, not the
|
|
128
|
+
type: the rank is a number in the ladder and a string in the DOM, which is why
|
|
129
|
+
`String(rank)` is the option value and `role` stays a string until the POST body
|
|
130
|
+
coerces it. The typed half has its exhibit in an app that owns an enum. */}
|
|
131
|
+
<Select
|
|
132
|
+
options={roles.map((option) => ({
|
|
133
|
+
value: String(option.rank),
|
|
134
|
+
label: option.label,
|
|
135
|
+
}))}
|
|
136
|
+
value={role}
|
|
137
|
+
onValueChange={setRole}
|
|
138
|
+
/>
|
|
110
139
|
</Field>
|
|
111
140
|
</Stack>
|
|
112
141
|
</div>
|
package/src/admin/UserDetail.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import { useRecord } from "../useRecord";
|
|
|
13
13
|
import { useToast } from "../toast";
|
|
14
14
|
import { Button } from "../ui/Button";
|
|
15
15
|
import { Input } from "../ui/Input";
|
|
16
|
+
import { useFormatDateTime } from "../format";
|
|
16
17
|
import { useStrings } from "../uiText";
|
|
17
18
|
import { unwrap } from "../unwrap";
|
|
18
19
|
|
|
@@ -30,6 +31,7 @@ export function UserDetail() {
|
|
|
30
31
|
const userId = useDeclaredParam("userId");
|
|
31
32
|
const client = useTerpClient();
|
|
32
33
|
const strings = useStrings();
|
|
34
|
+
const formatDateTime = useFormatDateTime();
|
|
33
35
|
const toast = useToast();
|
|
34
36
|
const [pendingLifecycle, setPendingLifecycle] = useState<PendingLifecycle | null>(null);
|
|
35
37
|
const [mutating, setMutating] = useState(false);
|
|
@@ -173,7 +175,7 @@ export function UserDetail() {
|
|
|
173
175
|
label: strings.statusColumn,
|
|
174
176
|
value: record.is_active ? strings.statusActive : strings.statusDeactivated,
|
|
175
177
|
},
|
|
176
|
-
{ label: strings.createdColumn, value:
|
|
178
|
+
{ label: strings.createdColumn, value: formatDateTime(record.created_at) },
|
|
177
179
|
]}
|
|
178
180
|
/>
|
|
179
181
|
)}
|
|
@@ -211,6 +213,7 @@ export function UserDetail() {
|
|
|
211
213
|
<Field label={strings.newPassword}>
|
|
212
214
|
<Input
|
|
213
215
|
type="password"
|
|
216
|
+
autoComplete="new-password"
|
|
214
217
|
value={resetPassword}
|
|
215
218
|
onChange={(event) => setResetPassword(event.target.value)}
|
|
216
219
|
required
|
package/src/admin/UsersAdmin.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import { useTerpClient } from "../TerpProvider";
|
|
|
9
9
|
import { DataView, HttpDataViewRepository, useServerDataView } from "../dataview";
|
|
10
10
|
import type { DataViewColumn } from "../dataview";
|
|
11
11
|
import { Button } from "../ui/Button";
|
|
12
|
+
import { useFormatDate } from "../format";
|
|
12
13
|
import { useStrings } from "../uiText";
|
|
13
14
|
import type { TerpStrings } from "../uiText";
|
|
14
15
|
import { unwrap } from "../unwrap";
|
|
@@ -18,7 +19,10 @@ import { adminRoleLabel } from "./roles";
|
|
|
18
19
|
|
|
19
20
|
type UserRead = components["schemas"]["UserRead"];
|
|
20
21
|
|
|
21
|
-
function buildColumns(
|
|
22
|
+
function buildColumns(
|
|
23
|
+
strings: TerpStrings,
|
|
24
|
+
formatDate: (value: string) => string,
|
|
25
|
+
): DataViewColumn<UserRead>[] {
|
|
22
26
|
return [
|
|
23
27
|
{ id: "email", header: strings.email, accessor: (u) => u.email, meta: { mobileSlot: "title" } },
|
|
24
28
|
{
|
|
@@ -26,20 +30,20 @@ function buildColumns(strings: TerpStrings): DataViewColumn<UserRead>[] {
|
|
|
26
30
|
header: strings.role,
|
|
27
31
|
accessor: (u) => u.role,
|
|
28
32
|
cell: (u) => adminRoleLabel(strings, u.role),
|
|
29
|
-
meta: { mobileSlot: "subtitle", width:
|
|
33
|
+
meta: { mobileSlot: "subtitle", width: "xs" },
|
|
30
34
|
},
|
|
31
35
|
{
|
|
32
36
|
id: "is_active",
|
|
33
37
|
header: strings.statusColumn,
|
|
34
38
|
accessor: (u) => (u.is_active ? strings.statusActive : strings.statusDeactivated),
|
|
35
|
-
meta: { mobileSlot: "status", width:
|
|
39
|
+
meta: { mobileSlot: "status", width: "sm" },
|
|
36
40
|
},
|
|
37
41
|
{
|
|
38
42
|
id: "created_at",
|
|
39
43
|
header: strings.createdColumn,
|
|
40
44
|
accessor: (u) => u.created_at,
|
|
41
|
-
cell: (u) =>
|
|
42
|
-
meta: { mobileSlot: "date", width:
|
|
45
|
+
cell: (u) => formatDate(u.created_at),
|
|
46
|
+
meta: { mobileSlot: "date", width: "sm" },
|
|
43
47
|
},
|
|
44
48
|
];
|
|
45
49
|
}
|
|
@@ -55,7 +59,11 @@ export function UsersAdmin() {
|
|
|
55
59
|
const navigate = useNavigate();
|
|
56
60
|
const serverQuery = useServerDataView({ initialPageSize: 10 });
|
|
57
61
|
|
|
58
|
-
const
|
|
62
|
+
const formatDate = useFormatDate();
|
|
63
|
+
const columns = useMemo(
|
|
64
|
+
() => buildColumns(strings, formatDate),
|
|
65
|
+
[strings, formatDate],
|
|
66
|
+
);
|
|
59
67
|
const repository = useMemo(
|
|
60
68
|
() =>
|
|
61
69
|
new HttpDataViewRepository<UserRead>({
|
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
|
+
}
|