@pilotiq/pilotiq 0.24.1 → 0.24.2

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 (480) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/boost/guidelines.md +566 -0
  3. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  4. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  5. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  6. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  7. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  9. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  10. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  11. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  12. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  13. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  14. package/package.json +6 -1
  15. package/.turbo/turbo-build.log +0 -8
  16. package/CLAUDE.md +0 -265
  17. package/src/Cluster.test.ts +0 -283
  18. package/src/Cluster.ts +0 -83
  19. package/src/Column.test.ts +0 -199
  20. package/src/Column.ts +0 -710
  21. package/src/Global.test.ts +0 -367
  22. package/src/Global.ts +0 -169
  23. package/src/Page.test.ts +0 -114
  24. package/src/Page.ts +0 -208
  25. package/src/Pilotiq.perf.test.ts +0 -252
  26. package/src/Pilotiq.test.ts +0 -129
  27. package/src/Pilotiq.ts +0 -1158
  28. package/src/PilotiqRegistry.ts +0 -36
  29. package/src/PilotiqServiceProvider.ts +0 -121
  30. package/src/RelationManager.test.ts +0 -400
  31. package/src/RelationManager.ts +0 -527
  32. package/src/RenderHook.test.ts +0 -252
  33. package/src/RenderHook.ts +0 -242
  34. package/src/Resource.test.ts +0 -284
  35. package/src/Resource.ts +0 -526
  36. package/src/RightPanel.test.ts +0 -202
  37. package/src/RightPanel.ts +0 -132
  38. package/src/Tab.test.ts +0 -91
  39. package/src/Tab.ts +0 -156
  40. package/src/UserMenuItem.ts +0 -145
  41. package/src/actions/Action.test.ts +0 -2526
  42. package/src/actions/Action.ts +0 -1515
  43. package/src/actions/ActionGroup.test.ts +0 -112
  44. package/src/actions/ActionGroup.ts +0 -173
  45. package/src/actions/attachFactory.ts +0 -172
  46. package/src/actions/bulkFactories.ts +0 -168
  47. package/src/actions/crudFactories.ts +0 -220
  48. package/src/actions/exportFactory.ts +0 -225
  49. package/src/actions/factoryHelpers.ts +0 -177
  50. package/src/actions/importFactory.ts +0 -243
  51. package/src/actions/index.ts +0 -17
  52. package/src/actions/m2mFactories.ts +0 -193
  53. package/src/actions/relationFactories.ts +0 -372
  54. package/src/applyPageHooks.test.ts +0 -463
  55. package/src/applyPageHooks.ts +0 -330
  56. package/src/authorization.test.ts +0 -483
  57. package/src/breadcrumbs.test.ts +0 -238
  58. package/src/cells/coerce.test.ts +0 -85
  59. package/src/cells/coerce.ts +0 -84
  60. package/src/clusterPaths.ts +0 -35
  61. package/src/columns/BadgeColumn.test.ts +0 -54
  62. package/src/columns/BadgeColumn.ts +0 -32
  63. package/src/columns/BooleanColumn.test.ts +0 -41
  64. package/src/columns/BooleanColumn.ts +0 -18
  65. package/src/columns/ColorColumn.test.ts +0 -37
  66. package/src/columns/ColorColumn.ts +0 -38
  67. package/src/columns/IconColumn.test.ts +0 -54
  68. package/src/columns/IconColumn.ts +0 -37
  69. package/src/columns/ImageColumn.test.ts +0 -41
  70. package/src/columns/ImageColumn.ts +0 -28
  71. package/src/columns/SelectColumn.ts +0 -98
  72. package/src/columns/TextColumn.test.ts +0 -190
  73. package/src/columns/TextColumn.ts +0 -20
  74. package/src/columns/TextInputColumn.ts +0 -68
  75. package/src/columns/ToggleColumn.ts +0 -46
  76. package/src/columns/editableColumns.test.ts +0 -238
  77. package/src/columns/index.ts +0 -9
  78. package/src/defaultGlobalPages.ts +0 -95
  79. package/src/defaultPages.test.ts +0 -634
  80. package/src/defaultPages.ts +0 -617
  81. package/src/defaultViewPage.test.ts +0 -147
  82. package/src/elements/Form.test.ts +0 -223
  83. package/src/elements/Form.ts +0 -416
  84. package/src/elements/ListTabs.ts +0 -28
  85. package/src/elements/Table.test.ts +0 -422
  86. package/src/elements/Table.ts +0 -850
  87. package/src/elements/TableGroup.test.ts +0 -260
  88. package/src/elements/TableGroup.ts +0 -334
  89. package/src/elements/dispatchAction.test.ts +0 -463
  90. package/src/elements/dispatchAction.ts +0 -355
  91. package/src/elements/dispatchForm.test.ts +0 -477
  92. package/src/elements/dispatchForm.ts +0 -1993
  93. package/src/elements/dispatchTable.test.ts +0 -1514
  94. package/src/elements/dispatchTable.ts +0 -745
  95. package/src/elements/index.ts +0 -21
  96. package/src/entries/BadgeEntry.ts +0 -39
  97. package/src/entries/CodeEntry.test.ts +0 -40
  98. package/src/entries/CodeEntry.ts +0 -52
  99. package/src/entries/ColorEntry.ts +0 -63
  100. package/src/entries/ComponentEntry.test.ts +0 -173
  101. package/src/entries/ComponentEntry.ts +0 -95
  102. package/src/entries/Entry.ts +0 -304
  103. package/src/entries/IconEntry.ts +0 -49
  104. package/src/entries/ImageEntry.ts +0 -61
  105. package/src/entries/KeyValueEntry.ts +0 -47
  106. package/src/entries/RepeatableEntry.test.ts +0 -239
  107. package/src/entries/RepeatableEntry.ts +0 -173
  108. package/src/entries/TextEntry.test.ts +0 -394
  109. package/src/entries/TextEntry.ts +0 -60
  110. package/src/entries/index.ts +0 -12
  111. package/src/entries/leaves.test.ts +0 -306
  112. package/src/entries/registry.ts +0 -54
  113. package/src/fields/BuilderField.test.ts +0 -1188
  114. package/src/fields/BuilderField.ts +0 -605
  115. package/src/fields/BuilderRelationship.test.ts +0 -811
  116. package/src/fields/CheckboxField.test.ts +0 -44
  117. package/src/fields/CheckboxField.ts +0 -27
  118. package/src/fields/CheckboxListField.test.ts +0 -99
  119. package/src/fields/CheckboxListField.ts +0 -66
  120. package/src/fields/ColorPickerField.test.ts +0 -33
  121. package/src/fields/ColorPickerField.ts +0 -25
  122. package/src/fields/DateField.ts +0 -54
  123. package/src/fields/DateTimeField.test.ts +0 -55
  124. package/src/fields/EmailField.ts +0 -16
  125. package/src/fields/Field.test.ts +0 -654
  126. package/src/fields/Field.ts +0 -817
  127. package/src/fields/FileUploadField.test.ts +0 -143
  128. package/src/fields/FileUploadField.ts +0 -159
  129. package/src/fields/HiddenField.test.ts +0 -27
  130. package/src/fields/HiddenField.ts +0 -28
  131. package/src/fields/KeyValueField.test.ts +0 -105
  132. package/src/fields/KeyValueField.ts +0 -55
  133. package/src/fields/MarkdownField.test.ts +0 -167
  134. package/src/fields/MarkdownField.ts +0 -162
  135. package/src/fields/NumberField.ts +0 -33
  136. package/src/fields/RadioField.test.ts +0 -94
  137. package/src/fields/RadioField.ts +0 -67
  138. package/src/fields/RepeaterField.test.ts +0 -1806
  139. package/src/fields/RepeaterField.ts +0 -939
  140. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  141. package/src/fields/RepeaterSimple.test.ts +0 -248
  142. package/src/fields/RowButton.test.ts +0 -219
  143. package/src/fields/RowButton.ts +0 -135
  144. package/src/fields/SelectField.test.ts +0 -192
  145. package/src/fields/SelectField.ts +0 -235
  146. package/src/fields/SliderField.test.ts +0 -50
  147. package/src/fields/SliderField.ts +0 -53
  148. package/src/fields/SlugField.ts +0 -24
  149. package/src/fields/TagsInputField.test.ts +0 -154
  150. package/src/fields/TagsInputField.ts +0 -133
  151. package/src/fields/TextField.test.ts +0 -213
  152. package/src/fields/TextField.ts +0 -177
  153. package/src/fields/TextareaField.test.ts +0 -58
  154. package/src/fields/TextareaField.ts +0 -59
  155. package/src/fields/ToggleButtonsField.test.ts +0 -106
  156. package/src/fields/ToggleButtonsField.ts +0 -59
  157. package/src/fields/ToggleField.ts +0 -16
  158. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  159. package/src/fields/optionsResolver.ts +0 -95
  160. package/src/fields/resolveField.ts +0 -28
  161. package/src/filters/BooleanFilter.ts +0 -35
  162. package/src/filters/DateRangeFilter.test.ts +0 -194
  163. package/src/filters/DateRangeFilter.ts +0 -148
  164. package/src/filters/Filter.test.ts +0 -268
  165. package/src/filters/Filter.ts +0 -184
  166. package/src/filters/FormFilter.test.ts +0 -238
  167. package/src/filters/FormFilter.ts +0 -215
  168. package/src/filters/MultiSelectFilter.test.ts +0 -119
  169. package/src/filters/MultiSelectFilter.ts +0 -78
  170. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  171. package/src/filters/QueryBuilderFilter.ts +0 -398
  172. package/src/filters/SelectFilter.ts +0 -46
  173. package/src/filters/TernaryFilter.test.ts +0 -160
  174. package/src/filters/TernaryFilter.ts +0 -72
  175. package/src/filters/TrashedFilter.test.ts +0 -149
  176. package/src/filters/TrashedFilter.ts +0 -55
  177. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  178. package/src/filters/queryBuilder/Constraint.ts +0 -115
  179. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  180. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  181. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  182. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  183. package/src/filters/queryBuilder/index.ts +0 -12
  184. package/src/icons/index.ts +0 -2
  185. package/src/icons/lucide.ts +0 -204
  186. package/src/icons/registry.test.ts +0 -56
  187. package/src/icons/registry.ts +0 -41
  188. package/src/icons/types.ts +0 -47
  189. package/src/index.ts +0 -525
  190. package/src/io/csv.test.ts +0 -142
  191. package/src/io/csv.ts +0 -170
  192. package/src/nestedRelationManagerData.test.ts +0 -547
  193. package/src/notifications/Notification.test.ts +0 -210
  194. package/src/notifications/Notification.ts +0 -354
  195. package/src/notifications/broadcast.test.ts +0 -110
  196. package/src/notifications/broadcast.ts +0 -95
  197. package/src/notifications/database.test.ts +0 -383
  198. package/src/notifications/database.ts +0 -398
  199. package/src/notifications/databaseNotifications.test.ts +0 -187
  200. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  201. package/src/notifications/dispatchNotificationAction.ts +0 -142
  202. package/src/notifications/flash.test.ts +0 -89
  203. package/src/notifications/flash.ts +0 -71
  204. package/src/notifications/index.ts +0 -45
  205. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  206. package/src/notifications/registerBroadcastAuth.ts +0 -100
  207. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  208. package/src/notifications/resolveSavedNotification.ts +0 -59
  209. package/src/notifications/types.ts +0 -93
  210. package/src/orm/m2mAccessor.ts +0 -66
  211. package/src/orm/modelDefaults.test.ts +0 -633
  212. package/src/orm/modelDefaults.ts +0 -666
  213. package/src/pageData/breadcrumbs.ts +0 -288
  214. package/src/pageData/forms.ts +0 -578
  215. package/src/pageData/helpers.ts +0 -857
  216. package/src/pageData/misc.ts +0 -347
  217. package/src/pageData/navigation.ts +0 -842
  218. package/src/pageData/relationPages.ts +0 -1248
  219. package/src/pageData/relationTabs.ts +0 -286
  220. package/src/pageData/resourcePages.ts +0 -609
  221. package/src/pageData.test.ts +0 -1545
  222. package/src/pageData.ts +0 -341
  223. package/src/plugins/index.ts +0 -8
  224. package/src/plugins/themeEditor.test.ts +0 -36
  225. package/src/plugins/themeEditor.ts +0 -45
  226. package/src/react/AppShell.tsx +0 -251
  227. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  228. package/src/react/CollabRoomContext.ts +0 -98
  229. package/src/react/CollabTextRendererRegistry.ts +0 -102
  230. package/src/react/CommandPalette.tsx +0 -375
  231. package/src/react/CurrentUserContext.tsx +0 -50
  232. package/src/react/CustomPageWrapperGate.tsx +0 -69
  233. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  234. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  235. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  236. package/src/react/FieldPresenceRegistry.ts +0 -46
  237. package/src/react/FormCollabBindingRegistry.ts +0 -242
  238. package/src/react/FormStateContext.tsx +0 -591
  239. package/src/react/HeadHooks.tsx +0 -126
  240. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  241. package/src/react/MarkdownEditorRegistry.ts +0 -107
  242. package/src/react/NotificationActionStrip.tsx +0 -263
  243. package/src/react/NotificationBell.tsx +0 -426
  244. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  245. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  246. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  247. package/src/react/PendingSuggestionsContext.tsx +0 -172
  248. package/src/react/RecordWrapperGate.tsx +0 -58
  249. package/src/react/RecordWrapperRegistry.ts +0 -39
  250. package/src/react/RenderHookSlot.tsx +0 -32
  251. package/src/react/RightSidebar.tsx +0 -257
  252. package/src/react/RightSidebarContext.tsx +0 -234
  253. package/src/react/RightSidebarTrigger.tsx +0 -53
  254. package/src/react/RowCoordsContext.tsx +0 -23
  255. package/src/react/SchemaRenderer.tsx +0 -549
  256. package/src/react/SearchTrigger.tsx +0 -46
  257. package/src/react/ThemeProvider.tsx +0 -93
  258. package/src/react/ThemeSettingsPage.tsx +0 -579
  259. package/src/react/ThemeToggle.tsx +0 -20
  260. package/src/react/Toaster.tsx +0 -158
  261. package/src/react/UserMenu.tsx +0 -196
  262. package/src/react/WidgetDataContext.tsx +0 -157
  263. package/src/react/cells/EditableCell.tsx +0 -389
  264. package/src/react/component-slots.test.ts +0 -103
  265. package/src/react/component-slots.ts +0 -116
  266. package/src/react/fieldJsHandler.test.ts +0 -166
  267. package/src/react/fieldJsHandler.ts +0 -79
  268. package/src/react/fields/BuilderInput.tsx +0 -1078
  269. package/src/react/fields/CheckboxInput.tsx +0 -39
  270. package/src/react/fields/CheckboxListInput.tsx +0 -102
  271. package/src/react/fields/ColorInput.tsx +0 -71
  272. package/src/react/fields/DateFieldInput.tsx +0 -70
  273. package/src/react/fields/DateTimeInput.tsx +0 -62
  274. package/src/react/fields/FieldShell.tsx +0 -348
  275. package/src/react/fields/FileUploadInput.tsx +0 -639
  276. package/src/react/fields/HiddenInput.tsx +0 -17
  277. package/src/react/fields/KeyValueInput.tsx +0 -230
  278. package/src/react/fields/MarkdownInput.tsx +0 -560
  279. package/src/react/fields/RadioInput.tsx +0 -81
  280. package/src/react/fields/RepeaterInput.test.ts +0 -116
  281. package/src/react/fields/RepeaterInput.tsx +0 -1420
  282. package/src/react/fields/SelectFieldInput.tsx +0 -280
  283. package/src/react/fields/SliderInput.tsx +0 -81
  284. package/src/react/fields/TagsInput.tsx +0 -283
  285. package/src/react/fields/TextLikeInput.tsx +0 -256
  286. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  287. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  288. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  289. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  290. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  291. package/src/react/fields/repeaterReconcile.ts +0 -104
  292. package/src/react/fields/rowChromeButton.tsx +0 -336
  293. package/src/react/fields/rowState.ts +0 -106
  294. package/src/react/fields/syncRowGates.test.ts +0 -202
  295. package/src/react/fields/syncRowGates.ts +0 -66
  296. package/src/react/fields/textInputControls.tsx +0 -238
  297. package/src/react/fields/useRowReorderDnd.ts +0 -78
  298. package/src/react/formStateHelpers.test.ts +0 -508
  299. package/src/react/formStateHelpers.ts +0 -381
  300. package/src/react/hooks/use-mobile.ts +0 -19
  301. package/src/react/icon-context.tsx +0 -60
  302. package/src/react/index.ts +0 -194
  303. package/src/react/layouts/SidebarLayout.tsx +0 -250
  304. package/src/react/layouts/TopbarLayout.tsx +0 -258
  305. package/src/react/navigate.tsx +0 -37
  306. package/src/react/onProviderSynced.test.ts +0 -90
  307. package/src/react/parseRecordEditUrl.test.ts +0 -122
  308. package/src/react/parseRecordEditUrl.ts +0 -94
  309. package/src/react/persistedState.ts +0 -40
  310. package/src/react/registry.ts +0 -48
  311. package/src/react/right-panel-registry.tsx +0 -47
  312. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  313. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  314. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  315. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  316. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  317. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  318. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  319. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  320. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  321. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  322. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  323. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  324. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  325. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  326. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  327. package/src/react/schemaRenderer/constants.ts +0 -50
  328. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  329. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  330. package/src/react/schemaRenderer/helpers.tsx +0 -81
  331. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  332. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  333. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  334. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  335. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  336. package/src/react/schemaRenderer/table/links.tsx +0 -112
  337. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  338. package/src/react/schemaRenderer/table/url.tsx +0 -143
  339. package/src/react/theme-preview/apply.ts +0 -99
  340. package/src/react/theme-preview/build-html.ts +0 -436
  341. package/src/react/ui/button.tsx +0 -51
  342. package/src/react/ui/calendar.tsx +0 -67
  343. package/src/react/ui/checkbox.tsx +0 -29
  344. package/src/react/ui/dialog.tsx +0 -108
  345. package/src/react/ui/dropdown-menu.tsx +0 -97
  346. package/src/react/ui/input.tsx +0 -20
  347. package/src/react/ui/label.tsx +0 -21
  348. package/src/react/ui/popover.tsx +0 -50
  349. package/src/react/ui/select.tsx +0 -169
  350. package/src/react/ui/separator.tsx +0 -25
  351. package/src/react/ui/sheet.tsx +0 -136
  352. package/src/react/ui/sidebar.tsx +0 -723
  353. package/src/react/ui/skeleton.tsx +0 -13
  354. package/src/react/ui/slider.tsx +0 -34
  355. package/src/react/ui/switch.tsx +0 -28
  356. package/src/react/ui/table.tsx +0 -105
  357. package/src/react/ui/tabs.tsx +0 -63
  358. package/src/react/ui/textarea.tsx +0 -18
  359. package/src/react/ui/tooltip.tsx +0 -64
  360. package/src/react/useResizableWidth.ts +0 -139
  361. package/src/react/utils.ts +0 -6
  362. package/src/react/widgetRegistry.test.ts +0 -43
  363. package/src/react/widgetRegistry.ts +0 -50
  364. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  365. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  366. package/src/react/widgets/ViewRenderer.tsx +0 -71
  367. package/src/relationManagerData.test.ts +0 -1595
  368. package/src/richtext/index.ts +0 -8
  369. package/src/richtext/registry.ts +0 -89
  370. package/src/routes/globals.ts +0 -148
  371. package/src/routes/guard.test.ts +0 -325
  372. package/src/routes/helpers.ts +0 -704
  373. package/src/routes/pages.ts +0 -175
  374. package/src/routes/panel.ts +0 -204
  375. package/src/routes/relations.ts +0 -1243
  376. package/src/routes/resources.ts +0 -781
  377. package/src/routes/theme.ts +0 -91
  378. package/src/routes-nested-relations.test.ts +0 -676
  379. package/src/routes-relations.test.ts +0 -972
  380. package/src/routes.test.ts +0 -2027
  381. package/src/routes.ts +0 -303
  382. package/src/schema/Alert.test.ts +0 -109
  383. package/src/schema/Alert.ts +0 -131
  384. package/src/schema/Block.ts +0 -169
  385. package/src/schema/Breadcrumbs.ts +0 -40
  386. package/src/schema/Card.ts +0 -35
  387. package/src/schema/Divider.ts +0 -20
  388. package/src/schema/Element.ts +0 -219
  389. package/src/schema/EmptyState.test.ts +0 -37
  390. package/src/schema/EmptyState.ts +0 -63
  391. package/src/schema/Fieldset.ts +0 -43
  392. package/src/schema/Grid.ts +0 -43
  393. package/src/schema/Group.ts +0 -30
  394. package/src/schema/Heading.ts +0 -39
  395. package/src/schema/Html.ts +0 -67
  396. package/src/schema/Icon.ts +0 -54
  397. package/src/schema/Image.ts +0 -57
  398. package/src/schema/LinkTag.ts +0 -41
  399. package/src/schema/Markdown.ts +0 -85
  400. package/src/schema/MetaTag.ts +0 -41
  401. package/src/schema/RelationTabs.ts +0 -71
  402. package/src/schema/ScriptTag.ts +0 -55
  403. package/src/schema/Section.ts +0 -160
  404. package/src/schema/ServerDataElement.test.ts +0 -140
  405. package/src/schema/ServerDataElement.ts +0 -156
  406. package/src/schema/SlotComponent.test.ts +0 -77
  407. package/src/schema/SlotComponent.ts +0 -71
  408. package/src/schema/Split.ts +0 -50
  409. package/src/schema/Stat.test.ts +0 -118
  410. package/src/schema/Stat.ts +0 -154
  411. package/src/schema/StatsOverview.test.ts +0 -141
  412. package/src/schema/StatsOverview.ts +0 -119
  413. package/src/schema/StyleTag.ts +0 -35
  414. package/src/schema/TableWidget.test.ts +0 -297
  415. package/src/schema/TableWidget.ts +0 -289
  416. package/src/schema/Tabs.ts +0 -79
  417. package/src/schema/Text.ts +0 -58
  418. package/src/schema/UnorderedList.ts +0 -49
  419. package/src/schema/View.test.ts +0 -111
  420. package/src/schema/View.ts +0 -127
  421. package/src/schema/Wizard.ts +0 -220
  422. package/src/schema/containers.test.ts +0 -564
  423. package/src/schema/headTags.test.ts +0 -134
  424. package/src/schema/index.ts +0 -40
  425. package/src/schema/primes.test.ts +0 -269
  426. package/src/schema/resolveSchema.test.ts +0 -379
  427. package/src/schema/resolveSchema.ts +0 -917
  428. package/src/schema/sanitize.ts +0 -58
  429. package/src/search.test.ts +0 -446
  430. package/src/search.ts +0 -178
  431. package/src/sessionFilters.test.ts +0 -375
  432. package/src/sessionFilters.ts +0 -143
  433. package/src/slot-components/index.ts +0 -10
  434. package/src/slot-components/registry.ts +0 -56
  435. package/src/styles/file-upload.css +0 -13
  436. package/src/summarizers/Summarizer.test.ts +0 -84
  437. package/src/summarizers/Summarizer.ts +0 -123
  438. package/src/summarizers/index.ts +0 -11
  439. package/src/theme/base-colors.ts +0 -68
  440. package/src/theme/chart-colors.ts +0 -50
  441. package/src/theme/colors.ts +0 -447
  442. package/src/theme/generate-css.test.ts +0 -139
  443. package/src/theme/generate-css.ts +0 -44
  444. package/src/theme/generate-scale.test.ts +0 -106
  445. package/src/theme/generate-scale.ts +0 -97
  446. package/src/theme/icon-map.ts +0 -42
  447. package/src/theme/index.ts +0 -34
  448. package/src/theme/migrate.test.ts +0 -178
  449. package/src/theme/migrate.ts +0 -81
  450. package/src/theme/presets.ts +0 -135
  451. package/src/theme/radius.ts +0 -18
  452. package/src/theme/resolve.test.ts +0 -238
  453. package/src/theme/resolve.ts +0 -96
  454. package/src/theme/spacing.ts +0 -18
  455. package/src/theme/storage.test.ts +0 -126
  456. package/src/theme/storage.ts +0 -106
  457. package/src/theme/theme-colors.ts +0 -88
  458. package/src/theme/types.ts +0 -125
  459. package/src/uploads/UploadAdapter.ts +0 -35
  460. package/src/uploads/index.ts +0 -2
  461. package/src/uploads/localUpload.test.ts +0 -70
  462. package/src/uploads/localUpload.ts +0 -84
  463. package/src/validation/Validator.ts +0 -49
  464. package/src/validation/index.ts +0 -28
  465. package/src/validation/rules.ts +0 -78
  466. package/src/validation/runValidators.ts +0 -435
  467. package/src/validation/uniqueValidator.test.ts +0 -196
  468. package/src/validation/uniqueValidator.ts +0 -133
  469. package/src/validation/validators.test.ts +0 -268
  470. package/src/vite.test.ts +0 -184
  471. package/src/vite.ts +0 -787
  472. package/src/widgets/index.ts +0 -10
  473. package/src/widgets/registry.ts +0 -45
  474. package/src/widgets.test.ts +0 -592
  475. package/tsconfig.build.json +0 -11
  476. package/tsconfig.json +0 -4
  477. package/tsconfig.test.json +0 -10
  478. package/views/react/Dashboard.tsx +0 -27
  479. package/views/react/Resources/Form.tsx +0 -102
  480. package/views/react/Resources/Index.tsx +0 -49
