@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.
Files changed (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. package/src/unwrap.ts +118 -32
@@ -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
  });
@@ -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
- /** Accessible focus/hover tooltip. */
24
- export function Tooltip({ content, children }: TooltipProps) {
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(false);
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={() => setOpen(true)}
37
- onMouseLeave={() => setOpen(false)}
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",
@@ -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(message: string, options: { code?: string; status: number; requestId?: string }) {
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
- throw new ApiError(errorMessage(result.error, result.response), {
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
- /** Human-readable message for a failed request: envelope `detail`, else `code`, else the status. */
76
- function errorMessage(error: unknown, response: Response): string {
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 structured = structuredDetail(envelope.detail);
83
- if (structured !== null) {
84
- return structured;
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
- /** Flatten common FastAPI/Pydantic validation details into an agent/user-actionable message. */
94
- function structuredDetail(detail: unknown): string | null {
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 = detail
99
- .map((item) => {
100
- if (item === null || typeof item !== "object") {
101
- return null;
102
- }
103
- const field = item as { loc?: unknown; msg?: unknown };
104
- if (typeof field.msg !== "string" || field.msg.length === 0) {
105
- return null;
106
- }
107
- const loc = Array.isArray(field.loc)
108
- ? field.loc.filter((part) => typeof part === "string" || typeof part === "number")
109
- : [];
110
- const path = loc
111
- .filter((part) => part !== "body" && part !== "query" && part !== "path")
112
- .join(".");
113
- return path.length > 0 ? `${path}: ${field.msg}` : field.msg;
114
- })
115
- .filter((message): message is string => message !== null);
116
- return messages.length > 0 ? messages.join("; ") : null;
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
  }