@elabs-ai/components-ui 4.0.0 → 4.2.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.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 +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,1021 @@
1
+ import { Profiler, type ProfilerOnRenderCallback } from "react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
4
+ import userEvent from "@testing-library/user-event";
5
+ import {
6
+ SchemaForm,
7
+ SchemaFormField,
8
+ SchemaFormFields,
9
+ SchemaFormProvider,
10
+ SchemaFormRoot,
11
+ SchemaFormSubmit,
12
+ SchemaFormTestAction,
13
+ } from "./schema-form";
14
+ import {
15
+ fieldSpecSchema,
16
+ fileMatchesAccept,
17
+ findFieldByName,
18
+ formSpecSchema,
19
+ normalizeFormSpec,
20
+ validateField,
21
+ type FormSpec,
22
+ } from "./schema-form-spec";
23
+
24
+ const simpleSpec: FormSpec = {
25
+ formName: "signup",
26
+ title: "Sign up",
27
+ fields: [
28
+ { type: "string", name: "name", label: "Name", required: true },
29
+ { type: "string", name: "email", label: "Email", format: "email", required: true },
30
+ ],
31
+ };
32
+
33
+ const groupSpec: FormSpec = {
34
+ formName: "connector",
35
+ title: "Connector",
36
+ fields: [
37
+ {
38
+ type: "group",
39
+ name: "auth",
40
+ label: "Authentication",
41
+ variant: "tabs",
42
+ groups: [
43
+ {
44
+ key: "apiKey",
45
+ label: "API key",
46
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
47
+ },
48
+ {
49
+ key: "oauth",
50
+ label: "OAuth",
51
+ fields: [{ type: "string", name: "clientId", label: "Client ID", required: true }],
52
+ },
53
+ ],
54
+ },
55
+ ],
56
+ };
57
+
58
+ // A controlling enum field plus a target field that's only visible (and only
59
+ // validated) once the controlling field equals "oauth" — issue #22's
60
+ // `visibleWhen: { field, equals }` conditional-visibility ask.
61
+ const conditionalSpec: FormSpec = {
62
+ formName: "connector_auth",
63
+ fields: [
64
+ {
65
+ type: "enum",
66
+ name: "authMethod",
67
+ label: "Auth method",
68
+ options: ["oauth", "apikey"],
69
+ },
70
+ {
71
+ type: "string",
72
+ name: "clientSecret",
73
+ label: "Client secret",
74
+ required: true,
75
+ visibleWhen: { field: "authMethod", equals: "oauth" },
76
+ },
77
+ ],
78
+ };
79
+
80
+ // Same shape, but the controller carries a spec `default` — a controlled
81
+ // consumer may omit it from its `values` object entirely and still expect
82
+ // the default to be what's actually in force (PR #81 review finding).
83
+ const defaultedConditionalSpec: FormSpec = {
84
+ formName: "defaulted_auth",
85
+ fields: [
86
+ {
87
+ type: "enum",
88
+ name: "authMethod",
89
+ label: "Auth method",
90
+ options: ["oauth", "apikey"],
91
+ default: "oauth",
92
+ },
93
+ {
94
+ type: "string",
95
+ name: "clientSecret",
96
+ label: "Client secret",
97
+ required: true,
98
+ visibleWhen: { field: "authMethod", equals: "oauth" },
99
+ },
100
+ ],
101
+ };
102
+
103
+ /** `SchemaFormProvider.spec` is a `NormalizedFormSpec` — normalize a plain `FormSpec` fixture once, up front, so tests can pass it directly. */
104
+ function normalizeSpecOrThrow(spec: FormSpec) {
105
+ const result = normalizeFormSpec(spec);
106
+ if (!result.ok) throw new Error(`expected spec to normalize: ${result.reason}`);
107
+ return result.spec;
108
+ }
109
+
110
+ describe("SchemaForm — rendering", () => {
111
+ it("renders the title, fields, and a submit button", () => {
112
+ render(<SchemaForm spec={simpleSpec} />);
113
+ expect(screen.getByText("Sign up")).toBeInTheDocument();
114
+ expect(screen.getByLabelText(/Name/)).toBeInTheDocument();
115
+ expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
116
+ expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
117
+ });
118
+
119
+ it("labels the form via aria-labelledby pointing at the title", () => {
120
+ render(<SchemaForm spec={simpleSpec} />);
121
+ expect(screen.getByRole("form", { name: "Sign up" })).toBeInTheDocument();
122
+ });
123
+
124
+ it("uses the humanized name when no label is given", () => {
125
+ render(
126
+ <SchemaForm spec={{ formName: "f", fields: [{ type: "string", name: "first_name" }] }} />,
127
+ );
128
+ expect(screen.getByLabelText("First name")).toBeInTheDocument();
129
+ });
130
+
131
+ it("renders a list field's label without a native htmlFor association (it's a multi-row region)", () => {
132
+ render(
133
+ <SchemaForm
134
+ spec={{
135
+ formName: "f",
136
+ fields: [{ type: "list", name: "domains", label: "Allowed domains" }],
137
+ }}
138
+ />,
139
+ );
140
+ const label = screen.getByText("Allowed domains");
141
+ expect(label).toBeInTheDocument();
142
+ expect(label).not.toHaveAttribute("for");
143
+ expect(screen.getByRole("button", { name: /add item/i })).toBeInTheDocument();
144
+ });
145
+
146
+ it("renders a key-value field", () => {
147
+ render(
148
+ <SchemaForm
149
+ spec={{ formName: "f", fields: [{ type: "key-value", name: "headers", label: "Headers" }] }}
150
+ />,
151
+ );
152
+ expect(screen.getByText("Headers")).toBeInTheDocument();
153
+ });
154
+
155
+ it("renders a file field", () => {
156
+ render(
157
+ <SchemaForm
158
+ spec={{ formName: "f", fields: [{ type: "file", name: "creds", label: "Credentials" }] }}
159
+ />,
160
+ );
161
+ expect(screen.getByText("Credentials")).toBeInTheDocument();
162
+ });
163
+
164
+ it("renders a group field's tabs and only the active branch's fields", () => {
165
+ render(<SchemaForm spec={groupSpec} />);
166
+ expect(screen.getByRole("tab", { name: "API key" })).toBeInTheDocument();
167
+ expect(screen.getByRole("tab", { name: "OAuth" })).toBeInTheDocument();
168
+ // The active branch's own field ("API key") is present; the role narrows
169
+ // past the tab trigger, which shares the same visible text.
170
+ expect(screen.getByRole("textbox", { name: /API key/ })).toBeInTheDocument();
171
+ });
172
+
173
+ it("renders a group field's advanced branches as disclosures", () => {
174
+ render(
175
+ <SchemaForm
176
+ spec={{
177
+ formName: "f",
178
+ fields: [
179
+ {
180
+ type: "group",
181
+ name: "advanced",
182
+ label: "Advanced",
183
+ variant: "advanced",
184
+ groups: [
185
+ {
186
+ key: "retry",
187
+ label: "Retry policy",
188
+ fields: [{ type: "integer", name: "maxRetries", label: "Max retries" }],
189
+ },
190
+ ],
191
+ },
192
+ ],
193
+ }}
194
+ />,
195
+ );
196
+ expect(screen.getByText("Retry policy")).toBeInTheDocument();
197
+ });
198
+ });
199
+
200
+ describe("SchemaForm — never throws / fallback", () => {
201
+ it("renders a fallback for a non-object spec (does not throw)", () => {
202
+ render(<SchemaForm spec={"nope" as unknown} />);
203
+ expect(screen.getByText(/could not|missing or malformed/i)).toBeInTheDocument();
204
+ });
205
+
206
+ it("renders a fallback for a valid-but-empty spec when not loading", () => {
207
+ render(<SchemaForm spec={{ formName: "x", fields: [] }} />);
208
+ expect(screen.getByText(/no fields/i)).toBeInTheDocument();
209
+ });
210
+
211
+ it("renders a skeleton (not a fallback) for an empty spec while loading", () => {
212
+ const { container } = render(<SchemaForm spec={{ formName: "x", fields: [] }} loading />);
213
+ expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
214
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
215
+ });
216
+ });
217
+
218
+ describe("SchemaForm — validation (submit) + focus-first-invalid", () => {
219
+ it("blocks submit, shows inline errors, and focuses the first invalid field", async () => {
220
+ const user = userEvent.setup();
221
+ const onSubmit = vi.fn();
222
+ render(<SchemaForm spec={simpleSpec} onSubmit={onSubmit} />);
223
+ await user.click(screen.getByRole("button", { name: "Submit" }));
224
+ expect(onSubmit).not.toHaveBeenCalled();
225
+ expect(screen.getAllByText("This field is required.").length).toBeGreaterThan(0);
226
+ expect(screen.getByLabelText(/Name/)).toHaveFocus();
227
+ });
228
+
229
+ it("submits { formName, values } when valid", async () => {
230
+ const user = userEvent.setup();
231
+ const onSubmit = vi.fn();
232
+ render(<SchemaForm spec={simpleSpec} onSubmit={onSubmit} />);
233
+ await user.type(screen.getByLabelText(/Name/), "Dana");
234
+ await user.type(screen.getByLabelText(/Email/), "dana@acme.com");
235
+ await user.click(screen.getByRole("button", { name: "Submit" }));
236
+ expect(onSubmit).toHaveBeenCalledWith({
237
+ formName: "signup",
238
+ values: expect.objectContaining({ name: "Dana", email: "dana@acme.com" }),
239
+ });
240
+ });
241
+
242
+ it("only validates the ACTIVE tab branch for a `variant: tabs` group", async () => {
243
+ const user = userEvent.setup();
244
+ const onSubmit = vi.fn();
245
+ render(<SchemaForm spec={groupSpec} onSubmit={onSubmit} />);
246
+ // The "apiKey" branch is active by default; its field is required and empty.
247
+ await user.click(screen.getByRole("button", { name: "Submit" }));
248
+ expect(onSubmit).not.toHaveBeenCalled();
249
+ // Switching to OAuth and filling ITS field should let submit succeed —
250
+ // the API-key branch's now-inactive required field must not block it.
251
+ await user.click(screen.getByRole("tab", { name: "OAuth" }));
252
+ await user.type(screen.getByLabelText(/Client ID/), "abc123");
253
+ await user.click(screen.getByRole("button", { name: "Submit" }));
254
+ expect(onSubmit).toHaveBeenCalledWith({
255
+ formName: "connector",
256
+ values: expect.objectContaining({ auth: "oauth", clientId: "abc123" }),
257
+ });
258
+ });
259
+
260
+ it("strips the INACTIVE tab branch's fields from the submitted values, not just its validation", async () => {
261
+ const user = userEvent.setup();
262
+ const onSubmit = vi.fn();
263
+ render(<SchemaForm spec={groupSpec} onSubmit={onSubmit} />);
264
+ // Type into the API-key branch first, then switch away from it. The role
265
+ // narrows past the "API key" tab trigger, which shares the visible text.
266
+ await user.type(screen.getByRole("textbox", { name: /API key/ }), "secret-key");
267
+ await user.click(screen.getByRole("tab", { name: "OAuth" }));
268
+ await user.type(screen.getByLabelText(/Client ID/), "abc123");
269
+ await user.click(screen.getByRole("button", { name: "Submit" }));
270
+ // Exact match (not objectContaining): a stray "apiKey" key left over from
271
+ // the now-inactive branch would fail this.
272
+ expect(onSubmit).toHaveBeenCalledWith({
273
+ formName: "connector",
274
+ values: { auth: "oauth", clientId: "abc123" },
275
+ });
276
+ });
277
+
278
+ it("hides a `visibleWhen` field while its controlling value is unset, and never blocks submit on it while hidden", async () => {
279
+ const onSubmit = vi.fn();
280
+ render(<SchemaForm spec={conditionalSpec} values={{}} onSubmit={onSubmit} />);
281
+ // Unset controlling value → the target field isn't rendered at all.
282
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
283
+
284
+ // Submitting must not report a validation error for the hidden required
285
+ // field — it must not even reach `validateForm`/the focus walk.
286
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
287
+ expect(onSubmit).toHaveBeenCalledWith({
288
+ formName: "connector_auth",
289
+ values: expect.objectContaining({ authMethod: undefined }),
290
+ });
291
+ });
292
+
293
+ it("reveals a `visibleWhen` field once its controlling field matches, and blocks submit on it once visible+required", async () => {
294
+ const onSubmit = vi.fn();
295
+ const { rerender } = render(
296
+ <SchemaForm spec={conditionalSpec} values={{ authMethod: "apikey" }} onSubmit={onSubmit} />,
297
+ );
298
+ // Still hidden for the OTHER enum option ("apikey" ≠ "oauth").
299
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
300
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
301
+ expect(onSubmit).toHaveBeenCalledWith({
302
+ formName: "connector_auth",
303
+ values: expect.objectContaining({ authMethod: "apikey" }),
304
+ });
305
+
306
+ // Switching the controlling field to "oauth" reveals it — and now it DOES
307
+ // block submit while empty (it's `required`).
308
+ onSubmit.mockClear();
309
+ rerender(
310
+ <SchemaForm spec={conditionalSpec} values={{ authMethod: "oauth" }} onSubmit={onSubmit} />,
311
+ );
312
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
313
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
314
+ expect(onSubmit).not.toHaveBeenCalled();
315
+ await waitFor(() => {
316
+ expect(screen.getByLabelText(/Client secret/)).toHaveFocus();
317
+ });
318
+ });
319
+
320
+ it("strips a hidden `visibleWhen` field's stale value from the submitted payload, even if it was set before the field became hidden", async () => {
321
+ const onSubmit = vi.fn();
322
+ const { rerender } = render(
323
+ <SchemaForm
324
+ spec={conditionalSpec}
325
+ values={{ authMethod: "oauth", clientSecret: "sekret" }}
326
+ onSubmit={onSubmit}
327
+ />,
328
+ );
329
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
330
+
331
+ // The controller flips away from "oauth" — clientSecret is now hidden,
332
+ // but a controlled consumer's `values` object still carries the
333
+ // previously-typed value (nothing clears it automatically).
334
+ rerender(
335
+ <SchemaForm
336
+ spec={conditionalSpec}
337
+ values={{ authMethod: "apikey", clientSecret: "sekret" }}
338
+ onSubmit={onSubmit}
339
+ />,
340
+ );
341
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
342
+
343
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
344
+ // Exact match (not objectContaining): a leaked "clientSecret" key from
345
+ // before the field was hidden would fail this.
346
+ expect(onSubmit).toHaveBeenCalledWith({
347
+ formName: "connector_auth",
348
+ values: { authMethod: "apikey" },
349
+ });
350
+ });
351
+
352
+ it("resolves visibility against the controller's EFFECTIVE (default-applied) value, not a raw value a controlled consumer may have omitted", async () => {
353
+ const onSubmit = vi.fn();
354
+ // The controlled `values` object omits `authMethod` entirely — the
355
+ // spec's own default ("oauth") is what actually renders and submits
356
+ // (via `effectiveValue`), so the OAuth-only field must be visible too,
357
+ // not hidden because the RAW value happens to be `undefined`.
358
+ render(<SchemaForm spec={defaultedConditionalSpec} values={{}} onSubmit={onSubmit} />);
359
+ expect(screen.getByLabelText(/Client secret/)).toBeInTheDocument();
360
+
361
+ // It's required and empty, so submit must block on it with a VISIBLE,
362
+ // focusable control to fix — not silently drop it as "hidden" while
363
+ // validation (which resolves the same default) still requires it.
364
+ await userEvent.click(screen.getByRole("button", { name: "Submit" }));
365
+ expect(onSubmit).not.toHaveBeenCalled();
366
+ await waitFor(() => {
367
+ expect(screen.getByLabelText(/Client secret/)).toHaveFocus();
368
+ });
369
+ });
370
+
371
+ it("SchemaFormField enforces its own visibleWhen even when placed directly in a custom layout, bypassing SchemaFormFields' filter", () => {
372
+ const result = normalizeFormSpec(conditionalSpec);
373
+ if (!result.ok) throw new Error("expected conditionalSpec to normalize");
374
+ render(
375
+ <SchemaFormProvider spec={result.spec} values={{ authMethod: "apikey" }}>
376
+ <SchemaFormField name="clientSecret" />
377
+ </SchemaFormProvider>,
378
+ );
379
+ expect(screen.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
380
+ });
381
+
382
+ it("blocks submit and shows an inline error for a file that fails validation (wrong type)", async () => {
383
+ const user = userEvent.setup();
384
+ const onSubmit = vi.fn();
385
+ const badFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
386
+ render(
387
+ <SchemaForm
388
+ spec={{
389
+ formName: "f",
390
+ fields: [{ type: "file", name: "creds", label: "Credentials", accept: ".json" }],
391
+ }}
392
+ values={{ creds: [badFile] }}
393
+ onSubmit={onSubmit}
394
+ />,
395
+ );
396
+ await user.click(screen.getByRole("button", { name: "Submit" }));
397
+ expect(onSubmit).not.toHaveBeenCalled();
398
+ expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
399
+ });
400
+
401
+ it("keeps a DROPPED wrong-type file visible with its inline error instead of silently dropping it", async () => {
402
+ render(
403
+ <SchemaForm
404
+ spec={{
405
+ formName: "f",
406
+ fields: [{ type: "file", name: "creds", label: "Credentials", accept: ".json" }],
407
+ }}
408
+ />,
409
+ );
410
+ const dropzone = document.querySelector("[data-slot='file-upload-dropzone']")!;
411
+ const badFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
412
+ fireEvent.drop(dropzone, { dataTransfer: { files: [badFile] } });
413
+ expect(await screen.findByText("creds.exe")).toBeInTheDocument();
414
+ expect(screen.getAllByText("This file type isn't accepted.").length).toBeGreaterThan(0);
415
+ });
416
+
417
+ it("auto-reveals a collapsed advanced-group branch that holds the first invalid field, and focuses it", async () => {
418
+ const user = userEvent.setup();
419
+ const onSubmit = vi.fn();
420
+ render(
421
+ <SchemaForm
422
+ spec={{
423
+ formName: "f",
424
+ fields: [
425
+ {
426
+ type: "group",
427
+ name: "advanced",
428
+ label: "Advanced",
429
+ variant: "advanced",
430
+ groups: [
431
+ {
432
+ key: "retry",
433
+ label: "Retry policy",
434
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
435
+ },
436
+ ],
437
+ },
438
+ ],
439
+ }}
440
+ onSubmit={onSubmit}
441
+ />,
442
+ );
443
+ // Collapsed by default — the field isn't reachable yet.
444
+ expect(screen.queryByLabelText(/API key/)).not.toBeInTheDocument();
445
+
446
+ await user.click(screen.getByRole("button", { name: "Submit" }));
447
+
448
+ expect(onSubmit).not.toHaveBeenCalled();
449
+ await waitFor(() => {
450
+ expect(screen.getByLabelText(/API key/)).toHaveFocus();
451
+ });
452
+ });
453
+ });
454
+
455
+ describe("SchemaForm — submit control a11y (never natively disabled while submitting)", () => {
456
+ it("keeps the submit button focusable and in the tab order while submitting", () => {
457
+ render(<SchemaForm spec={simpleSpec} submitting />);
458
+ const button = screen.getByRole("button", { name: "Submitting…" });
459
+ expect(button).not.toBeDisabled();
460
+ expect(button).toHaveAttribute("aria-disabled", "true");
461
+ });
462
+
463
+ it("ignores a click while submitting (handler guard backs the aria-disabled affordance)", async () => {
464
+ const user = userEvent.setup();
465
+ const onSubmit = vi.fn();
466
+ render(<SchemaForm spec={simpleSpec} submitting onSubmit={onSubmit} />);
467
+ await user.click(screen.getByRole("button", { name: "Submitting…" }));
468
+ expect(onSubmit).not.toHaveBeenCalled();
469
+ });
470
+
471
+ it("natively disables every control for an explicit, durable disabled form", () => {
472
+ render(<SchemaForm spec={simpleSpec} disabled />);
473
+ expect(screen.getByLabelText(/Name/)).toBeDisabled();
474
+ expect(screen.getByRole("button", { name: "Submit" })).toBeDisabled();
475
+ });
476
+ });
477
+
478
+ describe("SchemaForm — spec swap (uncontrolled values)", () => {
479
+ it("resets uncontrolled internal values when the spec's formName changes", async () => {
480
+ const user = userEvent.setup();
481
+ const { rerender } = render(
482
+ <SchemaForm spec={{ formName: "a", fields: [{ type: "string", name: "x", label: "X" }] }} />,
483
+ );
484
+ await user.type(screen.getByLabelText("X"), "hello");
485
+ expect(screen.getByLabelText("X")).toHaveValue("hello");
486
+
487
+ // A different spec (new formName) that happens to reuse the field name
488
+ // "x" — without a reset, the stale "hello" would leak into the new spec.
489
+ rerender(
490
+ <SchemaForm
491
+ spec={{ formName: "b", fields: [{ type: "string", name: "x", label: "X (form b)" }] }}
492
+ />,
493
+ );
494
+ expect(screen.getByLabelText("X (form b)")).toHaveValue("");
495
+ });
496
+
497
+ it("does NOT reset values on an ordinary re-render with the SAME formName", async () => {
498
+ const user = userEvent.setup();
499
+ const spec = { formName: "a", fields: [{ type: "string" as const, name: "x", label: "X" }] };
500
+ const { rerender } = render(<SchemaForm spec={spec} />);
501
+ await user.type(screen.getByLabelText("X"), "hello");
502
+ // A fresh object with the SAME formName/content (SchemaForm normalizes it
503
+ // fresh every render) must not be mistaken for a spec swap.
504
+ rerender(<SchemaForm spec={{ ...spec }} />);
505
+ expect(screen.getByLabelText("X")).toHaveValue("hello");
506
+ });
507
+ });
508
+
509
+ describe("SchemaForm — file field is controlled", () => {
510
+ it("renders a file seeded via `values` in the picker (not just its own internal state)", () => {
511
+ const file = new File(["{}"], "creds.json", { type: "application/json" });
512
+ render(
513
+ <SchemaForm
514
+ spec={{ formName: "f", fields: [{ type: "file", name: "creds", label: "Credentials" }] }}
515
+ values={{ creds: [file] }}
516
+ />,
517
+ );
518
+ expect(screen.getByText("creds.json")).toBeInTheDocument();
519
+ });
520
+ });
521
+
522
+ describe("SchemaForm — controlled + submitted", () => {
523
+ it("renders a controlled form whose `values` omit its array-valued fields without a render loop", () => {
524
+ // An unset file/list field resolves to a fresh `[]` on every snapshot read;
525
+ // unless that counts as unchanged, `useSyncExternalStore` loops forever.
526
+ render(
527
+ <SchemaForm
528
+ spec={{
529
+ formName: "f",
530
+ fields: [
531
+ { type: "file", name: "creds", label: "Credentials" },
532
+ { type: "list", name: "tags", label: "Tags" },
533
+ ],
534
+ }}
535
+ values={{}}
536
+ />,
537
+ );
538
+ expect(screen.getByText("Credentials")).toBeInTheDocument();
539
+ });
540
+ it("reflects controlled values and calls onChange", async () => {
541
+ const user = userEvent.setup();
542
+ const onChange = vi.fn();
543
+ render(<SchemaForm spec={simpleSpec} values={{ name: "Seed" }} onChange={onChange} />);
544
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Seed");
545
+ await user.type(screen.getByLabelText(/Email/), "a");
546
+ expect(onChange).toHaveBeenCalled();
547
+ });
548
+
549
+ it("renders an inert submitted state with values visible and no submit button", () => {
550
+ render(<SchemaForm spec={simpleSpec} submitted values={{ name: "Dana", email: "d@a.co" }} />);
551
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Dana");
552
+ expect(screen.getByLabelText(/Name/)).toHaveAttribute("readonly");
553
+ expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
554
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
555
+ });
556
+
557
+ it("renders a terminal, form-level submission error", () => {
558
+ render(<SchemaForm spec={simpleSpec} error="Couldn't save." />);
559
+ expect(screen.getByRole("alert")).toHaveTextContent("Couldn't save.");
560
+ });
561
+ });
562
+
563
+ describe("schema-form-spec", () => {
564
+ it("formSpecSchema accepts a well-formed spec, including group/list/key-value/file fields", () => {
565
+ expect(
566
+ formSpecSchema.safeParse({
567
+ formName: "f",
568
+ fields: [
569
+ { type: "list", name: "l" },
570
+ { type: "key-value", name: "kv" },
571
+ { type: "file", name: "file" },
572
+ {
573
+ type: "group",
574
+ name: "g",
575
+ variant: "advanced",
576
+ groups: [{ key: "a", label: "A", fields: [{ type: "string", name: "x" }] }],
577
+ },
578
+ ],
579
+ }).success,
580
+ ).toBe(true);
581
+ });
582
+
583
+ it("rejects a password/credential field (no such format)", () => {
584
+ expect(
585
+ fieldSpecSchema.safeParse({ type: "string", name: "pw", format: "password" }).success,
586
+ ).toBe(false);
587
+ });
588
+
589
+ it("normalizeFormSpec drops invalid top-level fields but keeps valid ones", () => {
590
+ const result = normalizeFormSpec({
591
+ formName: "f",
592
+ fields: [{ type: "string", name: "ok" }, { nope: true }],
593
+ });
594
+ expect(result.ok).toBe(true);
595
+ if (result.ok) {
596
+ expect(result.spec.fields).toHaveLength(1);
597
+ expect(result.spec.fields[0]?.name).toBe("ok");
598
+ }
599
+ });
600
+
601
+ it("findFieldByName resolves a field nested inside a group branch", () => {
602
+ const found = findFieldByName(groupSpec.fields, "clientId");
603
+ expect(found?.name).toBe("clientId");
604
+ });
605
+
606
+ it("normalizeFormSpec drops a nested field whose name collides with an already-accepted one", () => {
607
+ const result = normalizeFormSpec({
608
+ formName: "f",
609
+ fields: [
610
+ { type: "string", name: "shared" },
611
+ {
612
+ type: "group",
613
+ name: "g",
614
+ variant: "advanced",
615
+ groups: [{ key: "a", label: "A", fields: [{ type: "string", name: "shared" }] }],
616
+ },
617
+ ],
618
+ });
619
+ expect(result.ok).toBe(true);
620
+ if (result.ok) {
621
+ expect(result.spec.fields).toHaveLength(1);
622
+ expect(result.spec.fields[0]?.name).toBe("shared");
623
+ }
624
+ });
625
+
626
+ it("normalizeFormSpec drops a group whose OWN two branches collide with each other", () => {
627
+ const result = normalizeFormSpec({
628
+ formName: "f",
629
+ fields: [
630
+ {
631
+ type: "group",
632
+ name: "g",
633
+ variant: "advanced",
634
+ groups: [
635
+ { key: "a", label: "A", fields: [{ type: "string", name: "dup" }] },
636
+ { key: "b", label: "B", fields: [{ type: "string", name: "dup" }] },
637
+ ],
638
+ },
639
+ ],
640
+ });
641
+ expect(result.ok).toBe(true);
642
+ if (result.ok) expect(result.spec.fields).toHaveLength(0);
643
+ });
644
+
645
+ it("validateField enforces the declarative vocabulary", () => {
646
+ const req = { type: "string", name: "x", required: true } as const;
647
+ expect(validateField(req, undefined)).toMatch(/required/);
648
+ expect(validateField({ type: "integer", name: "n", min: 5 }, 3)).toMatch(/at least 5/);
649
+ expect(validateField({ type: "list", name: "l", minItems: 2 }, ["a"])).toMatch(/at least 2/);
650
+ });
651
+
652
+ it("fileMatchesAccept matches extensions, MIME types, and wildcards", () => {
653
+ const jsonFile = new File(["{}"], "creds.json", { type: "application/json" });
654
+ const exeFile = new File(["x"], "creds.exe", { type: "application/x-msdownload" });
655
+ const pngFile = new File(["x"], "pic.png", { type: "image/png" });
656
+ expect(fileMatchesAccept(jsonFile, ".json")).toBe(true);
657
+ expect(fileMatchesAccept(exeFile, ".json")).toBe(false);
658
+ expect(fileMatchesAccept(pngFile, "image/*")).toBe(true);
659
+ expect(fileMatchesAccept(jsonFile, undefined)).toBe(true);
660
+ });
661
+ });
662
+
663
+ describe("SchemaFormTestAction", () => {
664
+ // A required field left empty on purpose — every test in this block proves
665
+ // the test action neither reads nor is blocked by field validity.
666
+ const connectorSpec: FormSpec = {
667
+ formName: "connector",
668
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
669
+ };
670
+
671
+ function renderWithTestAction(onTest: () => void | Promise<void>) {
672
+ const result = normalizeFormSpec(connectorSpec);
673
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
674
+ const onSubmit = vi.fn();
675
+ render(
676
+ <SchemaFormProvider spec={result.spec} onSubmit={onSubmit}>
677
+ <SchemaFormRoot>
678
+ <SchemaFormFields />
679
+ <SchemaFormTestAction onTest={onTest} />
680
+ <SchemaFormSubmit />
681
+ </SchemaFormRoot>
682
+ </SchemaFormProvider>,
683
+ );
684
+ return { onSubmit };
685
+ }
686
+
687
+ it("renders idle with the default label", () => {
688
+ renderWithTestAction(() => {});
689
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
690
+ });
691
+
692
+ it("goes pending then success when onTest resolves, without touching field validity", async () => {
693
+ const user = userEvent.setup();
694
+ let resolveTest: () => void = () => {};
695
+ const onTest = vi.fn(
696
+ () =>
697
+ new Promise<void>((resolve) => {
698
+ resolveTest = resolve;
699
+ }),
700
+ );
701
+ renderWithTestAction(onTest);
702
+
703
+ const button = screen.getByRole("button", { name: "Test connection" });
704
+ await user.click(button);
705
+
706
+ expect(onTest).toHaveBeenCalledTimes(1);
707
+ // Pending: still focusable/in the tab order (aria-disabled, never native disabled).
708
+ expect(screen.getByRole("button", { name: "Testing…" })).not.toBeDisabled();
709
+ expect(screen.getByRole("button", { name: "Testing…" })).toHaveAttribute(
710
+ "aria-disabled",
711
+ "true",
712
+ );
713
+ // The empty required field is untouched — no validation error appeared just
714
+ // from running the test action.
715
+ expect(screen.queryByText(/required/i)).not.toBeInTheDocument();
716
+
717
+ resolveTest();
718
+ await waitFor(() => {
719
+ expect(screen.getByText("Connected")).toBeInTheDocument();
720
+ });
721
+ expect(screen.getByRole("button", { name: "Test connection" })).not.toHaveAttribute(
722
+ "aria-disabled",
723
+ );
724
+ });
725
+
726
+ it("goes pending then failure when onTest rejects, and shows the rejection's message", async () => {
727
+ const user = userEvent.setup();
728
+ const onTest = vi.fn().mockRejectedValue(new Error("connection refused"));
729
+ renderWithTestAction(onTest);
730
+
731
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
732
+
733
+ await waitFor(() => {
734
+ expect(screen.getByRole("alert")).toHaveTextContent("connection refused");
735
+ });
736
+ });
737
+
738
+ it("passes the form's current effective values to onTest", async () => {
739
+ const user = userEvent.setup();
740
+ const onTest = vi.fn().mockResolvedValue(undefined);
741
+ const result = normalizeFormSpec(connectorSpec);
742
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
743
+ render(
744
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }}>
745
+ <SchemaFormRoot>
746
+ <SchemaFormFields />
747
+ <SchemaFormTestAction onTest={onTest} />
748
+ </SchemaFormRoot>
749
+ </SchemaFormProvider>,
750
+ );
751
+
752
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
753
+ expect(onTest).toHaveBeenCalledWith({ apiKey: "sk-test" });
754
+ });
755
+
756
+ it("does not block or get blocked by SchemaFormSubmit's own validation", async () => {
757
+ const user = userEvent.setup();
758
+ const onTest = vi.fn().mockResolvedValue(undefined);
759
+ const { onSubmit } = renderWithTestAction(onTest);
760
+
761
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
762
+ await waitFor(() => {
763
+ expect(screen.getByText("Connected")).toBeInTheDocument();
764
+ });
765
+
766
+ // Submit still enforces the required field independently of the test
767
+ // action having "succeeded" — the two are not wired together.
768
+ await user.click(screen.getByRole("button", { name: "Submit" }));
769
+ expect(onSubmit).not.toHaveBeenCalled();
770
+ expect(screen.getByLabelText(/API key/)).toHaveFocus();
771
+ });
772
+
773
+ // The two tests above prove a SUCCESSFUL test-action doesn't bypass field
774
+ // validation. The maintainer ruling's actual direction is the opposite one
775
+ // — a FAILING or PENDING test-action must never BLOCK an otherwise-valid
776
+ // submit — and that direction was unlocked: a future change wiring
777
+ // `submitting` to the test-action's status would leave every test above
778
+ // green while breaking this. These two lock it, with `apiKey` seeded VALID
779
+ // so submit's own field validation can never be the reason it succeeds or
780
+ // fails.
781
+ function renderWithTestActionAndValidValues(onTest: () => void | Promise<void>) {
782
+ const result = normalizeFormSpec(connectorSpec);
783
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
784
+ const onSubmit = vi.fn();
785
+ render(
786
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} onSubmit={onSubmit}>
787
+ <SchemaFormRoot>
788
+ <SchemaFormFields />
789
+ <SchemaFormTestAction onTest={onTest} />
790
+ <SchemaFormSubmit />
791
+ </SchemaFormRoot>
792
+ </SchemaFormProvider>,
793
+ );
794
+ return { onSubmit };
795
+ }
796
+
797
+ it("a FAILING test-action does not block an otherwise-valid submit", async () => {
798
+ const user = userEvent.setup();
799
+ const onTest = vi.fn().mockRejectedValue(new Error("connection refused"));
800
+ const { onSubmit } = renderWithTestActionAndValidValues(onTest);
801
+
802
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
803
+ await waitFor(() => {
804
+ expect(screen.getByRole("alert")).toHaveTextContent("connection refused");
805
+ });
806
+
807
+ // The test action is in its terminal FAILURE state — submit is wired
808
+ // only to field validity, so a valid form still submits.
809
+ await user.click(screen.getByRole("button", { name: "Submit" }));
810
+ expect(onSubmit).toHaveBeenCalledWith({
811
+ formName: "connector",
812
+ values: expect.objectContaining({ apiKey: "sk-test" }),
813
+ });
814
+ });
815
+
816
+ it("a PENDING test-action does not block an otherwise-valid submit", async () => {
817
+ const user = userEvent.setup();
818
+ let resolveTest: () => void = () => {};
819
+ const onTest = vi.fn(
820
+ () =>
821
+ new Promise<void>((resolve) => {
822
+ resolveTest = resolve;
823
+ }),
824
+ );
825
+ const { onSubmit } = renderWithTestActionAndValidValues(onTest);
826
+
827
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
828
+ expect(screen.getByRole("button", { name: "Testing…" })).toBeInTheDocument();
829
+
830
+ // Still pending — Submit is a wholly separate control: not disabled, not
831
+ // aria-disabled, and not gated by the test action's in-flight state.
832
+ const submitButton = screen.getByRole("button", { name: "Submit" });
833
+ expect(submitButton).not.toHaveAttribute("aria-disabled");
834
+ await user.click(submitButton);
835
+ expect(onSubmit).toHaveBeenCalledWith({
836
+ formName: "connector",
837
+ values: expect.objectContaining({ apiKey: "sk-test" }),
838
+ });
839
+
840
+ // Let the still-in-flight test action settle so it doesn't leak a
841
+ // pending `act()` warning into a later test.
842
+ resolveTest();
843
+ await waitFor(() => {
844
+ expect(screen.getByText("Connected")).toBeInTheDocument();
845
+ });
846
+ });
847
+
848
+ // PR #119 review thread 0 (chatgpt-codex-connector, P2): nothing reset the
849
+ // test result when `effectiveValues` changed after the test STARTED, so a
850
+ // "Connected" badge kept describing values the user had since edited away.
851
+ it("discards a stale success if the field changes while the test is still pending", async () => {
852
+ const user = userEvent.setup();
853
+ let resolveTest: () => void = () => {};
854
+ const onTest = vi.fn(
855
+ () =>
856
+ new Promise<void>((resolve) => {
857
+ resolveTest = resolve;
858
+ }),
859
+ );
860
+ renderWithTestAction(onTest);
861
+
862
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
863
+ expect(screen.getByRole("button", { name: "Testing…" })).toBeInTheDocument();
864
+
865
+ // Edit the field WHILE the test is still in flight, then let it resolve.
866
+ await user.type(screen.getByLabelText(/API key/), "sk-edited");
867
+ resolveTest();
868
+
869
+ // The button returns to idle rather than showing "Connected" for a
870
+ // snapshot of values that is no longer current.
871
+ await waitFor(() => {
872
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
873
+ });
874
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
875
+ });
876
+
877
+ it("resets a settled success back to idle once the tested field changes", async () => {
878
+ const user = userEvent.setup();
879
+ const onTest = vi.fn().mockResolvedValue(undefined);
880
+ renderWithTestAction(onTest);
881
+
882
+ await user.click(screen.getByRole("button", { name: "Test connection" }));
883
+ await waitFor(() => {
884
+ expect(screen.getByText("Connected")).toBeInTheDocument();
885
+ });
886
+
887
+ // Edit AFTER the test already settled successfully.
888
+ await user.type(screen.getByLabelText(/API key/), "sk-edited");
889
+
890
+ await waitFor(() => {
891
+ expect(screen.queryByText("Connected")).not.toBeInTheDocument();
892
+ });
893
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeInTheDocument();
894
+ });
895
+
896
+ // PR #119 review thread 4 (chatgpt-codex-connector, P2): the test action
897
+ // read neither `submitted` nor `submitting` from context, so it stayed
898
+ // fully actionable on a terminal submitted form or during an in-flight
899
+ // submit — unlike every other control in this file.
900
+ it("is natively disabled once the form has been submitted (durable state)", () => {
901
+ const result = normalizeFormSpec(connectorSpec);
902
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
903
+ const onTest = vi.fn();
904
+ render(
905
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} submitted>
906
+ <SchemaFormRoot>
907
+ <SchemaFormTestAction onTest={onTest} />
908
+ </SchemaFormRoot>
909
+ </SchemaFormProvider>,
910
+ );
911
+ expect(screen.getByRole("button", { name: "Test connection" })).toBeDisabled();
912
+ });
913
+
914
+ it("is natively disabled while the form is submitting (a bystander to the SUBMIT button's own action, like every field)", async () => {
915
+ const user = userEvent.setup();
916
+ const onTest = vi.fn().mockResolvedValue(undefined);
917
+ const result = normalizeFormSpec(connectorSpec);
918
+ if (!result.ok) throw new Error("expected connectorSpec to normalize");
919
+ render(
920
+ <SchemaFormProvider spec={result.spec} values={{ apiKey: "sk-test" }} submitting>
921
+ <SchemaFormRoot>
922
+ <SchemaFormTestAction onTest={onTest} />
923
+ </SchemaFormRoot>
924
+ </SchemaFormProvider>,
925
+ );
926
+ const button = screen.getByRole("button", { name: "Test connection" });
927
+ // `submitting` describes the FORM's submit — an action this button
928
+ // didn't initiate and isn't mid-way through. It is a bystander here the
929
+ // same way a `SchemaFormField` is (`controlDisabled` includes
930
+ // `ctx.submitting`), not the button that was just activated (that's
931
+ // `SchemaFormSubmit`'s own `submitting`, which stays transient). Native
932
+ // `disabled` is correct: nothing keyboard-focused gets stranded, because
933
+ // this button can only reach a focused, un-clicked state via Tab, and a
934
+ // submit can only start from a click/Enter on Submit itself (which takes
935
+ // focus) or from Enter inside a text field (which never touches this
936
+ // button's focus state).
937
+ expect(button).toBeDisabled();
938
+ await user.click(button);
939
+ expect(onTest).not.toHaveBeenCalled();
940
+ });
941
+ });
942
+
943
+ // Perf review §3.4: "one context carries all values → every keystroke
944
+ // re-renders every field and re-runs isFieldVisible across the spec". Each
945
+ // `SchemaFormField` gets its OWN `<Profiler>` (React's own render-counting
946
+ // primitive — no new dependency) so a "commit" of one field can be told
947
+ // apart from a genuine re-render of another: `Profiler`'s `onRender` fires
948
+ // once per commit for every Profiler whose subtree was part of that commit,
949
+ // but `SchemaFormField` is wrapped in `memo` and reads its own value via a
950
+ // per-field `useSyncExternalStore` snapshot — a keystroke in one field must
951
+ // not change the CACHED snapshot object identity for any other field, so its
952
+ // memo bails and its own nested Profiler never re-fires.
953
+ describe("SchemaForm — per-field subscription (#review 3.4: one context re-renders every field)", () => {
954
+ function renderCounts() {
955
+ const counts: Record<string, number> = {};
956
+ const onRender: ProfilerOnRenderCallback = (id) => {
957
+ counts[id] = (counts[id] ?? 0) + 1;
958
+ };
959
+ return { counts, onRender };
960
+ }
961
+
962
+ it("typing in one field does not re-render a sibling field", async () => {
963
+ const user = userEvent.setup();
964
+ const { counts, onRender } = renderCounts();
965
+ render(
966
+ <SchemaFormProvider spec={normalizeSpecOrThrow(simpleSpec)}>
967
+ <SchemaFormRoot>
968
+ <Profiler id="name" onRender={onRender}>
969
+ <SchemaFormField name="name" />
970
+ </Profiler>
971
+ <Profiler id="email" onRender={onRender}>
972
+ <SchemaFormField name="email" />
973
+ </Profiler>
974
+ </SchemaFormRoot>
975
+ </SchemaFormProvider>,
976
+ );
977
+
978
+ // Both fields commit once on mount.
979
+ expect(counts.name).toBe(1);
980
+ expect(counts.email).toBe(1);
981
+
982
+ await user.type(screen.getByLabelText(/Name/), "Ada");
983
+
984
+ // The typed-in field re-renders once per keystroke; its untouched
985
+ // sibling's `useFieldSnapshot` bails (same cached snapshot reference),
986
+ // so its `memo`-wrapped `SchemaFormField` — and the `Profiler` around
987
+ // it — never fires again.
988
+ expect(counts.name).toBe(1 + "Ada".length);
989
+ expect(counts.email).toBe(1);
990
+ });
991
+
992
+ it("typing in one group branch's field does not re-render a field from a sibling branch of the same group", async () => {
993
+ const user = userEvent.setup();
994
+ const { counts, onRender } = renderCounts();
995
+ render(
996
+ <SchemaFormProvider spec={normalizeSpecOrThrow(groupSpec)}>
997
+ <SchemaFormRoot>
998
+ <Profiler id="apiKey" onRender={onRender}>
999
+ <SchemaFormField name="apiKey" />
1000
+ </Profiler>
1001
+ <Profiler id="clientId" onRender={onRender}>
1002
+ <SchemaFormField name="clientId" />
1003
+ </Profiler>
1004
+ </SchemaFormRoot>
1005
+ </SchemaFormProvider>,
1006
+ );
1007
+
1008
+ expect(counts.apiKey).toBe(1);
1009
+ // `apiKey` and `clientId` live in two different branches of the SAME
1010
+ // `group` field (`store.getField` resolves a nested field by name via
1011
+ // its recursive field map) — the per-field snapshot cache is keyed by
1012
+ // name, not by which branch a field happens to sit in, so this still
1013
+ // isolates them from each other.
1014
+ expect(counts.clientId).toBe(1);
1015
+
1016
+ await user.type(screen.getByLabelText(/API key/), "sk-live");
1017
+
1018
+ expect(counts.apiKey).toBe(1 + "sk-live".length);
1019
+ expect(counts.clientId).toBe(1);
1020
+ });
1021
+ });