@pilotiq/pilotiq 0.24.1 → 0.24.3

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