@@ -1,1923 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { RepeaterField } from './RepeaterField.js'
5
- import { TextField } from './TextField.js'
6
- import { Form } from '../elements/Form.js'
7
- import { dispatchFormSubmit, extractRelationshipRepeaters, loadRelationRows } from '../elements/dispatchForm.js'
8
- import { applyRelationshipRepeaterFill } from '../pageData.js'
9
- import type { ModelLike, ModelQuery } from '../orm/modelDefaults.js'
10
-
11
- /**
12
- * Test harness: a tiny in-memory ModelLike with a `paginate`-shaped
13
- * query and basic CRUD methods that record their calls. Lets the
14
- * relationship tests assert the exact sequence of create / update /
15
- * delete operations against a shared fake without spinning up a
16
- * database.
17
- */
18
- interface FakeRecord extends Record<string, unknown> {
19
- id?: string | number
20
- }
21
-
22
- function makeFakeChildModel(initial: FakeRecord[] = []) {
23
- let nextId = 1
24
- const rows: FakeRecord[] = initial.map(r => ({ ...r }))
25
- const calls: Array<
26
- | { kind: 'create'; data: Record<string, unknown> }
27
- | { kind: 'update'; id: string | number; data: Record<string, unknown> }
28
- | { kind: 'delete'; id: string | number }
29
- > = []
30
-
31
- const model: ModelLike = {
32
- primaryKey: 'id',
33
- find: async (id) => rows.find(r => String(r.id) === String(id)) ?? null,
34
- create: async (data) => {
35
- calls.push({ kind: 'create', data: { ...data } })
36
- const id = (data['id'] as string | number | undefined) ?? `c${nextId++}`
37
- const fresh: FakeRecord = { ...data, id }
38
- rows.push(fresh)
39
- return fresh
40
- },
41
- update: async (id, data) => {
42
- calls.push({ kind: 'update', id, data: { ...data } })
43
- const idx = rows.findIndex(r => String(r.id) === String(id))
44
- if (idx >= 0) {
45
- rows[idx] = { ...rows[idx], ...data, id }
46
- }
47
- return rows[idx]
48
- },
49
- delete: async (id) => {
50
- calls.push({ kind: 'delete', id })
51
- const idx = rows.findIndex(r => String(r.id) === String(id))
52
- if (idx >= 0) rows.splice(idx, 1)
53
- },
54
- query: () => makeQuery(rows),
55
- }
56
-
57
- return { model, rows, calls }
58
- }
59
-
60
- /** Fake `ModelQuery` — only `paginate` is wired since that's all the
61
- * relationship pipeline calls. Other methods chain back to the same
62
- * query so `where` / `orderBy` are silently ignored. */
63
- function makeQuery(rows: FakeRecord[]): ModelQuery {
64
- const q: ModelQuery = {
65
- where: () => q,
66
- orWhere: () => q,
67
- orderBy: () => q,
68
- paginate: async () => ({ data: rows.slice(), total: rows.length }),
69
- }
70
- return q
71
- }
72
-
73
- /**
74
- * Fake parent model with a `relations` map matching the rudder ORM
75
- * convention. The `relatedQuery` override pipes through to the child
76
- * model's rows filtered by FK so calls behave like a real hasMany.
77
- */
78
- function makeFakeParentModel(opts: {
79
- childModel: ModelLike
80
- childRows: FakeRecord[]
81
- relationName: string
82
- foreignKey: string
83
- }): ModelLike {
84
- const { childModel, childRows, relationName, foreignKey } = opts
85
- const parent: ModelLike & { relations: Record<string, unknown> } = {
86
- primaryKey: 'id',
87
- find: async () => null,
88
- create: async () => ({}),
89
- update: async () => ({}),
90
- delete: async () => {},
91
- query: () => makeQuery([]),
92
- relatedQuery: (parentRecord) => {
93
- const parentId = (parentRecord as Record<string, unknown>)['id']
94
- const filtered = childRows.filter(r => String(r[foreignKey]) === String(parentId))
95
- return makeQuery(filtered)
96
- },
97
- relations: {
98
- [relationName]: { type: 'hasMany', model: () => childModel, foreignKey },
99
- },
100
- }
101
- return parent
102
- }
103
-
104
- describe('Repeater.relationship — extraction', () => {
105
- it('extractRelationshipRepeaters pulls the field value out of data', () => {
106
- const repeater = RepeaterField.make('items')
107
- .relationship('items')
108
- .schema([TextField.make('label').required()])
109
- const data: Record<string, unknown> = {
110
- title: 'Order #1',
111
- items: [{ __id: '1', label: 'A' }, { label: 'B' }],
112
- otherJsonRepeater: [{ x: 1 }],
113
- }
114
- const deferrals = extractRelationshipRepeaters([repeater], data)
115
- assert.equal(deferrals.length, 1)
116
- assert.equal(deferrals[0]!.cfg.name, 'items')
117
- assert.deepEqual(deferrals[0]!.rows, [{ __id: '1', label: 'A' }, { label: 'B' }])
118
- // Pulled out of `data`.
119
- assert.equal('items' in data, false)
120
- // Non-relationship keys untouched.
121
- assert.equal(data['title'], 'Order #1')
122
- assert.deepEqual(data['otherJsonRepeater'], [{ x: 1 }])
123
- })
124
-
125
- it('extractRelationshipRepeaters skips non-relationship Repeaters', () => {
126
- const json = RepeaterField.make('jsonItems').schema([TextField.make('x')])
127
- const rel = RepeaterField.make('relItems').relationship('relItems').schema([TextField.make('y')])
128
- const data: Record<string, unknown> = { jsonItems: [{ x: 1 }], relItems: [{ y: 2 }] }
129
- const deferrals = extractRelationshipRepeaters([json, rel], data)
130
- assert.equal(deferrals.length, 1)
131
- assert.equal(deferrals[0]!.cfg.name, 'relItems')
132
- assert.equal('jsonItems' in data, true)
133
- assert.equal('relItems' in data, false)
134
- })
135
- })
136
-
137
- describe('Repeater.relationship — full pipeline', () => {
138
- it('create — submits new rows with FK stamped, no existing rows', async () => {
139
- const child = makeFakeChildModel([])
140
- const parent = makeFakeParentModel({
141
- childModel: child.model,
142
- childRows: child.rows,
143
- relationName: 'items',
144
- foreignKey: 'orderId',
145
- })
146
-
147
- const form = Form.make()
148
- .schema([
149
- TextField.make('title'),
150
- RepeaterField.make('items').relationship('items').schema([
151
- TextField.make('label').required(),
152
- ]),
153
- ])
154
- .save(async (data) => {
155
- // Parent never sees the relationship key — extracted before save.
156
- assert.equal('items' in data, false)
157
- return { id: 'p1', title: data['title'] }
158
- })
159
-
160
- const result = await dispatchFormSubmit(
161
- form,
162
- { title: 'Order', items: [{ label: 'A' }, { label: 'B' }] },
163
- { values: { title: 'Order', items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
164
- )
165
-
166
- assert.equal(result.ok, true)
167
- // Two creates, no updates, no deletes.
168
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
169
- assert.equal(child.calls.filter(c => c.kind === 'update').length, 0)
170
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
171
- // FK stamped on each create payload.
172
- const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
173
- assert.equal(creates[0]!.data['orderId'], 'p1')
174
- assert.equal(creates[1]!.data['orderId'], 'p1')
175
- assert.equal(creates[0]!.data['label'], 'A')
176
- assert.equal(creates[1]!.data['label'], 'B')
177
- })
178
-
179
- it('update — submits __id matching existing PK; routed through update without overwriting FK', async () => {
180
- const child = makeFakeChildModel([
181
- { id: 'c1', orderId: 'p1', label: 'old A' },
182
- { id: 'c2', orderId: 'p1', label: 'old B' },
183
- ])
184
- const parent = makeFakeParentModel({
185
- childModel: child.model,
186
- childRows: child.rows,
187
- relationName: 'items',
188
- foreignKey: 'orderId',
189
- })
190
-
191
- const form = Form.make()
192
- .schema([
193
- RepeaterField.make('items').relationship('items').schema([
194
- TextField.make('label').required(),
195
- ]),
196
- ])
197
- .save(async () => ({ id: 'p1' }))
198
-
199
- const result = await dispatchFormSubmit(
200
- form,
201
- { items: [{ __id: 'c1', label: 'new A' }, { __id: 'c2', label: 'new B' }] },
202
- {
203
- values: { items: [{ __id: 'c1', label: 'new A' }, { __id: 'c2', label: 'new B' }] },
204
- record: { id: 'p1' },
205
- parentModel: parent,
206
- },
207
- )
208
- assert.equal(result.ok, true)
209
- const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
210
- assert.equal(updates.length, 2)
211
- // FK NOT in update payload — stays as it was on the existing row.
212
- assert.equal('orderId' in updates[0]!.data, false)
213
- assert.equal('orderId' in updates[1]!.data, false)
214
- assert.equal(updates[0]!.data['label'], 'new A')
215
- assert.equal(updates[1]!.data['label'], 'new B')
216
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 0)
217
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
218
- })
219
-
220
- it('delete — existing PK omitted from submitted set is deleted', async () => {
221
- const child = makeFakeChildModel([
222
- { id: 'c1', orderId: 'p1', label: 'A' },
223
- { id: 'c2', orderId: 'p1', label: 'B' },
224
- { id: 'c3', orderId: 'p1', label: 'C' },
225
- ])
226
- const parent = makeFakeParentModel({
227
- childModel: child.model,
228
- childRows: child.rows,
229
- relationName: 'items',
230
- foreignKey: 'orderId',
231
- })
232
-
233
- const form = Form.make()
234
- .schema([
235
- RepeaterField.make('items').relationship('items').schema([
236
- TextField.make('label').required(),
237
- ]),
238
- ])
239
- .save(async () => ({ id: 'p1' }))
240
-
241
- const result = await dispatchFormSubmit(
242
- form,
243
- { items: [{ __id: 'c1', label: 'A' }] },
244
- {
245
- values: { items: [{ __id: 'c1', label: 'A' }] },
246
- record: { id: 'p1' },
247
- parentModel: parent,
248
- },
249
- )
250
- assert.equal(result.ok, true)
251
- const deletes = child.calls.filter(c => c.kind === 'delete') as Array<{ kind: 'delete'; id: string | number }>
252
- assert.deepEqual(deletes.map(c => String(c.id)).sort(), ['c2', 'c3'])
253
- })
254
-
255
- it('mixed — single submit performs creates, updates, and deletes in one diff', async () => {
256
- const child = makeFakeChildModel([
257
- { id: 'c1', orderId: 'p1', label: 'A' },
258
- { id: 'c2', orderId: 'p1', label: 'B' },
259
- ])
260
- const parent = makeFakeParentModel({
261
- childModel: child.model,
262
- childRows: child.rows,
263
- relationName: 'items',
264
- foreignKey: 'orderId',
265
- })
266
-
267
- const form = Form.make()
268
- .schema([
269
- RepeaterField.make('items').relationship('items').schema([
270
- TextField.make('label').required(),
271
- ]),
272
- ])
273
- .save(async () => ({ id: 'p1' }))
274
-
275
- // c1 stays (renamed), c2 gone, plus a new row.
276
- const result = await dispatchFormSubmit(
277
- form,
278
- { items: [{ __id: 'c1', label: 'A renamed' }, { label: 'fresh' }] },
279
- {
280
- values: { items: [{ __id: 'c1', label: 'A renamed' }, { label: 'fresh' }] },
281
- record: { id: 'p1' },
282
- parentModel: parent,
283
- },
284
- )
285
- assert.equal(result.ok, true)
286
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 1)
287
- assert.equal(child.calls.filter(c => c.kind === 'update').length, 1)
288
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 1)
289
- // Spot-check the create stamps the FK.
290
- const created = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
291
- assert.equal(created.data['orderId'], 'p1')
292
- assert.equal(created.data['label'], 'fresh')
293
- })
294
-
295
- it('orderColumn writes 0-based index on every create + update', async () => {
296
- const child = makeFakeChildModel([
297
- { id: 'c1', orderId: 'p1', label: 'A', sort: 5 },
298
- ])
299
- const parent = makeFakeParentModel({
300
- childModel: child.model,
301
- childRows: child.rows,
302
- relationName: 'items',
303
- foreignKey: 'orderId',
304
- })
305
-
306
- const form = Form.make()
307
- .schema([
308
- RepeaterField.make('items')
309
- .relationship('items')
310
- .orderColumn('sort')
311
- .schema([TextField.make('label').required()]),
312
- ])
313
- .save(async () => ({ id: 'p1' }))
314
-
315
- const result = await dispatchFormSubmit(
316
- form,
317
- { items: [{ label: 'fresh first' }, { __id: 'c1', label: 'A second' }] },
318
- {
319
- values: { items: [{ label: 'fresh first' }, { __id: 'c1', label: 'A second' }] },
320
- record: { id: 'p1' },
321
- parentModel: parent,
322
- },
323
- )
324
- assert.equal(result.ok, true)
325
- const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
326
- const update = child.calls.find(c => c.kind === 'update') as { kind: 'update'; id: string | number; data: Record<string, unknown> }
327
- assert.equal(create.data['sort'], 0)
328
- assert.equal(update.data['sort'], 1)
329
- })
330
-
331
- it('throws when parentModel is missing on the FormContext', async () => {
332
- const form = Form.make()
333
- .schema([
334
- RepeaterField.make('items').relationship('items').schema([TextField.make('label')]),
335
- ])
336
- .save(async () => ({ id: 'p1' }))
337
-
338
- await assert.rejects(
339
- () => dispatchFormSubmit(
340
- form,
341
- { items: [{ label: 'A' }] },
342
- { values: { items: [{ label: 'A' }] } },
343
- ),
344
- /parentModel on the FormContext/,
345
- )
346
- })
347
-
348
- it('throws when descriptor lookup fails and no override is set', async () => {
349
- const child = makeFakeChildModel()
350
- // Parent missing the `relations` map entry for 'phantom'.
351
- const parent: ModelLike = {
352
- find: async () => null,
353
- create: async () => ({}),
354
- update: async () => ({}),
355
- delete: async () => {},
356
- query: () => makeQuery([]),
357
- relatedQuery: () => makeQuery([]),
358
- }
359
-
360
- const form = Form.make()
361
- .schema([
362
- RepeaterField.make('phantom').relationship('phantom').schema([TextField.make('x').required()]),
363
- ])
364
- .save(async () => ({ id: 'p1' }))
365
-
366
- await assert.rejects(
367
- () => dispatchFormSubmit(
368
- form,
369
- { phantom: [{ x: 'a' }] },
370
- {
371
- values: { phantom: [{ x: 'a' }] },
372
- parentModel: parent,
373
- },
374
- ),
375
- /could not resolve the child model/,
376
- )
377
- void child
378
- })
379
-
380
- it('honors explicit model + foreignKey overrides on the field config (no descriptor needed)', async () => {
381
- const child = makeFakeChildModel()
382
- // Parent with NO relations map — overrides have to carry the day.
383
- const parent: ModelLike = {
384
- primaryKey: 'id',
385
- find: async () => null,
386
- create: async () => ({}),
387
- update: async () => ({}),
388
- delete: async () => {},
389
- query: () => makeQuery([]),
390
- relatedQuery: () => makeQuery(child.rows),
391
- }
392
-
393
- const form = Form.make()
394
- .schema([
395
- RepeaterField.make('items')
396
- .relationship({ name: 'items', model: child.model, foreignKey: 'orderId' })
397
- .schema([TextField.make('label').required()]),
398
- ])
399
- .save(async () => ({ id: 'p1' }))
400
-
401
- const result = await dispatchFormSubmit(
402
- form,
403
- { items: [{ label: 'A' }] },
404
- {
405
- values: { items: [{ label: 'A' }] },
406
- parentModel: parent,
407
- },
408
- )
409
- assert.equal(result.ok, true)
410
- assert.equal(child.calls.length, 1)
411
- const created = child.calls[0] as { kind: 'create'; data: Record<string, unknown> }
412
- assert.equal(created.data['orderId'], 'p1')
413
- })
414
- })
415
-
416
- describe('Repeater.relationship — PK-switch renames (Phase B)', () => {
417
- it('emits a rename for each create when submitted __id differs from new PK', async () => {
418
- const child = makeFakeChildModel([])
419
- const parent = makeFakeParentModel({
420
- childModel: child.model,
421
- childRows: child.rows,
422
- relationName: 'items',
423
- foreignKey: 'orderId',
424
- })
425
-
426
- const form = Form.make()
427
- .schema([
428
- RepeaterField.make('items').relationship('items').schema([
429
- TextField.make('label').required(),
430
- ]),
431
- ])
432
- .save(async () => ({ id: 'p1' }))
433
-
434
- const result = await dispatchFormSubmit(
435
- form,
436
- // Renderer-minted UUIDs on the two new rows — Fake model assigns
437
- // `c1` / `c2` so the post-save renames swap the UUIDs to those.
438
- { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
439
- {
440
- values: { items: [{ __id: 'uuid-A', label: 'A' }, { __id: 'uuid-B', label: 'B' }] },
441
- parentModel: parent,
442
- },
443
- )
444
- assert.equal(result.ok, true)
445
- if (!result.ok) return
446
- assert.deepEqual(result.relationshipRenames, [
447
- { field: 'items', old: 'uuid-A', new: 'c1' },
448
- { field: 'items', old: 'uuid-B', new: 'c2' },
449
- ])
450
- })
451
-
452
- it('skips renames for rows that resolve as updates (submitted __id matches existing PK)', async () => {
453
- const child = makeFakeChildModel([
454
- { id: 'c1', orderId: 'p1', label: 'old' },
455
- ])
456
- const parent = makeFakeParentModel({
457
- childModel: child.model,
458
- childRows: child.rows,
459
- relationName: 'items',
460
- foreignKey: 'orderId',
461
- })
462
-
463
- const form = Form.make()
464
- .schema([
465
- RepeaterField.make('items').relationship('items').schema([
466
- TextField.make('label').required(),
467
- ]),
468
- ])
469
- .save(async () => ({ id: 'p1' }))
470
-
471
- const result = await dispatchFormSubmit(
472
- form,
473
- { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
474
- {
475
- values: { items: [{ __id: 'c1', label: 'new' }, { __id: 'uuid-X', label: 'fresh' }] },
476
- record: { id: 'p1' },
477
- parentModel: parent,
478
- },
479
- )
480
- assert.equal(result.ok, true)
481
- if (!result.ok) return
482
- // Update of c1 emits no rename; create from uuid-X resolves to a new id.
483
- assert.equal(result.relationshipRenames.length, 1)
484
- assert.equal(result.relationshipRenames[0]?.field, 'items')
485
- assert.equal(result.relationshipRenames[0]?.old, 'uuid-X')
486
- })
487
-
488
- it('skips the rename when the consumer pre-assigned the DB PK', async () => {
489
- const child = makeFakeChildModel([])
490
- // Pre-assign id `c1` on the submitted row — the fake model honors data.id.
491
- const parent = makeFakeParentModel({
492
- childModel: child.model,
493
- childRows: child.rows,
494
- relationName: 'items',
495
- foreignKey: 'orderId',
496
- })
497
-
498
- const form = Form.make()
499
- .schema([
500
- RepeaterField.make('items').relationship('items').schema([
501
- TextField.make('label').required(),
502
- ]),
503
- ])
504
- .save(async () => ({ id: 'p1' }))
505
-
506
- const result = await dispatchFormSubmit(
507
- form,
508
- { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
509
- {
510
- values: { items: [{ __id: 'c1', label: 'A', id: 'c1' }] },
511
- parentModel: parent,
512
- },
513
- )
514
- assert.equal(result.ok, true)
515
- if (!result.ok) return
516
- // Submitted __id already matches the PK — no rename to emit.
517
- assert.equal(result.relationshipRenames.length, 0)
518
- })
519
-
520
- it('returns an empty array on a parent save with no relationship fields', async () => {
521
- const form = Form.make()
522
- .schema([TextField.make('title')])
523
- .save(async () => ({ id: 'p1' }))
524
-
525
- const result = await dispatchFormSubmit(
526
- form,
527
- { title: 'Hello' },
528
- { values: { title: 'Hello' } },
529
- )
530
- assert.equal(result.ok, true)
531
- if (!result.ok) return
532
- assert.deepEqual(result.relationshipRenames, [])
533
- })
534
- })
535
-
536
- describe('Repeater.relationship — load (applyRelationshipRepeaterFill)', () => {
537
- it('stamps __id from PK and strips PK + FK from each row', async () => {
538
- const child = makeFakeChildModel([
539
- { id: 'c1', orderId: 'p1', label: 'A' },
540
- { id: 'c2', orderId: 'p1', label: 'B' },
541
- ])
542
- const parent = makeFakeParentModel({
543
- childModel: child.model,
544
- childRows: child.rows,
545
- relationName: 'items',
546
- foreignKey: 'orderId',
547
- })
548
-
549
- const form = Form.make().schema([
550
- TextField.make('title'),
551
- RepeaterField.make('items').relationship('items').schema([
552
- TextField.make('label').required(),
553
- ]),
554
- ])
555
-
556
- const out = await applyRelationshipRepeaterFill(
557
- form,
558
- { title: 'Order' },
559
- { id: 'p1' },
560
- parent,
561
- )
562
- assert.deepEqual(out['items'], [
563
- { __id: 'c1', label: 'A' },
564
- { __id: 'c2', label: 'B' },
565
- ])
566
- // Non-relationship values untouched.
567
- assert.equal(out['title'], 'Order')
568
- })
569
-
570
- it('no-op when record is null, parentModel is missing, or there are no relationship Repeaters', async () => {
571
- const form = Form.make().schema([
572
- RepeaterField.make('items').relationship('items').schema([TextField.make('label')]),
573
- ])
574
- const parent = makeFakeParentModel({
575
- childModel: makeFakeChildModel().model,
576
- childRows: [],
577
- relationName: 'items',
578
- foreignKey: 'orderId',
579
- })
580
- // null record
581
- assert.deepEqual(
582
- await applyRelationshipRepeaterFill(form, { x: 1 }, null, parent),
583
- { x: 1 },
584
- )
585
- // missing parentModel
586
- assert.deepEqual(
587
- await applyRelationshipRepeaterFill(form, { x: 1 }, { id: 'p1' }, undefined),
588
- { x: 1 },
589
- )
590
- // form without relationship Repeaters
591
- const plain = Form.make().schema([TextField.make('title')])
592
- assert.deepEqual(
593
- await applyRelationshipRepeaterFill(plain, { title: 't' }, { id: 'p1' }, parent),
594
- { title: 't' },
595
- )
596
- })
597
-
598
- it('loadRelationRows reads through resolveRelatedQuery (paginate)', async () => {
599
- const child = makeFakeChildModel([
600
- { id: 'c1', orderId: 'p1', label: 'A' },
601
- ])
602
- const parent = makeFakeParentModel({
603
- childModel: child.model,
604
- childRows: child.rows,
605
- relationName: 'items',
606
- foreignKey: 'orderId',
607
- })
608
- const rows = await loadRelationRows(parent, { id: 'p1' }, 'items')
609
- assert.equal(rows.length, 1)
610
- assert.equal((rows[0] as Record<string, unknown>)['label'], 'A')
611
- })
612
- })
613
-
614
- describe('Repeater.relationship — morphMany', () => {
615
- // Parent shape: `Order.items: morphMany(Item, 'itemable')` — child
616
- // carries `itemableId` + `itemableType` instead of an FK column.
617
- // `computeMorphPayload(parent, descriptor)` reads the discriminator off
618
- // the parent **record**'s `constructor.morphAlias ?? constructor.name`,
619
- // so the parent record returned by `Form.save()` has to be a class
620
- // instance (not a plain object literal).
621
- function makeMorphParentSetup(opts: {
622
- childModel: ModelLike
623
- childRows: FakeRecord[]
624
- relationName: string
625
- morphName: string
626
- }) {
627
- const { childModel, childRows, relationName, morphName } = opts
628
- const idCol = `${morphName}Id`
629
- const typeCol = `${morphName}Type`
630
-
631
- class Order {
632
- id?: string
633
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
634
- }
635
-
636
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
637
- primaryKey: 'id',
638
- find: async () => null,
639
- create: async () => ({}),
640
- update: async () => ({}),
641
- delete: async () => {},
642
- query: () => makeQuery([]),
643
- relatedQuery: (parentRecord) => {
644
- const parentId = (parentRecord as Record<string, unknown>)['id']
645
- const parentType = (parentRecord as { constructor?: { morphAlias?: string; name?: string } })
646
- .constructor?.morphAlias
647
- ?? (parentRecord as { constructor?: { name?: string } }).constructor?.name
648
- const filtered = childRows.filter(r =>
649
- String(r[idCol]) === String(parentId) && r[typeCol] === parentType,
650
- )
651
- return makeQuery(filtered)
652
- },
653
- relations: {
654
- [relationName]: { type: 'morphMany', morphName, model: () => childModel },
655
- },
656
- }
657
- return { parentModel, makeRecord: (id: string) => new Order({ id }) }
658
- }
659
-
660
- it('create — stamps <morphName>Id + <morphName>Type instead of an FK column', async () => {
661
- const child = makeFakeChildModel([])
662
- const { parentModel, makeRecord } = makeMorphParentSetup({
663
- childModel: child.model, childRows: child.rows,
664
- relationName: 'items', morphName: 'itemable',
665
- })
666
-
667
- const form = Form.make()
668
- .schema([
669
- RepeaterField.make('items')
670
- .relationship('items')
671
- .schema([TextField.make('label').required()]),
672
- ])
673
- .save(async () => makeRecord('p1'))
674
-
675
- const submittedRows = [{ label: 'A' }, { label: 'B' }]
676
- const result = await dispatchFormSubmit(
677
- form,
678
- { items: submittedRows },
679
- { values: { items: submittedRows }, parentModel },
680
- )
681
- assert.equal(result.ok, true)
682
- const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
683
- assert.equal(creates.length, 2)
684
- for (const c of creates) {
685
- assert.equal(c.data['itemableId'], 'p1')
686
- assert.equal(c.data['itemableType'], 'Order')
687
- assert.equal('orderId' in c.data, false)
688
- }
689
- assert.equal(creates[0]!.data['label'], 'A')
690
- assert.equal(creates[1]!.data['label'], 'B')
691
- })
692
-
693
- it('update — does not overwrite morph cols on update (defense against re-link)', async () => {
694
- const child = makeFakeChildModel([
695
- { id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
696
- { id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
697
- ])
698
- const { parentModel, makeRecord } = makeMorphParentSetup({
699
- childModel: child.model, childRows: child.rows,
700
- relationName: 'items', morphName: 'itemable',
701
- })
702
-
703
- const form = Form.make()
704
- .schema([
705
- RepeaterField.make('items')
706
- .relationship('items')
707
- .schema([TextField.make('label')]),
708
- ])
709
- .save(async () => makeRecord('p1'))
710
-
711
- const submittedRows = [
712
- // Tampered client tries to send itemableType=Invoice; framework wins last.
713
- { __id: 'c1', label: 'A2', itemableType: 'Invoice' },
714
- { __id: 'c2', label: 'B2' },
715
- ]
716
- const result = await dispatchFormSubmit(
717
- form,
718
- { items: submittedRows },
719
- { values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
720
- )
721
- assert.equal(result.ok, true)
722
- const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
723
- assert.equal(updates.length, 2)
724
- for (const u of updates) {
725
- assert.equal('itemableId' in u.data, false)
726
- assert.equal('itemableType' in u.data, false)
727
- }
728
- })
729
-
730
- it('delete — existing PKs missing from submitted set are deleted (same shape as hasMany)', async () => {
731
- const child = makeFakeChildModel([
732
- { id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
733
- { id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
734
- { id: 'c3', itemableId: 'p1', itemableType: 'Order', label: 'C' },
735
- ])
736
- const { parentModel, makeRecord } = makeMorphParentSetup({
737
- childModel: child.model, childRows: child.rows,
738
- relationName: 'items', morphName: 'itemable',
739
- })
740
-
741
- const form = Form.make()
742
- .schema([
743
- RepeaterField.make('items')
744
- .relationship('items')
745
- .schema([TextField.make('label')]),
746
- ])
747
- .save(async () => makeRecord('p1'))
748
-
749
- const submittedRows = [{ __id: 'c1', label: 'A' }]
750
- const result = await dispatchFormSubmit(
751
- form,
752
- { items: submittedRows },
753
- { values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
754
- )
755
- assert.equal(result.ok, true)
756
- const deletes = child.calls.filter(c => c.kind === 'delete') as Array<{ kind: 'delete'; id: string | number }>
757
- assert.deepEqual(deletes.map(c => String(c.id)).sort(), ['c2', 'c3'])
758
- })
759
-
760
- it('orderColumn writes 0-based index on every morph create + update', async () => {
761
- const child = makeFakeChildModel([
762
- { id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A', sort: 5 },
763
- ])
764
- const { parentModel, makeRecord } = makeMorphParentSetup({
765
- childModel: child.model, childRows: child.rows,
766
- relationName: 'items', morphName: 'itemable',
767
- })
768
-
769
- const form = Form.make()
770
- .schema([
771
- RepeaterField.make('items')
772
- .relationship('items')
773
- .orderColumn('sort')
774
- .schema([TextField.make('label')]),
775
- ])
776
- .save(async () => makeRecord('p1'))
777
-
778
- const submittedRows = [
779
- { label: 'first' },
780
- { __id: 'c1', label: 'second' },
781
- ]
782
- const result = await dispatchFormSubmit(
783
- form,
784
- { items: submittedRows },
785
- { values: { items: submittedRows }, record: makeRecord('p1'), parentModel },
786
- )
787
- assert.equal(result.ok, true)
788
- const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
789
- const update = child.calls.find(c => c.kind === 'update') as { kind: 'update'; id: string | number; data: Record<string, unknown> }
790
- assert.equal(create.data['sort'], 0)
791
- assert.equal(update.data['sort'], 1)
792
- })
793
-
794
- it('morphType — explicit override on the relation entry wins over constructor name', async () => {
795
- const child = makeFakeChildModel([])
796
- class Order {
797
- id?: string
798
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
799
- }
800
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
801
- primaryKey: 'id',
802
- find: async () => null,
803
- create: async () => ({}),
804
- update: async () => ({}),
805
- delete: async () => {},
806
- query: () => makeQuery([]),
807
- relatedQuery: () => makeQuery([]),
808
- relations: {
809
- items: { type: 'morphMany', morphName: 'itemable', morphType: 'CustomDiscriminator', model: () => child.model },
810
- },
811
- }
812
-
813
- const form = Form.make()
814
- .schema([
815
- RepeaterField.make('items')
816
- .relationship('items')
817
- .schema([TextField.make('label')]),
818
- ])
819
- .save(async () => new Order({ id: 'p1' }))
820
-
821
- const submittedRows = [{ label: 'A' }]
822
- const result = await dispatchFormSubmit(
823
- form,
824
- { items: submittedRows },
825
- { values: { items: submittedRows }, parentModel },
826
- )
827
- assert.equal(result.ok, true)
828
- const create = child.calls.find(c => c.kind === 'create') as { kind: 'create'; data: Record<string, unknown> }
829
- assert.equal(create.data['itemableType'], 'CustomDiscriminator')
830
- })
831
-
832
- it('load — applyRelationshipRepeaterFill strips morph cols from rendered rows', async () => {
833
- const child = makeFakeChildModel([
834
- { id: 'c1', itemableId: 'p1', itemableType: 'Order', label: 'A' },
835
- { id: 'c2', itemableId: 'p1', itemableType: 'Order', label: 'B' },
836
- ])
837
- const { parentModel, makeRecord } = makeMorphParentSetup({
838
- childModel: child.model, childRows: child.rows,
839
- relationName: 'items', morphName: 'itemable',
840
- })
841
-
842
- const form = Form.make().schema([
843
- TextField.make('title'),
844
- RepeaterField.make('items')
845
- .relationship('items')
846
- .schema([TextField.make('label')]),
847
- ])
848
-
849
- const out = await applyRelationshipRepeaterFill(form, { title: 'P' }, makeRecord('p1'), parentModel)
850
- assert.deepEqual(out['items'], [
851
- { __id: 'c1', label: 'A' },
852
- { __id: 'c2', label: 'B' },
853
- ])
854
- // Morph cols should NOT leak into the rendered row payload.
855
- for (const row of out['items'] as Array<Record<string, unknown>>) {
856
- assert.equal('itemableId' in row, false)
857
- assert.equal('itemableType' in row, false)
858
- assert.equal('id' in row, false)
859
- }
860
- })
861
-
862
- it('morphMany config without the model thunk surfaces a clear error', async () => {
863
- const child = makeFakeChildModel([])
864
- class Order {
865
- id?: string
866
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
867
- }
868
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
869
- primaryKey: 'id',
870
- find: async () => null,
871
- create: async () => ({}),
872
- update: async () => ({}),
873
- delete: async () => {},
874
- query: () => makeQuery([]),
875
- relatedQuery: () => makeQuery([]),
876
- relations: {
877
- // No `model` thunk — getMorphRelationDescriptor returns
878
- // undefined, so the resolver falls through to the hasMany
879
- // branch which then asks for foreignKey. The user-facing fix
880
- // is the same: configure-the-relation.
881
- items: { type: 'morphMany', morphName: 'itemable' },
882
- },
883
- }
884
-
885
- const form = Form.make()
886
- .schema([
887
- RepeaterField.make('items')
888
- .relationship('items')
889
- .schema([TextField.make('label')]),
890
- ])
891
- .save(async () => new Order({ id: 'p1' }))
892
-
893
- const submittedRows = [{ label: 'A' }]
894
- await assert.rejects(
895
- () => dispatchFormSubmit(
896
- form,
897
- { items: submittedRows },
898
- { values: { items: submittedRows }, parentModel },
899
- ),
900
- /could not resolve the child model/,
901
- )
902
- void child
903
- })
904
- })
905
-
906
- /**
907
- * Test harness for M2M relations — `parent[rel]()` returns a recorded
908
- * accessor with `attach` / `detach` / `sync`. Mirrors `_makeBelongsToManyAccessor`
909
- * from the rudder ORM (the per-relation accessor returned by
910
- * `Model.belongsToMany`). Tests assert against `pivotCalls` directly so
911
- * we can verify the exact sequence of operations against the pivot.
912
- */
913
- function makeM2MParentSetup(opts: {
914
- childModel: ModelLike
915
- childRows: FakeRecord[]
916
- relationName: string
917
- /** When set, the related rows on load are filtered to those whose
918
- * PK appears in the pivot. Lets `applyRelationshipRepeaterFill`
919
- * return the right slice. */
920
- attachedIds?: Set<string | number>
921
- }) {
922
- const { childModel, childRows, relationName } = opts
923
- const attachedIds = opts.attachedIds ?? new Set<string | number>(childRows.map(r => r['id'] as string | number))
924
- const pivotCalls: Array<
925
- | { kind: 'attach'; ids: Array<string | number> }
926
- | { kind: 'detach'; ids: Array<string | number> | undefined }
927
- | { kind: 'sync'; desired: Array<string | number> }
928
- > = []
929
-
930
- class Parent {
931
- id?: string
932
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
933
- [relationName]() {
934
- return {
935
- attach: async (input: ReadonlyArray<string | number> | Record<string, Record<string, unknown>>) => {
936
- const ids = Array.isArray(input)
937
- ? [...input]
938
- : Object.keys(input).map(k => /^\d+$/.test(k) ? Number(k) : k)
939
- for (const id of ids) attachedIds.add(id)
940
- pivotCalls.push({ kind: 'attach', ids })
941
- },
942
- detach: async (ids?: ReadonlyArray<string | number>) => {
943
- if (ids === undefined) {
944
- const removed = [...attachedIds]
945
- attachedIds.clear()
946
- pivotCalls.push({ kind: 'detach', ids: undefined })
947
- return removed.length
948
- }
949
- for (const id of ids) attachedIds.delete(id)
950
- pivotCalls.push({ kind: 'detach', ids: [...ids] })
951
- return ids.length
952
- },
953
- sync: async (desiredIds: ReadonlyArray<string | number>) => {
954
- pivotCalls.push({ kind: 'sync', desired: [...desiredIds] })
955
- return { attached: [], detached: [] }
956
- },
957
- }
958
- }
959
- }
960
-
961
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
962
- primaryKey: 'id',
963
- find: async () => null,
964
- create: async () => ({}),
965
- update: async () => ({}),
966
- delete: async () => {},
967
- query: () => makeQuery([]),
968
- // Resolve "currently attached" rows for the parent — read from the
969
- // pivot snapshot. Drives both `loadRelationRows` (in the diff loop)
970
- // and `applyRelationshipRepeaterFill` (in load mode).
971
- relatedQuery: () => makeQuery(childRows.filter(r => attachedIds.has(r['id'] as string | number))),
972
- relations: {
973
- [relationName]: { type: 'belongsToMany', model: () => childModel, pivotTable: 'pivot' },
974
- },
975
- }
976
- return {
977
- parentModel,
978
- pivotCalls,
979
- attachedIds,
980
- makeRecord: (id: string) => new Parent({ id }),
981
- }
982
- }
983
-
984
- describe('Repeater.relationship — belongsToMany', () => {
985
- // Parent shape: `Article.tags: belongsToMany(Tag, pivotTable: 'article_tag')`.
986
- // Child Tag rows have NO FK column — pivot table holds the link.
987
- // Submit semantics: create-row → M.create + accessor.attach; update-row
988
- // → M.update (pivot untouched); delete-row → accessor.detach (no
989
- // M.delete, child may be attached to other parents).
990
-
991
- it('create — M.create the related child then attach via accessor (no FK on payload)', async () => {
992
- const child = makeFakeChildModel([])
993
- const setup = makeM2MParentSetup({
994
- childModel: child.model,
995
- childRows: child.rows,
996
- relationName: 'tags',
997
- attachedIds: new Set(),
998
- })
999
-
1000
- const form = Form.make()
1001
- .schema([
1002
- RepeaterField.make('tags')
1003
- .relationship('tags')
1004
- .schema([TextField.make('name').required()]),
1005
- ])
1006
- .save(async () => setup.makeRecord('a1'))
1007
-
1008
- const submittedRows = [{ name: 'red' }, { name: 'blue' }]
1009
- const result = await dispatchFormSubmit(
1010
- form,
1011
- { tags: submittedRows },
1012
- { values: { tags: submittedRows }, parentModel: setup.parentModel },
1013
- )
1014
- assert.equal(result.ok, true)
1015
- const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
1016
- assert.equal(creates.length, 2)
1017
- assert.equal(creates[0]!.data['name'], 'red')
1018
- assert.equal(creates[1]!.data['name'], 'blue')
1019
- // No FK / morph cols stamped on the related child — pivot covers it.
1020
- for (const c of creates) {
1021
- assert.equal('articleId' in c.data, false)
1022
- assert.equal('taggableId' in c.data, false)
1023
- assert.equal('taggableType' in c.data, false)
1024
- }
1025
- // One attach per new row, in row order.
1026
- const attachCalls = setup.pivotCalls.filter(c => c.kind === 'attach') as Array<{ kind: 'attach'; ids: Array<string | number> }>
1027
- assert.equal(attachCalls.length, 2)
1028
- assert.equal(attachCalls[0]!.ids.length, 1)
1029
- assert.equal(attachCalls[1]!.ids.length, 1)
1030
- // No pivot detach.
1031
- assert.equal(setup.pivotCalls.filter(c => c.kind === 'detach').length, 0)
1032
- })
1033
-
1034
- it('update — __id matching an attached PK routes through M.update; pivot untouched', async () => {
1035
- const child = makeFakeChildModel([
1036
- { id: 'c1', name: 'red' },
1037
- { id: 'c2', name: 'blue' },
1038
- ])
1039
- const setup = makeM2MParentSetup({
1040
- childModel: child.model,
1041
- childRows: child.rows,
1042
- relationName: 'tags',
1043
- attachedIds: new Set(['c1', 'c2']),
1044
- })
1045
-
1046
- const form = Form.make()
1047
- .schema([
1048
- RepeaterField.make('tags')
1049
- .relationship('tags')
1050
- .schema([TextField.make('name')]),
1051
- ])
1052
- .save(async () => setup.makeRecord('a1'))
1053
-
1054
- const submittedRows = [
1055
- { __id: 'c1', name: 'crimson' },
1056
- { __id: 'c2', name: 'navy' },
1057
- ]
1058
- const result = await dispatchFormSubmit(
1059
- form,
1060
- { tags: submittedRows },
1061
- { values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
1062
- )
1063
- assert.equal(result.ok, true)
1064
- const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
1065
- assert.equal(updates.length, 2)
1066
- assert.equal(updates[0]!.data['name'], 'crimson')
1067
- assert.equal(updates[1]!.data['name'], 'navy')
1068
- // No pivot operations — update doesn't touch attach/detach.
1069
- assert.equal(setup.pivotCalls.length, 0)
1070
- })
1071
-
1072
- it('delete — existing attached PK omitted from submitted set is detached only (no M.delete)', async () => {
1073
- const child = makeFakeChildModel([
1074
- { id: 'c1', name: 'red' },
1075
- { id: 'c2', name: 'blue' },
1076
- { id: 'c3', name: 'green' },
1077
- ])
1078
- const setup = makeM2MParentSetup({
1079
- childModel: child.model,
1080
- childRows: child.rows,
1081
- relationName: 'tags',
1082
- attachedIds: new Set(['c1', 'c2', 'c3']),
1083
- })
1084
-
1085
- const form = Form.make()
1086
- .schema([
1087
- RepeaterField.make('tags')
1088
- .relationship('tags')
1089
- .schema([TextField.make('name')]),
1090
- ])
1091
- .save(async () => setup.makeRecord('a1'))
1092
-
1093
- const submittedRows = [{ __id: 'c1', name: 'red' }]
1094
- const result = await dispatchFormSubmit(
1095
- form,
1096
- { tags: submittedRows },
1097
- { values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
1098
- )
1099
- assert.equal(result.ok, true)
1100
- // No M.delete on the related child — only pivot detach.
1101
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
1102
- const detachCalls = setup.pivotCalls.filter(c => c.kind === 'detach') as Array<{ kind: 'detach'; ids: Array<string | number> | undefined }>
1103
- // Each missing PK gets its own detach call.
1104
- const detachedIds = detachCalls
1105
- .flatMap(c => c.ids ?? [])
1106
- .map(id => String(id))
1107
- .sort()
1108
- assert.deepEqual(detachedIds, ['c2', 'c3'])
1109
- })
1110
-
1111
- it('mixed — single submit performs create+attach, update, and detach in one diff', async () => {
1112
- const child = makeFakeChildModel([
1113
- { id: 'c1', name: 'red' },
1114
- { id: 'c2', name: 'blue' },
1115
- ])
1116
- const setup = makeM2MParentSetup({
1117
- childModel: child.model,
1118
- childRows: child.rows,
1119
- relationName: 'tags',
1120
- attachedIds: new Set(['c1', 'c2']),
1121
- })
1122
-
1123
- const form = Form.make()
1124
- .schema([
1125
- RepeaterField.make('tags')
1126
- .relationship('tags')
1127
- .schema([TextField.make('name')]),
1128
- ])
1129
- .save(async () => setup.makeRecord('a1'))
1130
-
1131
- const submittedRows = [
1132
- { __id: 'c1', name: 'crimson' },
1133
- { name: 'fresh' },
1134
- ]
1135
- const result = await dispatchFormSubmit(
1136
- form,
1137
- { tags: submittedRows },
1138
- { values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
1139
- )
1140
- assert.equal(result.ok, true)
1141
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 1)
1142
- assert.equal(child.calls.filter(c => c.kind === 'update').length, 1)
1143
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
1144
- assert.equal(setup.pivotCalls.filter(c => c.kind === 'attach').length, 1)
1145
- const detachCalls = setup.pivotCalls.filter(c => c.kind === 'detach') as Array<{ kind: 'detach'; ids: Array<string | number> | undefined }>
1146
- const detachedIds = detachCalls.flatMap(c => c.ids ?? []).map(id => String(id))
1147
- assert.deepEqual(detachedIds, ['c2'])
1148
- })
1149
-
1150
- it('descriptor lookup — explicit cfg.model wins over the relation entry thunk', async () => {
1151
- const child = makeFakeChildModel([])
1152
- const otherChild = makeFakeChildModel([])
1153
- const setup = makeM2MParentSetup({
1154
- childModel: child.model,
1155
- childRows: child.rows,
1156
- relationName: 'tags',
1157
- attachedIds: new Set(),
1158
- })
1159
-
1160
- const form = Form.make()
1161
- .schema([
1162
- RepeaterField.make('tags')
1163
- .relationship({ name: 'tags', model: otherChild.model })
1164
- .schema([TextField.make('name')]),
1165
- ])
1166
- .save(async () => setup.makeRecord('a1'))
1167
-
1168
- const submittedRows = [{ name: 'red' }]
1169
- const result = await dispatchFormSubmit(
1170
- form,
1171
- { tags: submittedRows },
1172
- { values: { tags: submittedRows }, parentModel: setup.parentModel },
1173
- )
1174
- assert.equal(result.ok, true)
1175
- // Override model received the create, NOT the descriptor's model.
1176
- assert.equal(otherChild.calls.filter(c => c.kind === 'create').length, 1)
1177
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 0)
1178
- })
1179
-
1180
- it('orderColumn — rejected under M2M v1 with a clear error', async () => {
1181
- const child = makeFakeChildModel([])
1182
- const setup = makeM2MParentSetup({
1183
- childModel: child.model,
1184
- childRows: child.rows,
1185
- relationName: 'tags',
1186
- attachedIds: new Set(),
1187
- })
1188
-
1189
- const form = Form.make()
1190
- .schema([
1191
- RepeaterField.make('tags')
1192
- .relationship('tags')
1193
- .orderColumn('sort')
1194
- .schema([TextField.make('name')]),
1195
- ])
1196
- .save(async () => setup.makeRecord('a1'))
1197
-
1198
- const submittedRows = [{ name: 'red' }]
1199
- await assert.rejects(
1200
- () => dispatchFormSubmit(
1201
- form,
1202
- { tags: submittedRows },
1203
- { values: { tags: submittedRows }, parentModel: setup.parentModel },
1204
- ),
1205
- /orderColumn\(\) is not supported under 'belongsToMany'/,
1206
- )
1207
- })
1208
-
1209
- it('missing accessor — clear error when parent exposes neither parent[rel]() nor a legacy related() shape', async () => {
1210
- const child = makeFakeChildModel([])
1211
- // Parent missing the prototype `tags()` method AND missing `related`.
1212
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
1213
- primaryKey: 'id',
1214
- find: async () => null,
1215
- create: async () => ({}),
1216
- update: async () => ({}),
1217
- delete: async () => {},
1218
- query: () => makeQuery([]),
1219
- relatedQuery: () => makeQuery([]),
1220
- relations: {
1221
- tags: { type: 'belongsToMany', model: () => child.model, pivotTable: 'pivot' },
1222
- },
1223
- }
1224
-
1225
- const form = Form.make()
1226
- .schema([
1227
- RepeaterField.make('tags')
1228
- .relationship('tags')
1229
- .schema([TextField.make('name')]),
1230
- ])
1231
- .save(async () => ({ id: 'a1' }))
1232
-
1233
- const submittedRows = [{ name: 'red' }]
1234
- await assert.rejects(
1235
- () => dispatchFormSubmit(
1236
- form,
1237
- { tags: submittedRows },
1238
- { values: { tags: submittedRows }, parentModel },
1239
- ),
1240
- /could not resolve the pivot-mutation accessor/,
1241
- )
1242
- })
1243
- })
1244
-
1245
- describe('Repeater.relationship — morphToMany', () => {
1246
- // Parent shape: `Post.tags: morphToMany(Tag, pivotTable: 'taggable',
1247
- // morphName: 'taggable')`. The accessor handles the polymorphic stamp
1248
- // on the pivot row internally — pilotiq doesn't see the morph cols.
1249
- // Behavior is identical to belongsToMany from pilotiq's perspective.
1250
-
1251
- it('create — same path as belongsToMany; the accessor handles polymorphic stamping internally', async () => {
1252
- const child = makeFakeChildModel([])
1253
- const attachedIds = new Set<string | number>()
1254
- const pivotCalls: Array<{ kind: 'attach'; ids: Array<string | number> }> = []
1255
- class Post {
1256
- id?: string
1257
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
1258
- tags() {
1259
- return {
1260
- attach: async (input: ReadonlyArray<string | number>) => {
1261
- for (const id of input) attachedIds.add(id)
1262
- pivotCalls.push({ kind: 'attach', ids: [...input] })
1263
- },
1264
- detach: async () => 0,
1265
- sync: async () => ({ attached: [], detached: [] }),
1266
- }
1267
- }
1268
- }
1269
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
1270
- primaryKey: 'id',
1271
- find: async () => null,
1272
- create: async () => ({}),
1273
- update: async () => ({}),
1274
- delete: async () => {},
1275
- query: () => makeQuery([]),
1276
- relatedQuery: () => makeQuery([]),
1277
- relations: {
1278
- tags: { type: 'morphToMany', model: () => child.model, pivotTable: 'taggable', morphName: 'taggable' },
1279
- },
1280
- }
1281
-
1282
- const form = Form.make()
1283
- .schema([
1284
- RepeaterField.make('tags')
1285
- .relationship('tags')
1286
- .schema([TextField.make('name').required()]),
1287
- ])
1288
- .save(async () => new Post({ id: 'p1' }))
1289
-
1290
- const submittedRows = [{ name: 'red' }, { name: 'blue' }]
1291
- const result = await dispatchFormSubmit(
1292
- form,
1293
- { tags: submittedRows },
1294
- { values: { tags: submittedRows }, parentModel },
1295
- )
1296
- assert.equal(result.ok, true)
1297
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
1298
- assert.equal(pivotCalls.filter(c => c.kind === 'attach').length, 2)
1299
- })
1300
- })
1301
-
1302
- describe('Repeater.relationship — morphedByMany', () => {
1303
- // Parent shape: `Tag.posts: morphedByMany(Post, pivotTable: 'taggable',
1304
- // morphName: 'taggable')`. Inverse polymorphic side. Same accessor surface.
1305
-
1306
- it('detach-only on row removal (parallel to belongsToMany / morphToMany)', async () => {
1307
- const child = makeFakeChildModel([
1308
- { id: 'c1', title: 'first' },
1309
- { id: 'c2', title: 'second' },
1310
- ])
1311
- const attachedIds = new Set<string | number>(['c1', 'c2'])
1312
- const pivotCalls: Array<{ kind: 'detach'; ids: Array<string | number> | undefined }> = []
1313
- class Tag {
1314
- id?: string
1315
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
1316
- posts() {
1317
- return {
1318
- attach: async () => {},
1319
- detach: async (ids?: ReadonlyArray<string | number>) => {
1320
- if (ids === undefined) { attachedIds.clear(); pivotCalls.push({ kind: 'detach', ids: undefined }); return 0 }
1321
- for (const id of ids) attachedIds.delete(id)
1322
- pivotCalls.push({ kind: 'detach', ids: [...ids] })
1323
- return ids.length
1324
- },
1325
- sync: async () => ({ attached: [], detached: [] }),
1326
- }
1327
- }
1328
- }
1329
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
1330
- primaryKey: 'id',
1331
- find: async () => null,
1332
- create: async () => ({}),
1333
- update: async () => ({}),
1334
- delete: async () => {},
1335
- query: () => makeQuery([]),
1336
- relatedQuery: () => makeQuery(child.rows.filter(r => attachedIds.has(r['id'] as string | number))),
1337
- relations: {
1338
- posts: { type: 'morphedByMany', model: () => child.model, pivotTable: 'taggable', morphName: 'taggable' },
1339
- },
1340
- }
1341
-
1342
- const form = Form.make()
1343
- .schema([
1344
- RepeaterField.make('posts')
1345
- .relationship('posts')
1346
- .schema([TextField.make('title')]),
1347
- ])
1348
- .save(async () => new Tag({ id: 't1' }))
1349
-
1350
- const submittedRows = [{ __id: 'c1', title: 'first' }]
1351
- const result = await dispatchFormSubmit(
1352
- form,
1353
- { posts: submittedRows },
1354
- { values: { posts: submittedRows }, record: new Tag({ id: 't1' }), parentModel },
1355
- )
1356
- assert.equal(result.ok, true)
1357
- // Detach c2; never touch M.delete.
1358
- assert.equal(child.calls.filter(c => c.kind === 'delete').length, 0)
1359
- const detachedIds = pivotCalls.flatMap(c => c.ids ?? []).map(id => String(id))
1360
- assert.deepEqual(detachedIds, ['c2'])
1361
- })
1362
- })
1363
-
1364
- /**
1365
- * Test harness for M2M pivot-extras — `withPivot(...cols)` projection on
1366
- * the load query + `updatePivot(id, data)` + per-id-pivot `attach({ id:
1367
- * data })` on the accessor. Mirrors rudder ORM's
1368
- * `feat(orm): pivot-extras read/update + per-id sync` (PR #251).
1369
- *
1370
- * Each child row has an associated pivot row keyed by the child's PK.
1371
- * `withPivot` stamps the listed pivot columns onto each row under
1372
- * `row.pivot = { … }`. `updatePivot` patches the matching pivot row.
1373
- */
1374
- function makeM2MParentSetupWithPivot(opts: {
1375
- childModel: ModelLike
1376
- childRows: FakeRecord[]
1377
- relationName: string
1378
- /** Pivot rows keyed by child PK. Each entry holds the extra columns. */
1379
- pivot: Map<string, Record<string, unknown>>
1380
- attachedIds?: Set<string | number>
1381
- }) {
1382
- const { childModel, childRows, relationName, pivot } = opts
1383
- const attachedIds = opts.attachedIds
1384
- ?? new Set<string | number>(childRows.map(r => r['id'] as string | number))
1385
- const pivotCalls: Array<
1386
- | { kind: 'attach'; ids: Array<string | number>; pivot?: Record<string, Record<string, unknown>> }
1387
- | { kind: 'detach'; ids: Array<string | number> | undefined }
1388
- | { kind: 'updatePivot'; id: string | number; data: Record<string, unknown> }
1389
- > = []
1390
-
1391
- function projectPivot(rows: FakeRecord[], cols: string[]): FakeRecord[] {
1392
- return rows.map(r => {
1393
- const pk = String(r['id'])
1394
- const pe = pivot.get(pk) ?? {}
1395
- const proj: Record<string, unknown> = {}
1396
- for (const c of cols) proj[c] = pe[c] ?? null
1397
- return { ...r, pivot: proj }
1398
- })
1399
- }
1400
-
1401
- /** Pivot-aware fake query — adds `withPivot` to the chain. */
1402
- function makePivotAwareQuery(rows: FakeRecord[]): ModelQuery {
1403
- let pivotCols: string[] | undefined
1404
- const q: ModelQuery = {
1405
- where: () => q,
1406
- orWhere: () => q,
1407
- orderBy: () => q,
1408
- withPivot(...cols: string[]) {
1409
- pivotCols = cols
1410
- return q
1411
- },
1412
- paginate: async () => {
1413
- const projected = pivotCols ? projectPivot(rows, pivotCols) : rows.slice()
1414
- return { data: projected, total: projected.length }
1415
- },
1416
- }
1417
- return q
1418
- }
1419
-
1420
- class Parent {
1421
- id?: string
1422
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
1423
- [relationName]() {
1424
- return {
1425
- attach: async (input: ReadonlyArray<string | number> | Record<string, Record<string, unknown>>) => {
1426
- if (Array.isArray(input)) {
1427
- const ids = [...input]
1428
- for (const id of ids) attachedIds.add(id)
1429
- pivotCalls.push({ kind: 'attach', ids })
1430
- } else {
1431
- const map = input as Record<string, Record<string, unknown>>
1432
- const ids = Object.keys(map).map(k => /^\d+$/.test(k) ? Number(k) : k) as Array<string | number>
1433
- for (const id of ids) {
1434
- attachedIds.add(id)
1435
- pivot.set(String(id), { ...(pivot.get(String(id)) ?? {}), ...map[String(id)] })
1436
- }
1437
- pivotCalls.push({ kind: 'attach', ids, pivot: map })
1438
- }
1439
- },
1440
- detach: async (ids?: ReadonlyArray<string | number>) => {
1441
- if (ids === undefined) {
1442
- const removed = [...attachedIds]
1443
- attachedIds.clear()
1444
- for (const id of removed) pivot.delete(String(id))
1445
- pivotCalls.push({ kind: 'detach', ids: undefined })
1446
- return removed.length
1447
- }
1448
- for (const id of ids) {
1449
- attachedIds.delete(id)
1450
- pivot.delete(String(id))
1451
- }
1452
- pivotCalls.push({ kind: 'detach', ids: [...ids] })
1453
- return ids.length
1454
- },
1455
- updatePivot: async (id: string | number, data: Record<string, unknown>): Promise<number> => {
1456
- pivotCalls.push({ kind: 'updatePivot', id, data: { ...data } })
1457
- const key = String(id)
1458
- if (!pivot.has(key)) return 0
1459
- pivot.set(key, { ...pivot.get(key), ...data })
1460
- return 1
1461
- },
1462
- }
1463
- }
1464
- }
1465
-
1466
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
1467
- primaryKey: 'id',
1468
- find: async () => null,
1469
- create: async () => ({}),
1470
- update: async () => ({}),
1471
- delete: async () => {},
1472
- query: () => makeQuery([]),
1473
- relatedQuery: () => makePivotAwareQuery(
1474
- childRows.filter(r => attachedIds.has(r['id'] as string | number)),
1475
- ),
1476
- relations: {
1477
- [relationName]: { type: 'belongsToMany', model: () => childModel, pivotTable: 'pivot' },
1478
- },
1479
- }
1480
- return {
1481
- parentModel,
1482
- pivotCalls,
1483
- pivotState: pivot,
1484
- attachedIds,
1485
- makeRecord: (id: string) => new Parent({ id }),
1486
- }
1487
- }
1488
-
1489
- describe('Repeater.relationship — pivotColumns', () => {
1490
- // Surface check: setter requires .relationship() first; round-trips into cfg.
1491
- it('Repeater.pivotColumns([…]) requires .relationship() first', () => {
1492
- assert.throws(
1493
- () => RepeaterField.make('tags').pivotColumns(['role']),
1494
- /requires relationship\(\) to be configured first/,
1495
- )
1496
- })
1497
-
1498
- it('Repeater.pivotColumns([…]) writes to the relationship cfg', () => {
1499
- const r = RepeaterField.make('tags')
1500
- .relationship('tags')
1501
- .pivotColumns(['role', 'assignedAt'])
1502
- assert.deepEqual(r.getRelationship()?.pivotColumns, ['role', 'assignedAt'])
1503
- })
1504
-
1505
- it('load — withPivot ferries the configured columns; row values flatten onto the form data', async () => {
1506
- const child = makeFakeChildModel([
1507
- { id: 'c1', name: 'red' },
1508
- { id: 'c2', name: 'blue' },
1509
- ])
1510
- const setup = makeM2MParentSetupWithPivot({
1511
- childModel: child.model,
1512
- childRows: child.rows,
1513
- relationName: 'tags',
1514
- pivot: new Map([
1515
- ['c1', { role: 'owner' }],
1516
- ['c2', { role: 'editor' }],
1517
- ]),
1518
- attachedIds: new Set(['c1', 'c2']),
1519
- })
1520
-
1521
- const form = Form.make().schema([
1522
- RepeaterField.make('tags')
1523
- .relationship('tags')
1524
- .pivotColumns(['role'])
1525
- .schema([TextField.make('name'), TextField.make('role')]),
1526
- ])
1527
-
1528
- const filled = await applyRelationshipRepeaterFill(
1529
- form, {}, setup.makeRecord('a1'), setup.parentModel,
1530
- )
1531
-
1532
- const rows = filled['tags'] as Array<Record<string, unknown>>
1533
- assert.equal(rows.length, 2)
1534
- assert.equal(rows[0]?.['name'], 'red')
1535
- assert.equal(rows[0]?.['role'], 'owner')
1536
- assert.equal(rows[0]?.['__id'], 'c1')
1537
- assert.equal(rows[1]?.['name'], 'blue')
1538
- assert.equal(rows[1]?.['role'], 'editor')
1539
- // pivot envelope is dropped — it's an internal carrier, not form data.
1540
- assert.equal('pivot' in (rows[0] ?? {}), false)
1541
- })
1542
-
1543
- it('save (existing row) — pivot extras route through updatePivot, child fields through M.update', async () => {
1544
- const child = makeFakeChildModel([
1545
- { id: 'c1', name: 'red' },
1546
- ])
1547
- const setup = makeM2MParentSetupWithPivot({
1548
- childModel: child.model,
1549
- childRows: child.rows,
1550
- relationName: 'tags',
1551
- pivot: new Map([['c1', { role: 'editor' }]]),
1552
- attachedIds: new Set(['c1']),
1553
- })
1554
-
1555
- const form = Form.make()
1556
- .schema([
1557
- RepeaterField.make('tags')
1558
- .relationship('tags')
1559
- .pivotColumns(['role'])
1560
- .schema([TextField.make('name'), TextField.make('role')]),
1561
- ])
1562
- .save(async () => setup.makeRecord('a1'))
1563
-
1564
- const submittedRows = [{ __id: 'c1', name: 'crimson', role: 'owner' }]
1565
- const result = await dispatchFormSubmit(
1566
- form,
1567
- { tags: submittedRows },
1568
- { values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
1569
- )
1570
- assert.equal(result.ok, true)
1571
-
1572
- // Child row got the non-pivot field; pivot col was NOT smuggled through.
1573
- const updates = child.calls.filter(c => c.kind === 'update') as Array<{ kind: 'update'; id: string | number; data: Record<string, unknown> }>
1574
- assert.equal(updates.length, 1)
1575
- assert.equal(updates[0]?.data['name'], 'crimson')
1576
- assert.equal('role' in (updates[0]?.data ?? {}), false)
1577
-
1578
- // Pivot row patched via updatePivot.
1579
- const pivotUpdates = setup.pivotCalls.filter(c => c.kind === 'updatePivot') as Array<{ kind: 'updatePivot'; id: string | number; data: Record<string, unknown> }>
1580
- assert.equal(pivotUpdates.length, 1)
1581
- assert.equal(pivotUpdates[0]?.id, 'c1')
1582
- assert.deepEqual(pivotUpdates[0]?.data, { role: 'owner' })
1583
- })
1584
-
1585
- it('save (new row) — attach uses the per-id-pivot map shape', async () => {
1586
- const child = makeFakeChildModel([])
1587
- const setup = makeM2MParentSetupWithPivot({
1588
- childModel: child.model,
1589
- childRows: child.rows,
1590
- relationName: 'tags',
1591
- pivot: new Map(),
1592
- attachedIds: new Set(),
1593
- })
1594
-
1595
- const form = Form.make()
1596
- .schema([
1597
- RepeaterField.make('tags')
1598
- .relationship('tags')
1599
- .pivotColumns(['role'])
1600
- .schema([TextField.make('name'), TextField.make('role')]),
1601
- ])
1602
- .save(async () => setup.makeRecord('a1'))
1603
-
1604
- const submittedRows = [{ name: 'red', role: 'owner' }]
1605
- const result = await dispatchFormSubmit(
1606
- form,
1607
- { tags: submittedRows },
1608
- { values: { tags: submittedRows }, parentModel: setup.parentModel },
1609
- )
1610
- assert.equal(result.ok, true)
1611
-
1612
- // Child created without `role` (pivot column).
1613
- const creates = child.calls.filter(c => c.kind === 'create') as Array<{ kind: 'create'; data: Record<string, unknown> }>
1614
- assert.equal(creates.length, 1)
1615
- assert.equal(creates[0]?.data['name'], 'red')
1616
- assert.equal('role' in (creates[0]?.data ?? {}), false)
1617
-
1618
- // attach received the per-id-pivot map.
1619
- const attachCalls = setup.pivotCalls.filter(c => c.kind === 'attach') as Array<{ kind: 'attach'; pivot?: Record<string, Record<string, unknown>> }>
1620
- assert.equal(attachCalls.length, 1)
1621
- assert.ok(attachCalls[0]?.pivot, 'attach should have received a per-id pivot map')
1622
- const map = attachCalls[0]!.pivot!
1623
- const onlyKey = Object.keys(map)[0]!
1624
- assert.deepEqual(map[onlyKey], { role: 'owner' })
1625
- })
1626
-
1627
- it('save (existing row, no pivot edit) — skips updatePivot when payload has no pivot keys', async () => {
1628
- const child = makeFakeChildModel([
1629
- { id: 'c1', name: 'red' },
1630
- ])
1631
- const setup = makeM2MParentSetupWithPivot({
1632
- childModel: child.model,
1633
- childRows: child.rows,
1634
- relationName: 'tags',
1635
- pivot: new Map([['c1', { role: 'editor' }]]),
1636
- attachedIds: new Set(['c1']),
1637
- })
1638
-
1639
- const form = Form.make()
1640
- .schema([
1641
- RepeaterField.make('tags')
1642
- .relationship('tags')
1643
- .pivotColumns(['role'])
1644
- .schema([TextField.make('name'), TextField.make('role')]),
1645
- ])
1646
- .save(async () => setup.makeRecord('a1'))
1647
-
1648
- // Submit only changes the child column; role omitted entirely.
1649
- const submittedRows = [{ __id: 'c1', name: 'crimson' }]
1650
- const result = await dispatchFormSubmit(
1651
- form,
1652
- { tags: submittedRows },
1653
- { values: { tags: submittedRows }, record: setup.makeRecord('a1'), parentModel: setup.parentModel },
1654
- )
1655
- assert.equal(result.ok, true)
1656
- assert.equal(setup.pivotCalls.filter(c => c.kind === 'updatePivot').length, 0)
1657
- })
1658
-
1659
- it('save — throws a clear error when accessor lacks updatePivot but pivot extras changed', async () => {
1660
- const child = makeFakeChildModel([{ id: 'c1', name: 'red' }])
1661
- // Build a parent whose accessor does NOT expose updatePivot.
1662
- const accessorWithoutUpdate = {
1663
- attach: async () => {},
1664
- detach: async () => 0,
1665
- }
1666
- class Parent {
1667
- id?: string
1668
- constructor(init?: Partial<{ id: string }>) { Object.assign(this, init) }
1669
- tags() { return accessorWithoutUpdate }
1670
- }
1671
- const parentModel: ModelLike & { relations: Record<string, unknown> } = {
1672
- primaryKey: 'id',
1673
- find: async () => null,
1674
- create: async () => ({}),
1675
- update: async () => ({}),
1676
- delete: async () => {},
1677
- query: () => makeQuery([]),
1678
- relatedQuery: () => makeQuery([{ id: 'c1', name: 'red' }]),
1679
- relations: {
1680
- tags: { type: 'belongsToMany', model: () => child.model, pivotTable: 'pivot' },
1681
- },
1682
- }
1683
-
1684
- const form = Form.make()
1685
- .schema([
1686
- RepeaterField.make('tags')
1687
- .relationship('tags')
1688
- .pivotColumns(['role'])
1689
- .schema([TextField.make('name'), TextField.make('role')]),
1690
- ])
1691
- .save(async () => new Parent({ id: 'a1' }))
1692
-
1693
- const submittedRows = [{ __id: 'c1', name: 'red', role: 'owner' }]
1694
- await assert.rejects(
1695
- () => dispatchFormSubmit(
1696
- form,
1697
- { tags: submittedRows },
1698
- { values: { tags: submittedRows }, record: new Parent({ id: 'a1' }), parentModel },
1699
- ),
1700
- /requires a rudder ORM with `updatePivot`/,
1701
- )
1702
- })
1703
-
1704
- it('loadRelationRows — passes pivotColumns into withPivot when supported', async () => {
1705
- let seenCols: string[] | undefined
1706
- const q: ModelQuery = {
1707
- where: () => q,
1708
- orWhere: () => q,
1709
- orderBy: () => q,
1710
- withPivot(...cols: string[]) {
1711
- seenCols = cols
1712
- return q
1713
- },
1714
- paginate: async () => ({ data: [], total: 0 }),
1715
- }
1716
- const M: ModelLike = {
1717
- primaryKey: 'id',
1718
- find: async () => null,
1719
- create: async () => ({}),
1720
- update: async () => ({}),
1721
- delete: async () => {},
1722
- query: () => q,
1723
- relatedQuery: () => q,
1724
- }
1725
- await loadRelationRows(M, {}, 'tags', ['role', 'assignedAt'])
1726
- assert.deepEqual(seenCols, ['role', 'assignedAt'])
1727
- })
1728
-
1729
- it('loadRelationRows — silently skips withPivot on a model that does not implement it', async () => {
1730
- // A model whose query has no `withPivot` method — pilotiq should
1731
- // call paginate without throwing.
1732
- const q: ModelQuery = {
1733
- where: () => q,
1734
- orWhere: () => q,
1735
- orderBy: () => q,
1736
- paginate: async () => ({ data: [], total: 0 }),
1737
- }
1738
- const M: ModelLike = {
1739
- primaryKey: 'id',
1740
- find: async () => null,
1741
- create: async () => ({}),
1742
- update: async () => ({}),
1743
- delete: async () => {},
1744
- query: () => q,
1745
- relatedQuery: () => q,
1746
- }
1747
- const rows = await loadRelationRows(M, {}, 'tags', ['role'])
1748
- assert.deepEqual(rows, [])
1749
- })
1750
- })
1751
-
1752
- describe('Repeater.relationship — afterCreate / afterUpdate / afterDelete hooks', () => {
1753
- it('afterCreate fires once per created child with parent + index + mode in ctx', async () => {
1754
- const child = makeFakeChildModel([])
1755
- const parent = makeFakeParentModel({
1756
- childModel: child.model,
1757
- childRows: child.rows,
1758
- relationName: 'items',
1759
- foreignKey: 'orderId',
1760
- })
1761
-
1762
- const calls: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
1763
- const form = Form.make()
1764
- .schema([
1765
- RepeaterField.make('items')
1766
- .relationship('items')
1767
- .schema([TextField.make('label').required()])
1768
- .afterCreate((record, ctx) => {
1769
- calls.push({ record, ctx: { ...ctx } as Record<string, unknown> })
1770
- }),
1771
- ])
1772
- .save(async () => ({ id: 'p1', title: 'Order' }))
1773
-
1774
- await dispatchFormSubmit(
1775
- form,
1776
- { items: [{ label: 'A' }, { label: 'B' }] },
1777
- { values: { items: [{ label: 'A' }, { label: 'B' }] }, parentModel: parent },
1778
- )
1779
-
1780
- assert.equal(calls.length, 2)
1781
- assert.equal((calls[0]!.record as Record<string, unknown>)['label'], 'A')
1782
- assert.equal((calls[1]!.record as Record<string, unknown>)['label'], 'B')
1783
- assert.equal(calls[0]!.ctx['index'], 0)
1784
- assert.equal(calls[1]!.ctx['index'], 1)
1785
- assert.equal(calls[0]!.ctx['field'], 'items')
1786
- assert.equal(calls[0]!.ctx['mode'], 'hasMany')
1787
- assert.equal(calls[0]!.ctx['parentId'], 'p1')
1788
- assert.deepEqual(calls[0]!.ctx['parent'], { id: 'p1', title: 'Order' })
1789
- })
1790
-
1791
- it('afterUpdate fires per updated child (skipping pure-create rows)', async () => {
1792
- const child = makeFakeChildModel([
1793
- { id: 'c1', orderId: 'p1', label: 'old A' },
1794
- ])
1795
- const parent = makeFakeParentModel({
1796
- childModel: child.model,
1797
- childRows: child.rows,
1798
- relationName: 'items',
1799
- foreignKey: 'orderId',
1800
- })
1801
-
1802
- const updates: Array<{ record: unknown; index: number }> = []
1803
- const creates: Array<{ record: unknown; index: number }> = []
1804
- const form = Form.make()
1805
- .schema([
1806
- RepeaterField.make('items')
1807
- .relationship('items')
1808
- .schema([TextField.make('label').required()])
1809
- .afterCreate((record, ctx) => { creates.push({ record, index: ctx.index }) })
1810
- .afterUpdate((record, ctx) => { updates.push({ record, index: ctx.index }) }),
1811
- ])
1812
- .save(async () => ({ id: 'p1' }))
1813
-
1814
- await dispatchFormSubmit(
1815
- form,
1816
- { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
1817
- {
1818
- values: { items: [{ __id: 'c1', label: 'new A' }, { label: 'fresh B' }] },
1819
- record: { id: 'p1' },
1820
- parentModel: parent,
1821
- },
1822
- )
1823
-
1824
- assert.equal(updates.length, 1)
1825
- assert.equal((updates[0]!.record as Record<string, unknown>)['label'], 'new A')
1826
- assert.equal(updates[0]!.index, 0)
1827
- assert.equal(creates.length, 1)
1828
- assert.equal((creates[0]!.record as Record<string, unknown>)['label'], 'fresh B')
1829
- assert.equal(creates[0]!.index, 1)
1830
- })
1831
-
1832
- it('afterDelete fires once per removed child with the previous row data', async () => {
1833
- const child = makeFakeChildModel([
1834
- { id: 'c1', orderId: 'p1', label: 'A' },
1835
- { id: 'c2', orderId: 'p1', label: 'B' },
1836
- { id: 'c3', orderId: 'p1', label: 'C' },
1837
- ])
1838
- const parent = makeFakeParentModel({
1839
- childModel: child.model,
1840
- childRows: child.rows,
1841
- relationName: 'items',
1842
- foreignKey: 'orderId',
1843
- })
1844
-
1845
- const removed: Array<{ record: unknown; ctx: Record<string, unknown> }> = []
1846
- const form = Form.make()
1847
- .schema([
1848
- RepeaterField.make('items')
1849
- .relationship('items')
1850
- .schema([TextField.make('label').required()])
1851
- .afterDelete((record, ctx) => {
1852
- removed.push({ record, ctx: { ...ctx } as Record<string, unknown> })
1853
- }),
1854
- ])
1855
- .save(async () => ({ id: 'p1' }))
1856
-
1857
- // Submit only c1 — c2 and c3 disappear.
1858
- await dispatchFormSubmit(
1859
- form,
1860
- { items: [{ __id: 'c1', label: 'A' }] },
1861
- {
1862
- values: { items: [{ __id: 'c1', label: 'A' }] },
1863
- record: { id: 'p1' },
1864
- parentModel: parent,
1865
- },
1866
- )
1867
-
1868
- assert.equal(removed.length, 2)
1869
- const labels = removed.map(r => (r.record as Record<string, unknown>)['label']).sort()
1870
- assert.deepEqual(labels, ['B', 'C'])
1871
- assert.equal(removed[0]!.ctx['index'], -1)
1872
- assert.equal(removed[0]!.ctx['mode'], 'hasMany')
1873
- assert.equal(removed[0]!.ctx['parentId'], 'p1')
1874
- })
1875
-
1876
- it('hooks are no-op outside relationship() mode (throw at config time)', () => {
1877
- assert.throws(() =>
1878
- RepeaterField.make('json').afterCreate(() => {}),
1879
- /requires relationship/,
1880
- )
1881
- assert.throws(() =>
1882
- RepeaterField.make('json').afterUpdate(() => {}),
1883
- /requires relationship/,
1884
- )
1885
- assert.throws(() =>
1886
- RepeaterField.make('json').afterDelete(() => {}),
1887
- /requires relationship/,
1888
- )
1889
- })
1890
-
1891
- it('throwing handler propagates and aborts the rest of the persist diff', async () => {
1892
- const child = makeFakeChildModel([])
1893
- const parent = makeFakeParentModel({
1894
- childModel: child.model,
1895
- childRows: child.rows,
1896
- relationName: 'items',
1897
- foreignKey: 'orderId',
1898
- })
1899
-
1900
- const form = Form.make()
1901
- .schema([
1902
- RepeaterField.make('items')
1903
- .relationship('items')
1904
- .schema([TextField.make('label').required()])
1905
- .afterCreate((record) => {
1906
- const r = record as Record<string, unknown>
1907
- if (r['label'] === 'B') throw new Error('reject B')
1908
- }),
1909
- ])
1910
- .save(async () => ({ id: 'p1' }))
1911
-
1912
- await assert.rejects(
1913
- () => dispatchFormSubmit(
1914
- form,
1915
- { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] },
1916
- { values: { items: [{ label: 'A' }, { label: 'B' }, { label: 'C' }] }, parentModel: parent },
1917
- ),
1918
- /reject B/,
1919
- )
1920
- // Two creates fired before the throw — no rollback (v1 isn't transactional).
1921
- assert.equal(child.calls.filter(c => c.kind === 'create').length, 2)
1922
- })
1923
- })