@pilotiq/pilotiq 0.23.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 (500) hide show
  1. package/CHANGELOG.md +91 -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/dist/actions/exportFactory.d.ts +10 -0
  15. package/dist/actions/exportFactory.d.ts.map +1 -1
  16. package/dist/actions/exportFactory.js +10 -0
  17. package/dist/actions/exportFactory.js.map +1 -1
  18. package/dist/react/CollabRoomContext.d.ts +5 -5
  19. package/dist/react/index.d.ts +0 -1
  20. package/dist/react/index.d.ts.map +1 -1
  21. package/dist/react/index.js +0 -1
  22. package/dist/react/index.js.map +1 -1
  23. package/dist/routes/helpers.d.ts.map +1 -1
  24. package/dist/routes/helpers.js +6 -2
  25. package/dist/routes/helpers.js.map +1 -1
  26. package/dist/routes/relations.d.ts.map +1 -1
  27. package/dist/routes/relations.js +12 -0
  28. package/dist/routes/relations.js.map +1 -1
  29. package/package.json +6 -1
  30. package/.turbo/turbo-build.log +0 -8
  31. package/CLAUDE.md +0 -265
  32. package/dist/react/useCollabSeed.d.ts +0 -23
  33. package/dist/react/useCollabSeed.d.ts.map +0 -1
  34. package/dist/react/useCollabSeed.js +0 -82
  35. package/dist/react/useCollabSeed.js.map +0 -1
  36. package/src/Cluster.test.ts +0 -283
  37. package/src/Cluster.ts +0 -83
  38. package/src/Column.test.ts +0 -199
  39. package/src/Column.ts +0 -710
  40. package/src/Global.test.ts +0 -367
  41. package/src/Global.ts +0 -169
  42. package/src/Page.test.ts +0 -114
  43. package/src/Page.ts +0 -208
  44. package/src/Pilotiq.perf.test.ts +0 -252
  45. package/src/Pilotiq.test.ts +0 -129
  46. package/src/Pilotiq.ts +0 -1158
  47. package/src/PilotiqRegistry.ts +0 -36
  48. package/src/PilotiqServiceProvider.ts +0 -121
  49. package/src/RelationManager.test.ts +0 -400
  50. package/src/RelationManager.ts +0 -527
  51. package/src/RenderHook.test.ts +0 -252
  52. package/src/RenderHook.ts +0 -242
  53. package/src/Resource.test.ts +0 -284
  54. package/src/Resource.ts +0 -526
  55. package/src/RightPanel.test.ts +0 -202
  56. package/src/RightPanel.ts +0 -132
  57. package/src/Tab.test.ts +0 -91
  58. package/src/Tab.ts +0 -156
  59. package/src/UserMenuItem.ts +0 -145
  60. package/src/actions/Action.test.ts +0 -2526
  61. package/src/actions/Action.ts +0 -1515
  62. package/src/actions/ActionGroup.test.ts +0 -112
  63. package/src/actions/ActionGroup.ts +0 -173
  64. package/src/actions/attachFactory.ts +0 -172
  65. package/src/actions/bulkFactories.ts +0 -168
  66. package/src/actions/crudFactories.ts +0 -220
  67. package/src/actions/exportFactory.ts +0 -215
  68. package/src/actions/factoryHelpers.ts +0 -177
  69. package/src/actions/importFactory.ts +0 -243
  70. package/src/actions/index.ts +0 -17
  71. package/src/actions/m2mFactories.ts +0 -193
  72. package/src/actions/relationFactories.ts +0 -372
  73. package/src/applyPageHooks.test.ts +0 -463
  74. package/src/applyPageHooks.ts +0 -330
  75. package/src/authorization.test.ts +0 -483
  76. package/src/breadcrumbs.test.ts +0 -238
  77. package/src/cells/coerce.test.ts +0 -85
  78. package/src/cells/coerce.ts +0 -84
  79. package/src/clusterPaths.ts +0 -35
  80. package/src/columns/BadgeColumn.test.ts +0 -54
  81. package/src/columns/BadgeColumn.ts +0 -32
  82. package/src/columns/BooleanColumn.test.ts +0 -41
  83. package/src/columns/BooleanColumn.ts +0 -18
  84. package/src/columns/ColorColumn.test.ts +0 -37
  85. package/src/columns/ColorColumn.ts +0 -38
  86. package/src/columns/IconColumn.test.ts +0 -54
  87. package/src/columns/IconColumn.ts +0 -37
  88. package/src/columns/ImageColumn.test.ts +0 -41
  89. package/src/columns/ImageColumn.ts +0 -28
  90. package/src/columns/SelectColumn.ts +0 -98
  91. package/src/columns/TextColumn.test.ts +0 -190
  92. package/src/columns/TextColumn.ts +0 -20
  93. package/src/columns/TextInputColumn.ts +0 -68
  94. package/src/columns/ToggleColumn.ts +0 -46
  95. package/src/columns/editableColumns.test.ts +0 -238
  96. package/src/columns/index.ts +0 -9
  97. package/src/defaultGlobalPages.ts +0 -95
  98. package/src/defaultPages.test.ts +0 -634
  99. package/src/defaultPages.ts +0 -617
  100. package/src/defaultViewPage.test.ts +0 -147
  101. package/src/elements/Form.test.ts +0 -223
  102. package/src/elements/Form.ts +0 -416
  103. package/src/elements/ListTabs.ts +0 -28
  104. package/src/elements/Table.test.ts +0 -422
  105. package/src/elements/Table.ts +0 -850
  106. package/src/elements/TableGroup.test.ts +0 -260
  107. package/src/elements/TableGroup.ts +0 -334
  108. package/src/elements/dispatchAction.test.ts +0 -463
  109. package/src/elements/dispatchAction.ts +0 -355
  110. package/src/elements/dispatchForm.test.ts +0 -477
  111. package/src/elements/dispatchForm.ts +0 -1993
  112. package/src/elements/dispatchTable.test.ts +0 -1514
  113. package/src/elements/dispatchTable.ts +0 -745
  114. package/src/elements/index.ts +0 -21
  115. package/src/entries/BadgeEntry.ts +0 -39
  116. package/src/entries/CodeEntry.test.ts +0 -40
  117. package/src/entries/CodeEntry.ts +0 -52
  118. package/src/entries/ColorEntry.ts +0 -63
  119. package/src/entries/ComponentEntry.test.ts +0 -173
  120. package/src/entries/ComponentEntry.ts +0 -95
  121. package/src/entries/Entry.ts +0 -304
  122. package/src/entries/IconEntry.ts +0 -49
  123. package/src/entries/ImageEntry.ts +0 -61
  124. package/src/entries/KeyValueEntry.ts +0 -47
  125. package/src/entries/RepeatableEntry.test.ts +0 -239
  126. package/src/entries/RepeatableEntry.ts +0 -173
  127. package/src/entries/TextEntry.test.ts +0 -394
  128. package/src/entries/TextEntry.ts +0 -60
  129. package/src/entries/index.ts +0 -12
  130. package/src/entries/leaves.test.ts +0 -306
  131. package/src/entries/registry.ts +0 -54
  132. package/src/fields/BuilderField.test.ts +0 -1188
  133. package/src/fields/BuilderField.ts +0 -605
  134. package/src/fields/BuilderRelationship.test.ts +0 -811
  135. package/src/fields/CheckboxField.test.ts +0 -44
  136. package/src/fields/CheckboxField.ts +0 -27
  137. package/src/fields/CheckboxListField.test.ts +0 -99
  138. package/src/fields/CheckboxListField.ts +0 -66
  139. package/src/fields/ColorPickerField.test.ts +0 -33
  140. package/src/fields/ColorPickerField.ts +0 -25
  141. package/src/fields/DateField.ts +0 -54
  142. package/src/fields/DateTimeField.test.ts +0 -55
  143. package/src/fields/EmailField.ts +0 -16
  144. package/src/fields/Field.test.ts +0 -654
  145. package/src/fields/Field.ts +0 -817
  146. package/src/fields/FileUploadField.test.ts +0 -143
  147. package/src/fields/FileUploadField.ts +0 -159
  148. package/src/fields/HiddenField.test.ts +0 -27
  149. package/src/fields/HiddenField.ts +0 -28
  150. package/src/fields/KeyValueField.test.ts +0 -105
  151. package/src/fields/KeyValueField.ts +0 -55
  152. package/src/fields/MarkdownField.test.ts +0 -167
  153. package/src/fields/MarkdownField.ts +0 -162
  154. package/src/fields/NumberField.ts +0 -33
  155. package/src/fields/RadioField.test.ts +0 -94
  156. package/src/fields/RadioField.ts +0 -67
  157. package/src/fields/RepeaterField.test.ts +0 -1806
  158. package/src/fields/RepeaterField.ts +0 -939
  159. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  160. package/src/fields/RepeaterSimple.test.ts +0 -248
  161. package/src/fields/RowButton.test.ts +0 -219
  162. package/src/fields/RowButton.ts +0 -135
  163. package/src/fields/SelectField.test.ts +0 -192
  164. package/src/fields/SelectField.ts +0 -235
  165. package/src/fields/SliderField.test.ts +0 -50
  166. package/src/fields/SliderField.ts +0 -53
  167. package/src/fields/SlugField.ts +0 -24
  168. package/src/fields/TagsInputField.test.ts +0 -154
  169. package/src/fields/TagsInputField.ts +0 -133
  170. package/src/fields/TextField.test.ts +0 -213
  171. package/src/fields/TextField.ts +0 -177
  172. package/src/fields/TextareaField.test.ts +0 -58
  173. package/src/fields/TextareaField.ts +0 -59
  174. package/src/fields/ToggleButtonsField.test.ts +0 -106
  175. package/src/fields/ToggleButtonsField.ts +0 -59
  176. package/src/fields/ToggleField.ts +0 -16
  177. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  178. package/src/fields/optionsResolver.ts +0 -95
  179. package/src/fields/resolveField.ts +0 -28
  180. package/src/filters/BooleanFilter.ts +0 -35
  181. package/src/filters/DateRangeFilter.test.ts +0 -194
  182. package/src/filters/DateRangeFilter.ts +0 -148
  183. package/src/filters/Filter.test.ts +0 -268
  184. package/src/filters/Filter.ts +0 -184
  185. package/src/filters/FormFilter.test.ts +0 -238
  186. package/src/filters/FormFilter.ts +0 -215
  187. package/src/filters/MultiSelectFilter.test.ts +0 -119
  188. package/src/filters/MultiSelectFilter.ts +0 -78
  189. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  190. package/src/filters/QueryBuilderFilter.ts +0 -398
  191. package/src/filters/SelectFilter.ts +0 -46
  192. package/src/filters/TernaryFilter.test.ts +0 -160
  193. package/src/filters/TernaryFilter.ts +0 -72
  194. package/src/filters/TrashedFilter.test.ts +0 -149
  195. package/src/filters/TrashedFilter.ts +0 -55
  196. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  197. package/src/filters/queryBuilder/Constraint.ts +0 -115
  198. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  199. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  200. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  201. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  202. package/src/filters/queryBuilder/index.ts +0 -12
  203. package/src/icons/index.ts +0 -2
  204. package/src/icons/lucide.ts +0 -204
  205. package/src/icons/registry.test.ts +0 -56
  206. package/src/icons/registry.ts +0 -41
  207. package/src/icons/types.ts +0 -47
  208. package/src/index.ts +0 -525
  209. package/src/io/csv.test.ts +0 -142
  210. package/src/io/csv.ts +0 -170
  211. package/src/nestedRelationManagerData.test.ts +0 -547
  212. package/src/notifications/Notification.test.ts +0 -210
  213. package/src/notifications/Notification.ts +0 -354
  214. package/src/notifications/broadcast.test.ts +0 -110
  215. package/src/notifications/broadcast.ts +0 -95
  216. package/src/notifications/database.test.ts +0 -383
  217. package/src/notifications/database.ts +0 -398
  218. package/src/notifications/databaseNotifications.test.ts +0 -187
  219. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  220. package/src/notifications/dispatchNotificationAction.ts +0 -142
  221. package/src/notifications/flash.test.ts +0 -89
  222. package/src/notifications/flash.ts +0 -71
  223. package/src/notifications/index.ts +0 -45
  224. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  225. package/src/notifications/registerBroadcastAuth.ts +0 -100
  226. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  227. package/src/notifications/resolveSavedNotification.ts +0 -59
  228. package/src/notifications/types.ts +0 -93
  229. package/src/orm/m2mAccessor.ts +0 -66
  230. package/src/orm/modelDefaults.test.ts +0 -633
  231. package/src/orm/modelDefaults.ts +0 -666
  232. package/src/pageData/breadcrumbs.ts +0 -288
  233. package/src/pageData/forms.ts +0 -578
  234. package/src/pageData/helpers.ts +0 -857
  235. package/src/pageData/misc.ts +0 -347
  236. package/src/pageData/navigation.ts +0 -842
  237. package/src/pageData/relationPages.ts +0 -1248
  238. package/src/pageData/relationTabs.ts +0 -286
  239. package/src/pageData/resourcePages.ts +0 -609
  240. package/src/pageData.test.ts +0 -1545
  241. package/src/pageData.ts +0 -341
  242. package/src/plugins/index.ts +0 -8
  243. package/src/plugins/themeEditor.test.ts +0 -36
  244. package/src/plugins/themeEditor.ts +0 -45
  245. package/src/react/AppShell.tsx +0 -251
  246. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  247. package/src/react/CollabRoomContext.ts +0 -98
  248. package/src/react/CollabTextRendererRegistry.ts +0 -102
  249. package/src/react/CommandPalette.tsx +0 -375
  250. package/src/react/CurrentUserContext.tsx +0 -50
  251. package/src/react/CustomPageWrapperGate.tsx +0 -69
  252. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  253. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  254. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  255. package/src/react/FieldPresenceRegistry.ts +0 -46
  256. package/src/react/FormCollabBindingRegistry.ts +0 -242
  257. package/src/react/FormStateContext.tsx +0 -591
  258. package/src/react/HeadHooks.tsx +0 -126
  259. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  260. package/src/react/MarkdownEditorRegistry.ts +0 -107
  261. package/src/react/NotificationActionStrip.tsx +0 -263
  262. package/src/react/NotificationBell.tsx +0 -426
  263. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  264. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  265. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  266. package/src/react/PendingSuggestionsContext.tsx +0 -172
  267. package/src/react/RecordWrapperGate.tsx +0 -58
  268. package/src/react/RecordWrapperRegistry.ts +0 -39
  269. package/src/react/RenderHookSlot.tsx +0 -32
  270. package/src/react/RightSidebar.tsx +0 -257
  271. package/src/react/RightSidebarContext.tsx +0 -234
  272. package/src/react/RightSidebarTrigger.tsx +0 -53
  273. package/src/react/RowCoordsContext.tsx +0 -23
  274. package/src/react/SchemaRenderer.tsx +0 -549
  275. package/src/react/SearchTrigger.tsx +0 -46
  276. package/src/react/ThemeProvider.tsx +0 -93
  277. package/src/react/ThemeSettingsPage.tsx +0 -579
  278. package/src/react/ThemeToggle.tsx +0 -20
  279. package/src/react/Toaster.tsx +0 -158
  280. package/src/react/UserMenu.tsx +0 -196
  281. package/src/react/WidgetDataContext.tsx +0 -157
  282. package/src/react/cells/EditableCell.tsx +0 -389
  283. package/src/react/component-slots.test.ts +0 -103
  284. package/src/react/component-slots.ts +0 -116
  285. package/src/react/fieldJsHandler.test.ts +0 -166
  286. package/src/react/fieldJsHandler.ts +0 -79
  287. package/src/react/fields/BuilderInput.tsx +0 -1078
  288. package/src/react/fields/CheckboxInput.tsx +0 -39
  289. package/src/react/fields/CheckboxListInput.tsx +0 -102
  290. package/src/react/fields/ColorInput.tsx +0 -71
  291. package/src/react/fields/DateFieldInput.tsx +0 -70
  292. package/src/react/fields/DateTimeInput.tsx +0 -62
  293. package/src/react/fields/FieldShell.tsx +0 -348
  294. package/src/react/fields/FileUploadInput.tsx +0 -639
  295. package/src/react/fields/HiddenInput.tsx +0 -17
  296. package/src/react/fields/KeyValueInput.tsx +0 -230
  297. package/src/react/fields/MarkdownInput.tsx +0 -560
  298. package/src/react/fields/RadioInput.tsx +0 -81
  299. package/src/react/fields/RepeaterInput.test.ts +0 -116
  300. package/src/react/fields/RepeaterInput.tsx +0 -1420
  301. package/src/react/fields/SelectFieldInput.tsx +0 -280
  302. package/src/react/fields/SliderInput.tsx +0 -81
  303. package/src/react/fields/TagsInput.tsx +0 -283
  304. package/src/react/fields/TextLikeInput.tsx +0 -256
  305. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  306. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  307. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  308. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  309. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  310. package/src/react/fields/repeaterReconcile.ts +0 -104
  311. package/src/react/fields/rowChromeButton.tsx +0 -336
  312. package/src/react/fields/rowState.ts +0 -106
  313. package/src/react/fields/syncRowGates.test.ts +0 -202
  314. package/src/react/fields/syncRowGates.ts +0 -66
  315. package/src/react/fields/textInputControls.tsx +0 -238
  316. package/src/react/fields/useRowReorderDnd.ts +0 -78
  317. package/src/react/formStateHelpers.test.ts +0 -508
  318. package/src/react/formStateHelpers.ts +0 -381
  319. package/src/react/hooks/use-mobile.ts +0 -19
  320. package/src/react/icon-context.tsx +0 -60
  321. package/src/react/index.ts +0 -195
  322. package/src/react/layouts/SidebarLayout.tsx +0 -250
  323. package/src/react/layouts/TopbarLayout.tsx +0 -258
  324. package/src/react/navigate.tsx +0 -37
  325. package/src/react/onProviderSynced.test.ts +0 -90
  326. package/src/react/parseRecordEditUrl.test.ts +0 -122
  327. package/src/react/parseRecordEditUrl.ts +0 -94
  328. package/src/react/persistedState.ts +0 -40
  329. package/src/react/registry.ts +0 -48
  330. package/src/react/right-panel-registry.tsx +0 -47
  331. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  332. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  333. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  334. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  335. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  336. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  337. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  338. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  339. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  340. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  341. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  342. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  343. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  344. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  345. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  346. package/src/react/schemaRenderer/constants.ts +0 -50
  347. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  348. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  349. package/src/react/schemaRenderer/helpers.tsx +0 -81
  350. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  351. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  352. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  353. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  354. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  355. package/src/react/schemaRenderer/table/links.tsx +0 -112
  356. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  357. package/src/react/schemaRenderer/table/url.tsx +0 -143
  358. package/src/react/theme-preview/apply.ts +0 -99
  359. package/src/react/theme-preview/build-html.ts +0 -436
  360. package/src/react/ui/button.tsx +0 -51
  361. package/src/react/ui/calendar.tsx +0 -67
  362. package/src/react/ui/checkbox.tsx +0 -29
  363. package/src/react/ui/dialog.tsx +0 -108
  364. package/src/react/ui/dropdown-menu.tsx +0 -97
  365. package/src/react/ui/input.tsx +0 -20
  366. package/src/react/ui/label.tsx +0 -21
  367. package/src/react/ui/popover.tsx +0 -50
  368. package/src/react/ui/select.tsx +0 -169
  369. package/src/react/ui/separator.tsx +0 -25
  370. package/src/react/ui/sheet.tsx +0 -136
  371. package/src/react/ui/sidebar.tsx +0 -723
  372. package/src/react/ui/skeleton.tsx +0 -13
  373. package/src/react/ui/slider.tsx +0 -34
  374. package/src/react/ui/switch.tsx +0 -28
  375. package/src/react/ui/table.tsx +0 -105
  376. package/src/react/ui/tabs.tsx +0 -63
  377. package/src/react/ui/textarea.tsx +0 -18
  378. package/src/react/ui/tooltip.tsx +0 -64
  379. package/src/react/useCollabSeed.ts +0 -86
  380. package/src/react/useResizableWidth.ts +0 -139
  381. package/src/react/utils.ts +0 -6
  382. package/src/react/widgetRegistry.test.ts +0 -43
  383. package/src/react/widgetRegistry.ts +0 -50
  384. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  385. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  386. package/src/react/widgets/ViewRenderer.tsx +0 -71
  387. package/src/relationManagerData.test.ts +0 -1595
  388. package/src/richtext/index.ts +0 -8
  389. package/src/richtext/registry.ts +0 -89
  390. package/src/routes/globals.ts +0 -148
  391. package/src/routes/guard.test.ts +0 -325
  392. package/src/routes/helpers.ts +0 -700
  393. package/src/routes/pages.ts +0 -175
  394. package/src/routes/panel.ts +0 -204
  395. package/src/routes/relations.ts +0 -1227
  396. package/src/routes/resources.ts +0 -781
  397. package/src/routes/theme.ts +0 -91
  398. package/src/routes-nested-relations.test.ts +0 -676
  399. package/src/routes-relations.test.ts +0 -972
  400. package/src/routes.test.ts +0 -2027
  401. package/src/routes.ts +0 -303
  402. package/src/schema/Alert.test.ts +0 -109
  403. package/src/schema/Alert.ts +0 -131
  404. package/src/schema/Block.ts +0 -169
  405. package/src/schema/Breadcrumbs.ts +0 -40
  406. package/src/schema/Card.ts +0 -35
  407. package/src/schema/Divider.ts +0 -20
  408. package/src/schema/Element.ts +0 -219
  409. package/src/schema/EmptyState.test.ts +0 -37
  410. package/src/schema/EmptyState.ts +0 -63
  411. package/src/schema/Fieldset.ts +0 -43
  412. package/src/schema/Grid.ts +0 -43
  413. package/src/schema/Group.ts +0 -30
  414. package/src/schema/Heading.ts +0 -39
  415. package/src/schema/Html.ts +0 -67
  416. package/src/schema/Icon.ts +0 -54
  417. package/src/schema/Image.ts +0 -57
  418. package/src/schema/LinkTag.ts +0 -41
  419. package/src/schema/Markdown.ts +0 -85
  420. package/src/schema/MetaTag.ts +0 -41
  421. package/src/schema/RelationTabs.ts +0 -71
  422. package/src/schema/ScriptTag.ts +0 -55
  423. package/src/schema/Section.ts +0 -160
  424. package/src/schema/ServerDataElement.test.ts +0 -140
  425. package/src/schema/ServerDataElement.ts +0 -156
  426. package/src/schema/SlotComponent.test.ts +0 -77
  427. package/src/schema/SlotComponent.ts +0 -71
  428. package/src/schema/Split.ts +0 -50
  429. package/src/schema/Stat.test.ts +0 -118
  430. package/src/schema/Stat.ts +0 -154
  431. package/src/schema/StatsOverview.test.ts +0 -141
  432. package/src/schema/StatsOverview.ts +0 -119
  433. package/src/schema/StyleTag.ts +0 -35
  434. package/src/schema/TableWidget.test.ts +0 -297
  435. package/src/schema/TableWidget.ts +0 -289
  436. package/src/schema/Tabs.ts +0 -79
  437. package/src/schema/Text.ts +0 -58
  438. package/src/schema/UnorderedList.ts +0 -49
  439. package/src/schema/View.test.ts +0 -111
  440. package/src/schema/View.ts +0 -127
  441. package/src/schema/Wizard.ts +0 -220
  442. package/src/schema/containers.test.ts +0 -564
  443. package/src/schema/headTags.test.ts +0 -134
  444. package/src/schema/index.ts +0 -40
  445. package/src/schema/primes.test.ts +0 -269
  446. package/src/schema/resolveSchema.test.ts +0 -379
  447. package/src/schema/resolveSchema.ts +0 -917
  448. package/src/schema/sanitize.ts +0 -58
  449. package/src/search.test.ts +0 -446
  450. package/src/search.ts +0 -178
  451. package/src/sessionFilters.test.ts +0 -375
  452. package/src/sessionFilters.ts +0 -143
  453. package/src/slot-components/index.ts +0 -10
  454. package/src/slot-components/registry.ts +0 -56
  455. package/src/styles/file-upload.css +0 -13
  456. package/src/summarizers/Summarizer.test.ts +0 -84
  457. package/src/summarizers/Summarizer.ts +0 -123
  458. package/src/summarizers/index.ts +0 -11
  459. package/src/theme/base-colors.ts +0 -68
  460. package/src/theme/chart-colors.ts +0 -50
  461. package/src/theme/colors.ts +0 -447
  462. package/src/theme/generate-css.test.ts +0 -139
  463. package/src/theme/generate-css.ts +0 -44
  464. package/src/theme/generate-scale.test.ts +0 -106
  465. package/src/theme/generate-scale.ts +0 -97
  466. package/src/theme/icon-map.ts +0 -42
  467. package/src/theme/index.ts +0 -34
  468. package/src/theme/migrate.test.ts +0 -178
  469. package/src/theme/migrate.ts +0 -81
  470. package/src/theme/presets.ts +0 -135
  471. package/src/theme/radius.ts +0 -18
  472. package/src/theme/resolve.test.ts +0 -238
  473. package/src/theme/resolve.ts +0 -96
  474. package/src/theme/spacing.ts +0 -18
  475. package/src/theme/storage.test.ts +0 -126
  476. package/src/theme/storage.ts +0 -106
  477. package/src/theme/theme-colors.ts +0 -88
  478. package/src/theme/types.ts +0 -125
  479. package/src/uploads/UploadAdapter.ts +0 -35
  480. package/src/uploads/index.ts +0 -2
  481. package/src/uploads/localUpload.test.ts +0 -70
  482. package/src/uploads/localUpload.ts +0 -84
  483. package/src/validation/Validator.ts +0 -49
  484. package/src/validation/index.ts +0 -28
  485. package/src/validation/rules.ts +0 -78
  486. package/src/validation/runValidators.ts +0 -435
  487. package/src/validation/uniqueValidator.test.ts +0 -196
  488. package/src/validation/uniqueValidator.ts +0 -133
  489. package/src/validation/validators.test.ts +0 -268
  490. package/src/vite.test.ts +0 -184
  491. package/src/vite.ts +0 -787
  492. package/src/widgets/index.ts +0 -10
  493. package/src/widgets/registry.ts +0 -45
  494. package/src/widgets.test.ts +0 -592
  495. package/tsconfig.build.json +0 -11
  496. package/tsconfig.json +0 -4
  497. package/tsconfig.test.json +0 -10
  498. package/views/react/Dashboard.tsx +0 -27
  499. package/views/react/Resources/Form.tsx +0 -102
  500. 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
- })