@elabs-ai/components-ui 4.0.0 → 4.1.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 (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,390 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ fromJsonSchema,
4
+ jsonSchemaRequestBody,
5
+ UnsupportedJsonSchemaError,
6
+ } from "./from-json-schema";
7
+ import type {
8
+ BooleanFieldSpec,
9
+ EnumFieldSpec,
10
+ GroupFieldSpec,
11
+ IntegerFieldSpec,
12
+ ListFieldSpec,
13
+ MultiEnumFieldSpec,
14
+ NumberFieldSpec,
15
+ StringFieldSpec,
16
+ } from "./schema-form-spec";
17
+
18
+ describe("fromJsonSchema — documented subset", () => {
19
+ it("maps a string property, carrying title/description/required/format/length/pattern/default", () => {
20
+ const form = fromJsonSchema(
21
+ {
22
+ type: "object",
23
+ properties: {
24
+ name: {
25
+ type: "string",
26
+ title: "Full name",
27
+ description: "As it appears on your ID",
28
+ minLength: 1,
29
+ maxLength: 80,
30
+ pattern: "^[A-Za-z ]+$",
31
+ default: "Ada Lovelace",
32
+ },
33
+ email: { type: "string", format: "email" },
34
+ weird: { type: "string", format: "binary" },
35
+ },
36
+ required: ["name"],
37
+ },
38
+ { formName: "profile" },
39
+ );
40
+
41
+ expect(form.formName).toBe("profile");
42
+ expect(form.fields).toHaveLength(3);
43
+
44
+ const name = form.fields[0] as StringFieldSpec;
45
+ expect(name).toMatchObject({
46
+ type: "string",
47
+ name: "name",
48
+ label: "Full name",
49
+ description: "As it appears on your ID",
50
+ required: true,
51
+ minLength: 1,
52
+ maxLength: 80,
53
+ pattern: "^[A-Za-z ]+$",
54
+ default: "Ada Lovelace",
55
+ });
56
+
57
+ const email = form.fields[1] as StringFieldSpec;
58
+ expect(email.format).toBe("email");
59
+ expect(email.required).toBeUndefined();
60
+
61
+ // An unsupported `format` value is ignored predictably, not passed through.
62
+ const weird = form.fields[2] as StringFieldSpec;
63
+ expect(weird.format).toBeUndefined();
64
+ });
65
+
66
+ it("maps a string enum to an EnumFieldSpec", () => {
67
+ const form = fromJsonSchema(
68
+ {
69
+ type: "object",
70
+ properties: {
71
+ plan: { type: "string", enum: ["free", "pro", "enterprise"], default: "free" },
72
+ },
73
+ },
74
+ { formName: "f" },
75
+ );
76
+ const plan = form.fields[0] as EnumFieldSpec;
77
+ expect(plan.type).toBe("enum");
78
+ expect(plan.options).toEqual(["free", "pro", "enterprise"]);
79
+ expect(plan.default).toBe("free");
80
+ });
81
+
82
+ it("maps number and integer properties independently, each with its own literal `type`", () => {
83
+ const form = fromJsonSchema(
84
+ {
85
+ type: "object",
86
+ properties: {
87
+ rate: { type: "number", minimum: 0, maximum: 1, default: 0.5 },
88
+ retries: { type: "integer", minimum: 0, maximum: 10, default: 3 },
89
+ // A non-integer default on an integer property is dropped, not coerced.
90
+ badRetries: { type: "integer", default: 1.5 },
91
+ },
92
+ },
93
+ { formName: "f" },
94
+ );
95
+ const rate = form.fields[0] as NumberFieldSpec;
96
+ expect(rate).toMatchObject({ type: "number", min: 0, max: 1, default: 0.5 });
97
+
98
+ const retries = form.fields[1] as IntegerFieldSpec;
99
+ expect(retries).toMatchObject({ type: "integer", min: 0, max: 10, default: 3 });
100
+
101
+ const badRetries = form.fields[2] as IntegerFieldSpec;
102
+ expect(badRetries.type).toBe("integer");
103
+ expect(badRetries.default).toBeUndefined();
104
+ });
105
+
106
+ it("maps a boolean property", () => {
107
+ const form = fromJsonSchema(
108
+ { type: "object", properties: { enabled: { type: "boolean", default: true } } },
109
+ { formName: "f" },
110
+ );
111
+ const enabled = form.fields[0] as BooleanFieldSpec;
112
+ expect(enabled).toMatchObject({ type: "boolean", default: true });
113
+ });
114
+
115
+ it("maps an array-of-string property to a ListFieldSpec, and drops a non-string-item array", () => {
116
+ const form = fromJsonSchema(
117
+ {
118
+ type: "object",
119
+ properties: {
120
+ tags: { type: "array", items: { type: "string" }, minItems: 1, maxItems: 5 },
121
+ scores: { type: "array", items: { type: "number" } },
122
+ },
123
+ },
124
+ { formName: "f" },
125
+ );
126
+ expect(form.fields).toHaveLength(1);
127
+ const tags = form.fields[0] as ListFieldSpec;
128
+ expect(tags).toMatchObject({ type: "list", minItems: 1, maxItems: 5 });
129
+ });
130
+
131
+ // PR #119 review thread 1 (chatgpt-codex-connector): a free-text array
132
+ // property's `default` was silently dropped when mapped to a
133
+ // `ListFieldSpec`, unlike its `multi-enum` sibling three lines up —
134
+ // `initialFormValues` would then seed the field to `[]` even though the
135
+ // source schema declared existing values.
136
+ it("preserves a valid string[] default on a free-text ListFieldSpec", () => {
137
+ const form = fromJsonSchema(
138
+ {
139
+ type: "object",
140
+ properties: {
141
+ tags: {
142
+ type: "array",
143
+ items: { type: "string" },
144
+ default: ["existing", "values"],
145
+ },
146
+ },
147
+ },
148
+ { formName: "f" },
149
+ );
150
+ const tags = form.fields[0] as ListFieldSpec;
151
+ expect(tags).toMatchObject({ type: "list", default: ["existing", "values"] });
152
+ });
153
+
154
+ it("drops a non-string-array default on a free-text ListFieldSpec", () => {
155
+ const form = fromJsonSchema(
156
+ {
157
+ type: "object",
158
+ properties: {
159
+ tags: {
160
+ type: "array",
161
+ items: { type: "string" },
162
+ default: ["ok", 42],
163
+ },
164
+ },
165
+ },
166
+ { formName: "f" },
167
+ );
168
+ const tags = form.fields[0] as ListFieldSpec;
169
+ expect(tags.type).toBe("list");
170
+ expect(tags.default).toBeUndefined();
171
+ });
172
+
173
+ // fix-round-1 (issue #22): a validator probe found that a CONSTRAINED array
174
+ // item schema (`items.enum`) was silently widening into a free-text
175
+ // `ListFieldSpec` — the source schema only allows two values, but the
176
+ // rendered form accepted anything. `items.enum` must map onto the
177
+ // FieldSpec that actually enforces the constraint (`multi-enum`), not the
178
+ // one that drops it.
179
+ it("maps an array-of-CONSTRAINED-string property (items.enum) to a MultiEnumFieldSpec, not a free-text list", () => {
180
+ const form = fromJsonSchema(
181
+ {
182
+ type: "object",
183
+ properties: {
184
+ scopes: {
185
+ type: "array",
186
+ items: { type: "string", enum: ["read", "write", "admin"] },
187
+ default: ["read"],
188
+ },
189
+ },
190
+ },
191
+ { formName: "f" },
192
+ );
193
+ expect(form.fields).toHaveLength(1);
194
+ const scopes = form.fields[0] as MultiEnumFieldSpec;
195
+ expect(scopes).toMatchObject({
196
+ type: "multi-enum",
197
+ options: ["read", "write", "admin"],
198
+ default: ["read"],
199
+ });
200
+ });
201
+
202
+ it("drops an out-of-range default rather than passing it through when mapping items.enum", () => {
203
+ const form = fromJsonSchema(
204
+ {
205
+ type: "object",
206
+ properties: {
207
+ scopes: {
208
+ type: "array",
209
+ items: { type: "string", enum: ["read", "write"] },
210
+ default: ["read", "not-an-option"],
211
+ },
212
+ },
213
+ },
214
+ { formName: "f" },
215
+ );
216
+ const scopes = form.fields[0] as MultiEnumFieldSpec;
217
+ expect(scopes.type).toBe("multi-enum");
218
+ expect(scopes.default).toBeUndefined();
219
+ });
220
+
221
+ it("maps a nested object property to a single-branch advanced GroupFieldSpec", () => {
222
+ const form = fromJsonSchema(
223
+ {
224
+ type: "object",
225
+ properties: {
226
+ address: {
227
+ type: "object",
228
+ title: "Address",
229
+ properties: {
230
+ city: { type: "string" },
231
+ zip: { type: "string" },
232
+ },
233
+ required: ["city"],
234
+ },
235
+ },
236
+ },
237
+ { formName: "f" },
238
+ );
239
+ const address = form.fields[0] as GroupFieldSpec;
240
+ expect(address.type).toBe("group");
241
+ expect(address.variant).toBe("advanced");
242
+ expect(address.groups).toHaveLength(1);
243
+ expect(address.groups[0]?.fields.map((f) => f.name)).toEqual(["city", "zip"]);
244
+ expect(address.groups[0]?.fields[0]).toMatchObject({ name: "city", required: true });
245
+ });
246
+
247
+ // PR #119 review thread 3 (chatgpt-codex-connector, P1): SchemaForm keeps
248
+ // every field's value in one FLAT map, so a naively-collected payload for
249
+ // this same nested schema is `{ city: "...", zip: "..." }`, not the
250
+ // advertised `{ address: { city: "...", zip: "..." } }`.
251
+ // `jsonSchemaRequestBody` reconstructs the nested shape from the mapped
252
+ // `FormSpec` plus the flat values.
253
+ it("jsonSchemaRequestBody reconstructs the nested object shape a flat FormValues map loses", () => {
254
+ const form = fromJsonSchema(
255
+ {
256
+ type: "object",
257
+ properties: {
258
+ name: { type: "string" },
259
+ address: {
260
+ type: "object",
261
+ title: "Address",
262
+ properties: {
263
+ city: { type: "string" },
264
+ zip: { type: "string" },
265
+ },
266
+ required: ["city"],
267
+ },
268
+ },
269
+ },
270
+ { formName: "f" },
271
+ );
272
+ // The flat values a `SchemaFormProvider` actually stores — one entry per
273
+ // BARE field name, exactly as `initialFormValues`/`onChange` populate it.
274
+ const flatValues = { name: "Ada", city: "Springfield", zip: "00000" };
275
+ expect(jsonSchemaRequestBody(form, flatValues)).toEqual({
276
+ name: "Ada",
277
+ address: { city: "Springfield", zip: "00000" },
278
+ });
279
+ });
280
+
281
+ it("jsonSchemaRequestBody omits a field with no value rather than inventing undefined/null", () => {
282
+ const form = fromJsonSchema(
283
+ {
284
+ type: "object",
285
+ properties: {
286
+ name: { type: "string" },
287
+ address: {
288
+ type: "object",
289
+ properties: { city: { type: "string" } },
290
+ },
291
+ },
292
+ },
293
+ { formName: "f" },
294
+ );
295
+ expect(jsonSchemaRequestBody(form, { name: "Ada" })).toEqual({
296
+ name: "Ada",
297
+ address: {},
298
+ });
299
+ });
300
+
301
+ it("drops a property whose type/shape falls outside the subset instead of throwing", () => {
302
+ const form = fromJsonSchema(
303
+ {
304
+ type: "object",
305
+ properties: {
306
+ kept: { type: "string" },
307
+ nullish: { type: "null" },
308
+ untyped: { description: "no type at all" },
309
+ emptyObject: { type: "object" },
310
+ },
311
+ },
312
+ { formName: "f" },
313
+ );
314
+ expect(form.fields.map((f) => f.name)).toEqual(["kept"]);
315
+ });
316
+
317
+ it("honors title/description/submitLabel overrides and falls back to the schema's own", () => {
318
+ const form = fromJsonSchema(
319
+ { type: "object", title: "Schema title", description: "Schema description", properties: {} },
320
+ { formName: "f" },
321
+ );
322
+ expect(form.title).toBe("Schema title");
323
+ expect(form.description).toBe("Schema description");
324
+
325
+ const overridden = fromJsonSchema(
326
+ { type: "object", title: "Schema title", properties: {} },
327
+ { formName: "f", title: "Override title", submitLabel: "Save" },
328
+ );
329
+ expect(overridden.title).toBe("Override title");
330
+ expect(overridden.submitLabel).toBe("Save");
331
+ });
332
+ });
333
+
334
+ describe("fromJsonSchema — refuses combinators rather than approximating them", () => {
335
+ it.each(["$ref", "allOf", "oneOf", "anyOf", "not"] as const)(
336
+ "throws UnsupportedJsonSchemaError for a top-level %s",
337
+ (keyword) => {
338
+ expect(() =>
339
+ fromJsonSchema({ type: "object", properties: {}, [keyword]: {} }, { formName: "f" }),
340
+ ).toThrow(UnsupportedJsonSchemaError);
341
+ },
342
+ );
343
+
344
+ it("throws for a combinator nested inside a property schema", () => {
345
+ expect(() =>
346
+ fromJsonSchema(
347
+ { type: "object", properties: { x: { $ref: "#/definitions/X" } } },
348
+ { formName: "f" },
349
+ ),
350
+ ).toThrow(/\$ref/);
351
+ });
352
+
353
+ it("throws for a combinator nested inside an array's items schema", () => {
354
+ expect(() =>
355
+ fromJsonSchema(
356
+ { type: "object", properties: { x: { type: "array", items: { oneOf: [] } } } },
357
+ { formName: "f" },
358
+ ),
359
+ ).toThrow(/oneOf/);
360
+ });
361
+
362
+ it("throws for a combinator nested inside a nested object's properties", () => {
363
+ expect(() =>
364
+ fromJsonSchema(
365
+ {
366
+ type: "object",
367
+ properties: { addr: { type: "object", properties: { city: { allOf: [] } } } },
368
+ },
369
+ { formName: "f" },
370
+ ),
371
+ ).toThrow(/allOf/);
372
+ });
373
+
374
+ it("throws when the top-level schema is not an object schema", () => {
375
+ expect(() => fromJsonSchema({ type: "string" }, { formName: "f" })).toThrow(
376
+ UnsupportedJsonSchemaError,
377
+ );
378
+ });
379
+
380
+ it("the error message names the keyword and a JSON-Pointer-style path", () => {
381
+ try {
382
+ fromJsonSchema({ type: "object", properties: { x: { $ref: "#/X" } } }, { formName: "f" });
383
+ throw new Error("expected fromJsonSchema to throw");
384
+ } catch (err) {
385
+ expect(err).toBeInstanceOf(UnsupportedJsonSchemaError);
386
+ expect((err as Error).message).toContain("$ref");
387
+ expect((err as Error).message).toContain("#/properties/x");
388
+ }
389
+ });
390
+ });