@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/ui/Tooltip.test.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
|
-
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
|
3
|
+
import { vi } from "vitest";
|
|
3
4
|
import { afterEach, describe, expect, it } from "vitest";
|
|
4
5
|
|
|
5
6
|
import { Button } from "./Button";
|
|
@@ -25,4 +26,58 @@ describe("Tooltip", () => {
|
|
|
25
26
|
fireEvent.mouseEnter(trigger.parentElement!);
|
|
26
27
|
expect(tooltip).toBeVisible();
|
|
27
28
|
});
|
|
29
|
+
|
|
30
|
+
it("dismisses on Escape without moving the pointer or focus", () => {
|
|
31
|
+
// WCAG 1.4.13, Dismissible. There was no key handler of any kind, so a bubble covering the
|
|
32
|
+
// content under it could only be escaped by moving away from the control the user was
|
|
33
|
+
// reading about. Bound on the document, because the pointer-opened case has no focus
|
|
34
|
+
// anywhere near this component and a trigger-bound handler would never see the key.
|
|
35
|
+
// Mutation: delete the keydown effect.
|
|
36
|
+
render(
|
|
37
|
+
<Tooltip content="More information" defaultOpen>
|
|
38
|
+
<Button>Help</Button>
|
|
39
|
+
</Tooltip>,
|
|
40
|
+
);
|
|
41
|
+
const tooltip = screen.getByRole("tooltip");
|
|
42
|
+
expect(tooltip).toBeVisible();
|
|
43
|
+
fireEvent.keyDown(document, { key: "Escape" });
|
|
44
|
+
expect(tooltip).not.toBeVisible();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("stays open while the pointer crosses to the bubble", () => {
|
|
48
|
+
// WCAG 1.4.13, Hoverable. The bubble used to declare pointer-events: none, which makes
|
|
49
|
+
// reaching it impossible by construction; that is gone, and the close is delayed so the
|
|
50
|
+
// visual gap between trigger and bubble can be crossed. Re-entering cancels the close.
|
|
51
|
+
// Mutation: close synchronously on mouseleave, and this fails.
|
|
52
|
+
vi.useFakeTimers();
|
|
53
|
+
try {
|
|
54
|
+
render(
|
|
55
|
+
<Tooltip content="More information" defaultOpen>
|
|
56
|
+
<Button>Help</Button>
|
|
57
|
+
</Tooltip>,
|
|
58
|
+
);
|
|
59
|
+
const tooltip = screen.getByRole("tooltip");
|
|
60
|
+
const anchor = tooltip.parentElement!;
|
|
61
|
+
fireEvent.mouseLeave(anchor);
|
|
62
|
+
// Still open partway through the grace period...
|
|
63
|
+
act(() => {
|
|
64
|
+
vi.advanceTimersByTime(60);
|
|
65
|
+
});
|
|
66
|
+
expect(tooltip).toBeVisible();
|
|
67
|
+
// ...and re-entering cancels the close entirely.
|
|
68
|
+
fireEvent.mouseEnter(anchor);
|
|
69
|
+
act(() => {
|
|
70
|
+
vi.advanceTimersByTime(500);
|
|
71
|
+
});
|
|
72
|
+
expect(tooltip).toBeVisible();
|
|
73
|
+
// Leaving and staying away does close it.
|
|
74
|
+
fireEvent.mouseLeave(anchor);
|
|
75
|
+
act(() => {
|
|
76
|
+
vi.advanceTimersByTime(500);
|
|
77
|
+
});
|
|
78
|
+
expect(tooltip).not.toBeVisible();
|
|
79
|
+
} finally {
|
|
80
|
+
vi.useRealTimers();
|
|
81
|
+
}
|
|
82
|
+
});
|
|
28
83
|
});
|
package/src/ui/Tooltip.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cloneElement, isValidElement, useId, useState } from "react";
|
|
1
|
+
import { cloneElement, isValidElement, useEffect, useId, useRef, useState } from "react";
|
|
2
2
|
import type { FocusEvent, MouseEvent, ReactElement } from "react";
|
|
3
3
|
|
|
4
4
|
import { injectTerpStyles } from "../styles";
|
|
@@ -10,6 +10,16 @@ injectTerpStyles();
|
|
|
10
10
|
export interface TooltipProps {
|
|
11
11
|
content: UiText;
|
|
12
12
|
children: ReactElement;
|
|
13
|
+
/**
|
|
14
|
+
* Start with the bubble shown.
|
|
15
|
+
*
|
|
16
|
+
* The same dev/specimen affordance `AppShell.defaultCollapsed` and `defaultDrawerOpen` are,
|
|
17
|
+
* added for the same reason: the panel's whole style block — its surface, its ink, its shadow
|
|
18
|
+
* and its measure — was painted by nothing. The one Tooltip specimen renders the trigger with
|
|
19
|
+
* the bubble closed, and neither browser lane can hover or focus, so a change to any of those
|
|
20
|
+
* declarations moved no pixel that any gate reads. An app has no reason to pin a tooltip open.
|
|
21
|
+
*/
|
|
22
|
+
defaultOpen?: boolean;
|
|
13
23
|
}
|
|
14
24
|
|
|
15
25
|
interface TriggerHandlers {
|
|
@@ -20,11 +30,60 @@ interface TriggerHandlers {
|
|
|
20
30
|
"aria-describedby"?: string;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
/**
|
|
24
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Accessible focus/hover tooltip.
|
|
35
|
+
*
|
|
36
|
+
* Holds all three parts of WCAG 1.4.13 (Content on Hover or Focus, level AA), and two of them
|
|
37
|
+
* had to be added:
|
|
38
|
+
*
|
|
39
|
+
* - **Dismissible.** Escape closes the bubble without moving the pointer or focus. There was no
|
|
40
|
+
* key handler of any kind before, so a tooltip covering the content beneath it could only be
|
|
41
|
+
* escaped by moving away from the control the user was reading about.
|
|
42
|
+
* - **Hoverable.** The bubble is reachable with the pointer. It used to declare
|
|
43
|
+
* `pointer-events: none`, which makes hovering it impossible by construction — so a tooltip
|
|
44
|
+
* long enough to need reading could not be read by anyone tracking with a pointer or using
|
|
45
|
+
* magnification. The bubble is a DOM child of the anchor, so moving onto it does not fire the
|
|
46
|
+
* anchor's `mouseleave`; the close delay below covers the visual gap between the two, which
|
|
47
|
+
* the pointer does cross.
|
|
48
|
+
* - **Persistent.** It stays until dismissed, focus leaves or the pointer leaves — it has never
|
|
49
|
+
* had a timeout.
|
|
50
|
+
*/
|
|
51
|
+
export function Tooltip({ content, children, defaultOpen = false }: TooltipProps) {
|
|
25
52
|
const id = useId();
|
|
26
53
|
const resolve = useUiText();
|
|
27
|
-
const [open, setOpen] = useState(
|
|
54
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
55
|
+
// Cleared on re-entry, which is what makes the gap between trigger and bubble crossable.
|
|
56
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
57
|
+
|
|
58
|
+
function cancelClose() {
|
|
59
|
+
if (closeTimer.current !== null) {
|
|
60
|
+
clearTimeout(closeTimer.current);
|
|
61
|
+
closeTimer.current = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scheduleClose() {
|
|
66
|
+
cancelClose();
|
|
67
|
+
closeTimer.current = setTimeout(() => setOpen(false), 120);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
useEffect(() => cancelClose, []);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!open) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// On the document rather than the trigger: the pointer-opened case has no focus anywhere
|
|
77
|
+
// near this component, so a handler bound to the trigger would never see the key. The same
|
|
78
|
+
// placement Popover uses, for the same reason.
|
|
79
|
+
function onKeyDown(event: KeyboardEvent) {
|
|
80
|
+
if (event.key === "Escape") {
|
|
81
|
+
setOpen(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
document.addEventListener("keydown", onKeyDown);
|
|
85
|
+
return () => document.removeEventListener("keydown", onKeyDown);
|
|
86
|
+
}, [open]);
|
|
28
87
|
|
|
29
88
|
if (!isValidElement<TriggerHandlers>(children)) {
|
|
30
89
|
return children;
|
|
@@ -33,13 +92,17 @@ export function Tooltip({ content, children }: TooltipProps) {
|
|
|
33
92
|
return (
|
|
34
93
|
<span
|
|
35
94
|
data-terp="tooltip-anchor"
|
|
36
|
-
onMouseEnter={() =>
|
|
37
|
-
|
|
95
|
+
onMouseEnter={() => {
|
|
96
|
+
cancelClose();
|
|
97
|
+
setOpen(true);
|
|
98
|
+
}}
|
|
99
|
+
onMouseLeave={scheduleClose}
|
|
38
100
|
>
|
|
39
101
|
{cloneElement(children, {
|
|
40
102
|
"aria-describedby": id,
|
|
41
103
|
onFocus: (event: FocusEvent) => {
|
|
42
104
|
children.props.onFocus?.(event);
|
|
105
|
+
cancelClose();
|
|
43
106
|
setOpen(true);
|
|
44
107
|
},
|
|
45
108
|
onBlur: (event: FocusEvent) => {
|
package/src/uiText.tsx
CHANGED
|
@@ -40,6 +40,10 @@ export interface TerpStrings {
|
|
|
40
40
|
email: string;
|
|
41
41
|
/** Login password placeholder. */
|
|
42
42
|
password: string;
|
|
43
|
+
/** Password reveal toggle, while the value is hidden. */
|
|
44
|
+
showPassword: string;
|
|
45
|
+
/** Password reveal toggle, while the value is visible. */
|
|
46
|
+
hidePassword: string;
|
|
43
47
|
/** Login failure message. */
|
|
44
48
|
signInFailed: string;
|
|
45
49
|
/** Label of the dev-only button that fills the seeded development credentials. */
|
|
@@ -86,6 +90,8 @@ export interface TerpStrings {
|
|
|
86
90
|
home: string;
|
|
87
91
|
/** Accessible name of the sidebar `nav` landmark. */
|
|
88
92
|
primaryNavigationLabel: string;
|
|
93
|
+
/** The shell's skip link — the first thing a keyboard reaches, jumping past the chrome. */
|
|
94
|
+
skipToContent: string;
|
|
89
95
|
/** Accessible label of the header toggle when it collapses the expanded sidebar. */
|
|
90
96
|
collapseSidebar: string;
|
|
91
97
|
/** Accessible label of the header toggle when it expands the collapsed sidebar. */
|
|
@@ -215,6 +221,8 @@ export const DEFAULT_STRINGS: TerpStrings = {
|
|
|
215
221
|
signingIn: "Signing in…",
|
|
216
222
|
email: "Email",
|
|
217
223
|
password: "Password",
|
|
224
|
+
showPassword: "Show password",
|
|
225
|
+
hidePassword: "Hide password",
|
|
218
226
|
signInFailed: "Sign-in failed. Check your credentials.",
|
|
219
227
|
fillDevCredentials: "Fill dev credentials",
|
|
220
228
|
continueWith: "Continue with",
|
|
@@ -238,6 +246,7 @@ export const DEFAULT_STRINGS: TerpStrings = {
|
|
|
238
246
|
role: "Role",
|
|
239
247
|
home: "Home",
|
|
240
248
|
primaryNavigationLabel: "Primary",
|
|
249
|
+
skipToContent: "Skip to content",
|
|
241
250
|
collapseSidebar: "Collapse sidebar",
|
|
242
251
|
expandSidebar: "Expand sidebar",
|
|
243
252
|
openNavigation: "Open navigation",
|
package/src/unwrap.test.ts
CHANGED
|
@@ -96,3 +96,135 @@ describe("unwrapOptional", () => {
|
|
|
96
96
|
expect((caught as ApiError).status).toBe(403);
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
// The per-field half of the error envelope.
|
|
101
|
+
//
|
|
102
|
+
// `Field` shipped an `error` prop, a `field-error` marker, an `aria-describedby`, an
|
|
103
|
+
// `aria-invalid` and a style rule for all of it, and nothing in this package could produce the
|
|
104
|
+
// value: every `error=` on a `Field` in all three trees was a test or a specimen. The reason was
|
|
105
|
+
// one function here, which computed each reason's field path and then joined it into a sentence.
|
|
106
|
+
// The information reached the client and was discarded on arrival.
|
|
107
|
+
//
|
|
108
|
+
// So these do not assert that a parser parses. Each one names a way the path can be lost again.
|
|
109
|
+
describe("ApiError.fields", () => {
|
|
110
|
+
function failureOf(error: unknown, status = 422): ApiError {
|
|
111
|
+
try {
|
|
112
|
+
unwrap({ error, response: response(status) });
|
|
113
|
+
} catch (thrown) {
|
|
114
|
+
return thrown as ApiError;
|
|
115
|
+
}
|
|
116
|
+
throw new Error("unwrap returned instead of throwing");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
it("keys a FastAPI 422 by dotted path, with the body/query/path prefix dropped", () => {
|
|
120
|
+
expect(
|
|
121
|
+
failureOf({
|
|
122
|
+
detail: [
|
|
123
|
+
{ loc: ["body", "title"], msg: "String should have at least 1 character" },
|
|
124
|
+
{ loc: ["body", "owner", "email"], msg: "Input should be a valid email" },
|
|
125
|
+
],
|
|
126
|
+
}).fields,
|
|
127
|
+
).toEqual({
|
|
128
|
+
title: "String should have at least 1 character",
|
|
129
|
+
"owner.email": "Input should be a valid email",
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("reads the envelope's own `details`, whose `loc` is already a dotted string", () => {
|
|
134
|
+
// `terp.core.ErrorDetail` documents its shape as "deliberately mirrors FastAPI's own 422
|
|
135
|
+
// detail entries ... so a frontend handles both with one branch", and no such branch existed:
|
|
136
|
+
// `unwrap` named `details` in a doc comment and never read the key. Handling only the array
|
|
137
|
+
// spelling would have kept half of that promise while claiming all of it.
|
|
138
|
+
// Mutation: delete the string arm of `fieldPath` and only this test goes red.
|
|
139
|
+
expect(
|
|
140
|
+
failureOf({
|
|
141
|
+
code: "validation_failed",
|
|
142
|
+
detail: "Two things are wrong.",
|
|
143
|
+
details: [
|
|
144
|
+
{ code: "too_short", loc: "name", msg: "Name is too short." },
|
|
145
|
+
{ code: "not_unique", loc: "contact.email", msg: "Already registered." },
|
|
146
|
+
],
|
|
147
|
+
}).fields,
|
|
148
|
+
).toEqual({ name: "Name is too short.", "contact.email": "Already registered." });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("leaves the message to `detail` when `details` rides beside it", () => {
|
|
152
|
+
// `details` is additive on the wire, so it must be additive here too. If it captured the
|
|
153
|
+
// message slot that `detail` already fills, every existing caller's copy would change on the
|
|
154
|
+
// day a backend first emits a reason list.
|
|
155
|
+
expect(
|
|
156
|
+
failureOf({
|
|
157
|
+
detail: "Two things are wrong.",
|
|
158
|
+
details: [{ code: "too_short", loc: "name", msg: "Name is too short." }],
|
|
159
|
+
}).message,
|
|
160
|
+
).toBe("Two things are wrong.");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("is empty for every failure that names no field", () => {
|
|
164
|
+
expect(failureOf({ code: "permission_denied", detail: "Nope." }, 403).fields).toEqual({});
|
|
165
|
+
expect(failureOf({ code: "conflict" }, 409).fields).toEqual({});
|
|
166
|
+
expect(failureOf(undefined, 500).fields).toEqual({});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("keeps a reason with no `loc` in the message, the only place it can go", () => {
|
|
170
|
+
// A record keyed by field has no slot for a reason about the request as a whole, which
|
|
171
|
+
// `ErrorDetail` documents as a supported case. Dropping it silently would be a regression
|
|
172
|
+
// from the joined string this replaced, so the message keeps carrying it.
|
|
173
|
+
const failure = failureOf({
|
|
174
|
+
detail: [
|
|
175
|
+
{ loc: [], msg: "The window overlaps an existing one." },
|
|
176
|
+
{ loc: ["body", "name"], msg: "Name is too short." },
|
|
177
|
+
],
|
|
178
|
+
});
|
|
179
|
+
expect(failure.fields).toEqual({ name: "Name is too short." });
|
|
180
|
+
expect(failure.message).toBe("The window overlaps an existing one.; name: Name is too short.");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("takes the first reason per field and leaves the rest in the message", () => {
|
|
184
|
+
const failure = failureOf({
|
|
185
|
+
detail: [
|
|
186
|
+
{ loc: ["body", "password"], msg: "Too short." },
|
|
187
|
+
{ loc: ["body", "password"], msg: "Needs a digit." },
|
|
188
|
+
],
|
|
189
|
+
});
|
|
190
|
+
expect(failure.fields).toEqual({ password: "Too short." });
|
|
191
|
+
expect(failure.message).toBe("password: Too short.; password: Needs a digit.");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("drops only a LEADING body/query/path, because elsewhere the same word is a field name", () => {
|
|
195
|
+
// FastAPI puts exactly one of these at the front to say where in the request the value came
|
|
196
|
+
// from. Anywhere else it is a name the caller chose, and a webhook with a `path` field is not
|
|
197
|
+
// a hypothetical. Stripping every occurrence attached the reason to the wrong control, or —
|
|
198
|
+
// for a top-level field called `path` — to no control at all, since the key came out empty.
|
|
199
|
+
expect(failureOf({ detail: [{ loc: ["body", "path"], msg: "Must start with a slash." }] }).fields)
|
|
200
|
+
.toEqual({ path: "Must start with a slash." });
|
|
201
|
+
expect(
|
|
202
|
+
failureOf({ detail: [{ loc: ["body", "config", "body"], msg: "Required." }] }).fields,
|
|
203
|
+
).toEqual({ "config.body": "Required." });
|
|
204
|
+
// A loc that is ONLY the prefix is still about the request as a whole, so it keeps no key.
|
|
205
|
+
const whole = failureOf({ detail: [{ loc: ["body"], msg: "Malformed JSON." }] });
|
|
206
|
+
expect(whole.fields).toEqual({});
|
|
207
|
+
expect(whole.message).toBe("Malformed JSON.");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps a reason whose field is named after something on Object.prototype", () => {
|
|
211
|
+
// The keys are server-supplied. `fields["constructor"]` on a plain object is truthy before
|
|
212
|
+
// anything is written to it, so a first-wins guard spelled `=== undefined` decides the slot is
|
|
213
|
+
// taken and drops the reason with no trace.
|
|
214
|
+
expect(
|
|
215
|
+
failureOf({
|
|
216
|
+
detail: [
|
|
217
|
+
{ loc: ["body", "constructor"], msg: "Unknown builder." },
|
|
218
|
+
{ loc: ["body", "toString"], msg: "Not a template." },
|
|
219
|
+
],
|
|
220
|
+
}).fields,
|
|
221
|
+
).toEqual({ constructor: "Unknown builder.", toString: "Not a template." });
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("is frozen, so one failure's reasons cannot be edited into something the server never said", () => {
|
|
225
|
+
const fields = failureOf({ detail: [{ loc: ["body", "name"], msg: "Too short." }] }).fields;
|
|
226
|
+
expect(() => {
|
|
227
|
+
(fields as Record<string, string>).name = "anything";
|
|
228
|
+
}).toThrow();
|
|
229
|
+
});
|
|
230
|
+
});
|
package/src/unwrap.ts
CHANGED
|
@@ -30,13 +30,36 @@ export class ApiError extends Error {
|
|
|
30
30
|
readonly status: number;
|
|
31
31
|
/** Correlation id from the envelope, for support and log lookup. */
|
|
32
32
|
readonly requestId?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Per-field reasons, keyed by dotted field path (`loc` with FastAPI's `body` / `query` /
|
|
35
|
+
* `path` prefix removed). Empty for every failure that names no field, so
|
|
36
|
+
* `Object.keys(error.fields).length > 0` is the test for "this belongs on the form".
|
|
37
|
+
*
|
|
38
|
+
* What a caller does with it is hand it to `Field`'s `error` prop, which is the whole
|
|
39
|
+
* reason it exists: the framework shipped the rendering half of field-level validation —
|
|
40
|
+
* the marker, the `aria-describedby`, the `aria-invalid`, the styling — and nothing that
|
|
41
|
+
* produces the value. The path was already being computed one function below and joined
|
|
42
|
+
* into a sentence, so the information reached the client and was discarded on arrival.
|
|
43
|
+
*/
|
|
44
|
+
readonly fields: Readonly<Record<string, string>>;
|
|
33
45
|
|
|
34
|
-
constructor(
|
|
46
|
+
constructor(
|
|
47
|
+
message: string,
|
|
48
|
+
options: {
|
|
49
|
+
code?: string;
|
|
50
|
+
status: number;
|
|
51
|
+
requestId?: string;
|
|
52
|
+
fields?: Readonly<Record<string, string>>;
|
|
53
|
+
},
|
|
54
|
+
) {
|
|
35
55
|
super(message);
|
|
36
56
|
this.name = "ApiError";
|
|
37
57
|
this.code = options.code;
|
|
38
58
|
this.status = options.status;
|
|
39
59
|
this.requestId = options.requestId;
|
|
60
|
+
// Defaulted and frozen: every caller reads `.fields` without a presence check, and an
|
|
61
|
+
// error's reasons cannot be edited into something the server never said.
|
|
62
|
+
this.fields = Object.freeze({ ...options.fields });
|
|
40
63
|
}
|
|
41
64
|
}
|
|
42
65
|
|
|
@@ -63,55 +86,118 @@ export function unwrap<T>(result: FetchResult<T>): T {
|
|
|
63
86
|
result.error !== null && typeof result.error === "object"
|
|
64
87
|
? (result.error as { code?: unknown; request_id?: unknown })
|
|
65
88
|
: {};
|
|
66
|
-
|
|
89
|
+
const failure = describeFailure(result.error, result.response);
|
|
90
|
+
throw new ApiError(failure.message, {
|
|
67
91
|
code: typeof envelope.code === "string" ? envelope.code : undefined,
|
|
68
92
|
status: result.response.status,
|
|
69
93
|
requestId: typeof envelope.request_id === "string" ? envelope.request_id : undefined,
|
|
94
|
+
fields: failure.fields,
|
|
70
95
|
});
|
|
71
96
|
}
|
|
72
97
|
return result.data as T;
|
|
73
98
|
}
|
|
74
99
|
|
|
75
|
-
/**
|
|
76
|
-
|
|
100
|
+
/** A failure's message and its per-field reasons — one value, because one walk produces both. */
|
|
101
|
+
interface Failure {
|
|
102
|
+
message: string;
|
|
103
|
+
fields: Record<string, string>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Human-readable message for a failed request — envelope `detail`, else `code`, else the
|
|
108
|
+
* status — paired with whatever per-field reasons came with it.
|
|
109
|
+
*
|
|
110
|
+
* The envelope keeps its reasons in two different places depending on who raised the error,
|
|
111
|
+
* and both are read here. A Terp `AppError` puts a sentence in `detail` and its structured
|
|
112
|
+
* reasons in `details` beside it, so the sentence still wins the message. FastAPI's own 422
|
|
113
|
+
* handler makes `detail` *itself* the list — the app registers no `RequestValidationError`
|
|
114
|
+
* override, so that is the shape a schema rejection actually arrives in.
|
|
115
|
+
*/
|
|
116
|
+
function describeFailure(error: unknown, response: Response): Failure {
|
|
77
117
|
if (error !== null && typeof error === "object") {
|
|
78
|
-
const envelope = error as { detail?: unknown; code?: unknown };
|
|
118
|
+
const envelope = error as { detail?: unknown; details?: unknown; code?: unknown };
|
|
119
|
+
// `details` sits beside `detail` rather than replacing it, so its reasons attach to
|
|
120
|
+
// whichever message wins below instead of competing for the slot.
|
|
121
|
+
const reasons = structuredDetail(envelope.details);
|
|
122
|
+
const fields = reasons?.fields ?? {};
|
|
79
123
|
if (typeof envelope.detail === "string" && envelope.detail.length > 0) {
|
|
80
|
-
return envelope.detail;
|
|
124
|
+
return { message: envelope.detail, fields };
|
|
81
125
|
}
|
|
82
|
-
const
|
|
83
|
-
if (
|
|
84
|
-
return
|
|
126
|
+
const validation = structuredDetail(envelope.detail);
|
|
127
|
+
if (validation !== null) {
|
|
128
|
+
return validation;
|
|
85
129
|
}
|
|
86
130
|
if (typeof envelope.code === "string" && envelope.code.length > 0) {
|
|
87
|
-
return envelope.code;
|
|
131
|
+
return { message: envelope.code, fields };
|
|
88
132
|
}
|
|
89
133
|
}
|
|
90
|
-
return `Request failed (HTTP ${response.status})
|
|
134
|
+
return { message: `Request failed (HTTP ${response.status})`, fields: {} };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The dotted field path a reason addresses, or `""` when it is about the request as a whole.
|
|
139
|
+
*
|
|
140
|
+
* Two spellings arrive and reading only one is how the client stayed half-deaf to its own
|
|
141
|
+
* contract: FastAPI emits `loc` as an array (`["body", "owner", "email"]`), while
|
|
142
|
+
* `terp.core.ErrorDetail` emits it already dotted — a shape whose docstring says it
|
|
143
|
+
* "deliberately mirrors FastAPI's own 422 detail entries ... so a frontend handles both with
|
|
144
|
+
* one branch". Nothing had written that branch. This is it.
|
|
145
|
+
*
|
|
146
|
+
* Only a LEADING `body` / `query` / `path` is dropped. FastAPI puts exactly one of them at the
|
|
147
|
+
* front to say where in the request the value came from; anywhere else the same word is a field
|
|
148
|
+
* the caller named, and `["body", "path"]` is a webhook's `path` field, not a bare request. While
|
|
149
|
+
* this only shaped a sentence the difference was cosmetic. It now chooses which input lights up,
|
|
150
|
+
* so stripping every occurrence would attach a reason to the wrong control — or, for a top-level
|
|
151
|
+
* field called `path`, to no control at all.
|
|
152
|
+
*/
|
|
153
|
+
const LOC_PREFIXES = new Set(["body", "query", "path"]);
|
|
154
|
+
|
|
155
|
+
function fieldPath(loc: unknown): string {
|
|
156
|
+
if (Array.isArray(loc)) {
|
|
157
|
+
const parts = loc.filter(
|
|
158
|
+
(part): part is string | number => typeof part === "string" || typeof part === "number",
|
|
159
|
+
);
|
|
160
|
+
const start = typeof parts[0] === "string" && LOC_PREFIXES.has(parts[0]) ? 1 : 0;
|
|
161
|
+
return parts.slice(start).join(".");
|
|
162
|
+
}
|
|
163
|
+
return typeof loc === "string" ? loc : "";
|
|
91
164
|
}
|
|
92
165
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
166
|
+
/**
|
|
167
|
+
* Flatten common FastAPI/Pydantic validation details into an agent/user-actionable message,
|
|
168
|
+
* keeping each reason's field alongside it.
|
|
169
|
+
*
|
|
170
|
+
* The message is assembled exactly as before, deliberately: it is what every existing caller
|
|
171
|
+
* shows, and `fields` is additive beside it rather than a replacement for it. A reason with
|
|
172
|
+
* no `loc` therefore still reaches the user through the message, which is the only place it
|
|
173
|
+
* can go — a record keyed by field has no slot for a reason about the request as a whole.
|
|
174
|
+
*/
|
|
175
|
+
function structuredDetail(detail: unknown): Failure | null {
|
|
95
176
|
if (!Array.isArray(detail)) {
|
|
96
177
|
return null;
|
|
97
178
|
}
|
|
98
|
-
const messages =
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
179
|
+
const messages: string[] = [];
|
|
180
|
+
const fields: Record<string, string> = {};
|
|
181
|
+
for (const item of detail) {
|
|
182
|
+
if (item === null || typeof item !== "object") {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const reason = item as { loc?: unknown; msg?: unknown };
|
|
186
|
+
if (typeof reason.msg !== "string" || reason.msg.length === 0) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const path = fieldPath(reason.loc);
|
|
190
|
+
messages.push(path.length > 0 ? `${path}: ${reason.msg}` : reason.msg);
|
|
191
|
+
// First reason wins per path. A field that fails two checks shows the one the server
|
|
192
|
+
// reported first and keeps the rest in the message; overwriting would show the last,
|
|
193
|
+
// which is no more correct and reads as arbitrary.
|
|
194
|
+
//
|
|
195
|
+
// `Object.hasOwn`, not `=== undefined`: the keys are server-supplied, and a field called
|
|
196
|
+
// `constructor` or `toString` inherits a truthy value from `Object.prototype`, so the
|
|
197
|
+
// cheaper test would decide the slot was already taken and drop the reason silently.
|
|
198
|
+
if (path.length > 0 && !Object.hasOwn(fields, path)) {
|
|
199
|
+
fields[path] = reason.msg;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return messages.length > 0 ? { message: messages.join("; "), fields } : null;
|
|
117
203
|
}
|