@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.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -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
  }