@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,972 +0,0 @@
1
- import { describe, it, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { Router } from '@rudderjs/router'
5
-
6
- import { Pilotiq } from './Pilotiq.js'
7
- import { Resource } from './Resource.js'
8
- import { RelationManager } from './RelationManager.js'
9
- import { Form } from './elements/Form.js'
10
- import { Table } from './elements/Table.js'
11
- import { Column } from './Column.js'
12
- import { TextField } from './fields/TextField.js'
13
- import { registerPilotiqRoutes } from './routes.js'
14
- import type { ModelLike, ModelQuery } from './orm/modelDefaults.js'
15
- import { Action } from './actions/Action.js'
16
-
17
- // ── Test doubles ─────────────────────────────────────────────────
18
-
19
- interface Row extends Record<string, unknown> { id: string | number }
20
-
21
- class StubQuery implements ModelQuery {
22
- private filters: Array<{ col: string; val: unknown }> = []
23
- constructor(private rows: Row[]) {}
24
- where(col: string, ...rest: unknown[]): ModelQuery {
25
- const val = rest.length === 1 ? rest[0] : rest[1]
26
- this.filters.push({ col, val })
27
- return this
28
- }
29
- orWhere(...args: unknown[]): ModelQuery { return this.where(args[0] as string, ...args.slice(1)) }
30
- orderBy(_c: string, _d?: 'ASC' | 'DESC'): ModelQuery { return this }
31
- async paginate() {
32
- let data = this.rows
33
- for (const f of this.filters) data = data.filter(r => r[f.col] === f.val)
34
- return { data, total: data.length }
35
- }
36
- }
37
-
38
- function fakeReq(overrides: Partial<{
39
- params: Record<string, string>
40
- body: unknown
41
- query: Record<string, string>
42
- headers: Record<string, string>
43
- }> = {}): any {
44
- return {
45
- params: overrides.params ?? {},
46
- body: overrides.body ?? null,
47
- query: overrides.query ?? {},
48
- headers: overrides.headers ?? {},
49
- raw: {},
50
- }
51
- }
52
-
53
- interface FakeRes {
54
- statusCode: number
55
- redirectedTo?: { url: string; code: number }
56
- sentBody?: unknown
57
- status(code: number): FakeRes
58
- redirect(url: string, code?: number): FakeRes
59
- send(body: unknown): FakeRes
60
- json(body: unknown): FakeRes
61
- }
62
-
63
- function fakeRes(): FakeRes {
64
- const r: FakeRes = {
65
- statusCode: 200,
66
- status(code) { this.statusCode = code; return this },
67
- redirect(url, code = 302) { this.redirectedTo = { url, code }; return this },
68
- send(body) { this.sentBody = body; return this },
69
- json(body) { this.sentBody = body; return this },
70
- }
71
- return r
72
- }
73
-
74
- async function callHandler(handler: (...args: any[]) => unknown, req: any = fakeReq(), res: any = fakeRes()) {
75
- const result = await handler(req, res)
76
- return { result, res: res as FakeRes }
77
- }
78
-
79
- /**
80
- * Adapt a stub `find(id)` to the `query().where(pk, id).paginate(1, 1)`
81
- * shape that pilotiq's `findRecord(R, id, ctx)` now drives. Lets these
82
- * tests keep their `find(id)` map-backed stubs without rewriting them
83
- * into row arrays.
84
- */
85
- function findAdapter(find: (id: string) => Promise<unknown>): ModelQuery {
86
- let captured: unknown
87
- const q: ModelQuery = {
88
- where(...args: unknown[]): ModelQuery {
89
- captured = args.length === 2 ? args[1] : args[2]
90
- return q
91
- },
92
- orWhere(...args: unknown[]): ModelQuery {
93
- captured = args.length === 2 ? args[1] : args[2]
94
- return q
95
- },
96
- orderBy(): ModelQuery { return q },
97
- async paginate() {
98
- const r = await find(String(captured))
99
- return { data: r ? [r] : [], total: r ? 1 : 0 }
100
- },
101
- }
102
- return q
103
- }
104
-
105
- // ── World builder ─────────────────────────────────────────────────
106
-
107
- function buildWorld() {
108
- const postRows: Row[] = [
109
- { id: 'p1', parentId: 'u1', title: 'Post One' },
110
- { id: 'p2', parentId: 'u1', title: 'Post Two' },
111
- { id: 'p3', parentId: 'u2', title: 'Other Post' },
112
- ]
113
- const PostModel: ModelLike = {
114
- async find(id) { return postRows.find(r => r['id'] === id || String(r['id']) === String(id)) ?? null },
115
- async create(data) { const n: Row = { id: `p${postRows.length + 1}`, ...data }; postRows.push(n); return n },
116
- async update(id, data) { const r = postRows.find(r => r['id'] === id); if (r) Object.assign(r, data); return r ?? null },
117
- async delete(id) { const i = postRows.findIndex(r => r['id'] === id); if (i >= 0) postRows.splice(i, 1) },
118
- query() { return new StubQuery(postRows) },
119
- }
120
-
121
- const parents = new Map<string, { id: string; related: (n: string) => ModelQuery }>([
122
- ['u1', { id: 'u1', related: (_n) => new StubQuery(postRows.filter(r => r['parentId'] === 'u1')) }],
123
- ['u2', { id: 'u2', related: (_n) => new StubQuery(postRows.filter(r => r['parentId'] === 'u2')) }],
124
- ])
125
- const ParentModel: ModelLike = {
126
- async find(id) { return parents.get(String(id)) ?? null },
127
- async create() { throw new Error('not used') },
128
- async update() { throw new Error('not used') },
129
- async delete() { /* no-op */ },
130
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
131
- }
132
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
133
-
134
- class PostResource extends Resource {
135
- static override label = 'Posts'
136
- static override labelSingular = 'Post'
137
- static override slug = 'posts'
138
- static override get model() { return PostModel }
139
- static override form(form: Form): Form { return form.schema([TextField.make('title').required()]) }
140
- }
141
- class PostsManager extends RelationManager {
142
- static override relationship = 'posts'
143
- static override table(t: Table): Table { return t.columns([Column.make('title').sortable()]) }
144
- static override form(f: Form): Form { return f.schema([TextField.make('title').required()]) }
145
- }
146
- class UserResource extends Resource {
147
- static override label = 'Users'
148
- static override slug = 'users'
149
- static override get model() { return ParentModel }
150
- static override relations() { return [PostsManager] }
151
- }
152
-
153
- const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
154
- return { panel, UserResource, PostResource, PostsManager, postRows, ParentModel, PostModel }
155
- }
156
-
157
- // ── Tests ─────────────────────────────────────────────────────────
158
-
159
- describe('relation routes — registration', () => {
160
- let router: Router
161
- beforeEach(() => { router = new Router() })
162
-
163
- it('registers list/create/view/edit/delete per manager', () => {
164
- const { panel } = buildWorld()
165
- registerPilotiqRoutes(router, panel)
166
- const paths = router.list().map(r => `${r.method} ${r.path}`)
167
-
168
- assert.ok(paths.includes('GET /admin/users/:id/posts'), 'list')
169
- assert.ok(paths.includes('GET /admin/users/:id/posts/create'), 'create-get')
170
- assert.ok(paths.includes('POST /admin/users/:id/posts/create'), 'create-post')
171
- assert.ok(paths.includes('GET /admin/users/:id/posts/:childId'), 'view-get')
172
- assert.ok(paths.includes('GET /admin/users/:id/posts/:childId/edit'), 'edit-get')
173
- assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/edit'), 'edit-post')
174
- assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/delete'), 'delete')
175
- })
176
-
177
- it('throws at boot when a manager uses a reserved relationship', () => {
178
- class BadM extends RelationManager {
179
- static override relationship = 'edit'
180
- }
181
- class WithBad extends Resource {
182
- static override slug = 'things'
183
- static override relations() { return [BadM] }
184
- }
185
- const panel = Pilotiq.make('T').path('/admin').resources([WithBad])
186
- assert.throws(
187
- () => registerPilotiqRoutes(new Router(), panel),
188
- /uses reserved relationship "edit"/,
189
- )
190
- })
191
- })
192
-
193
- describe('relation routes — list handler', () => {
194
- let router: Router
195
- beforeEach(() => { router = new Router() })
196
-
197
- it('returns relation-list view with parent-scoped table rows', async () => {
198
- const { panel } = buildWorld()
199
- registerPilotiqRoutes(router, panel)
200
- const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
201
- const { result } = await callHandler(route.handler, fakeReq({ params: { id: 'u1' } }))
202
-
203
- const view = result as { id: string; props: Record<string, unknown> }
204
- assert.equal(view.id, 'pilotiq.relation-list')
205
- const schema = view.props['schemaData'] as Array<Record<string, unknown>>
206
- const tableMeta = schema.find(s => s['type'] === 'table')
207
- assert.ok(tableMeta, 'expected a table element')
208
- const rows = tableMeta['rows'] as Array<Record<string, unknown>>
209
- assert.deepEqual(rows.map(r => r['id']).sort(), ['p1', 'p2'])
210
- })
211
-
212
- it('404s when the parent record is missing', async () => {
213
- const { panel } = buildWorld()
214
- registerPilotiqRoutes(router, panel)
215
- const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
216
- const { res } = await callHandler(route.handler, fakeReq({ params: { id: 'unknown' } }))
217
- assert.equal(res.statusCode, 404)
218
- })
219
-
220
- it('403s when manager.canViewAny denies', async () => {
221
- const { panel } = buildWorld()
222
- // Patch the manager class registered with the panel.
223
- const R = panel.getConfig().resources[0]!
224
- const M = R.relations()[0]!
225
- ;(M as unknown as { canViewAny: (...a: unknown[]) => Promise<boolean> }).canViewAny = async () => false
226
-
227
- registerPilotiqRoutes(router, panel)
228
- const route = router.list().find(r => r.path === '/admin/users/:id/posts' && r.method === 'GET')!
229
- const { res } = await callHandler(route.handler, fakeReq({ params: { id: 'u1' } }))
230
- assert.equal(res.statusCode, 403)
231
- })
232
- })
233
-
234
- describe('relation routes — view GET (Phase A)', () => {
235
- let router: Router
236
- beforeEach(() => { router = new Router() })
237
-
238
- it('returns relation-view for a child that belongs to the parent', async () => {
239
- const { panel } = buildWorld()
240
- registerPilotiqRoutes(router, panel)
241
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
242
- const { result, res } = await callHandler(
243
- route.handler,
244
- fakeReq({ params: { id: 'u1', childId: 'p1' } }),
245
- )
246
- assert.equal(res.statusCode, 200)
247
- const view = result as { id: string; props: Record<string, unknown> }
248
- assert.equal(view.id, 'pilotiq.relation-view')
249
- assert.equal(view.props['mode'], 'view')
250
- assert.equal(view.props['childId'], 'p1')
251
- })
252
-
253
- it('404s under IDOR (child belongs to a different parent)', async () => {
254
- const { panel } = buildWorld()
255
- registerPilotiqRoutes(router, panel)
256
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
257
- const { res } = await callHandler(
258
- route.handler,
259
- fakeReq({ params: { id: 'u1', childId: 'p3' } }), // p3 is u2's
260
- )
261
- assert.equal(res.statusCode, 404)
262
- })
263
-
264
- it('404s when childId is the literal "create" reserved token', async () => {
265
- const { panel } = buildWorld()
266
- registerPilotiqRoutes(router, panel)
267
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
268
- const { res } = await callHandler(
269
- route.handler,
270
- fakeReq({ params: { id: 'u1', childId: 'create' } }),
271
- )
272
- assert.equal(res.statusCode, 404)
273
- })
274
-
275
- it('403s when manager.canView denies', async () => {
276
- const { panel } = buildWorld()
277
- const R = panel.getConfig().resources[0]!
278
- const M = R.relations()[0]!
279
- ;(M as unknown as { canView: (...a: unknown[]) => Promise<boolean> }).canView = async () => false
280
-
281
- registerPilotiqRoutes(router, panel)
282
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId' && r.method === 'GET')!
283
- const { res } = await callHandler(
284
- route.handler,
285
- fakeReq({ params: { id: 'u1', childId: 'p1' } }),
286
- )
287
- assert.equal(res.statusCode, 403)
288
- })
289
- })
290
-
291
- describe('relation routes — create POST', () => {
292
- let router: Router
293
- beforeEach(() => { router = new Router() })
294
-
295
- it('creates a child and redirects to the list', async () => {
296
- const { panel, postRows } = buildWorld()
297
- registerPilotiqRoutes(router, panel)
298
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/create' && r.method === 'POST')!
299
- const { res } = await callHandler(
300
- route.handler,
301
- fakeReq({ params: { id: 'u1' }, body: { title: 'Fresh post' } }),
302
- )
303
-
304
- assert.equal(res.redirectedTo?.url, '/admin/users/u1/posts')
305
- assert.equal(res.redirectedTo?.code, 303)
306
- assert.ok(postRows.some(r => r['title'] === 'Fresh post'))
307
- })
308
-
309
- it('422 on validation failure with prefilled values', async () => {
310
- const { panel } = buildWorld()
311
- registerPilotiqRoutes(router, panel)
312
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/create' && r.method === 'POST')!
313
- const { result, res } = await callHandler(
314
- route.handler,
315
- fakeReq({ params: { id: 'u1' }, body: { title: '' } }), // required violated
316
- )
317
- assert.equal(res.statusCode, 422)
318
- const view = result as { id: string; props: Record<string, unknown> }
319
- assert.equal(view.id, 'pilotiq.relation-create')
320
- assert.equal(view.props['hasErrors'], true)
321
- })
322
- })
323
-
324
- describe('relation routes — edit + delete POST', () => {
325
- let router: Router
326
- beforeEach(() => { router = new Router() })
327
-
328
- it('edit POST updates the child and redirects', async () => {
329
- const { panel, postRows } = buildWorld()
330
- registerPilotiqRoutes(router, panel)
331
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/edit' && r.method === 'POST')!
332
- const { res } = await callHandler(
333
- route.handler,
334
- fakeReq({ params: { id: 'u1', childId: 'p1' }, body: { title: 'Renamed' } }),
335
- )
336
- assert.equal(res.redirectedTo?.code, 303)
337
- assert.equal(postRows.find(r => r['id'] === 'p1')!['title'], 'Renamed')
338
- })
339
-
340
- it('edit POST 404s when the child belongs to a different parent (IDOR)', async () => {
341
- const { panel } = buildWorld()
342
- registerPilotiqRoutes(router, panel)
343
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/edit' && r.method === 'POST')!
344
- // p3 belongs to u2, not u1 — this MUST not edit anything.
345
- const { res } = await callHandler(
346
- route.handler,
347
- fakeReq({ params: { id: 'u1', childId: 'p3' }, body: { title: 'Hacked' } }),
348
- )
349
- assert.equal(res.statusCode, 404)
350
- })
351
-
352
- it('delete POST removes the child and redirects to the list', async () => {
353
- const { panel, postRows } = buildWorld()
354
- registerPilotiqRoutes(router, panel)
355
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/delete' && r.method === 'POST')!
356
- const { res } = await callHandler(
357
- route.handler,
358
- fakeReq({ params: { id: 'u1', childId: 'p2' } }),
359
- )
360
- assert.equal(res.redirectedTo?.url, '/admin/users/u1/posts')
361
- assert.equal(res.redirectedTo?.code, 303)
362
- assert.ok(!postRows.some(r => r['id'] === 'p2'), 'child p2 should be deleted')
363
- })
364
-
365
- it('delete POST 404s under IDOR', async () => {
366
- const { panel, postRows } = buildWorld()
367
- registerPilotiqRoutes(router, panel)
368
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/delete' && r.method === 'POST')!
369
- const { res } = await callHandler(
370
- route.handler,
371
- fakeReq({ params: { id: 'u1', childId: 'p3' } }),
372
- )
373
- assert.equal(res.statusCode, 404)
374
- // p3 still in store untouched
375
- assert.ok(postRows.some(r => r['id'] === 'p3'))
376
- })
377
- })
378
-
379
- // ── M2M follow-up: manager-scoped _action + _detach routes ──────────
380
-
381
- /** World builder for a M2M Article ↔ Tag relation. Defaults to
382
- * `belongsToMany`; pass `'morphToMany'` or `'morphedByMany'` to flip the
383
- * relations-map type so `getRelationType` resolves to the polymorphic
384
- * variant. The runtime accessor surface (where, paginate, attach,
385
- * detach) is identical across all three — the rudder ORM stamps +
386
- * filters the polymorphic discriminator on the morph variants
387
- * automatically, so pilotiq's plumbing is mode-agnostic beyond the
388
- * detach 404 gate + visibility predicates. */
389
- function buildM2MWorld(morphMode: 'belongsToMany' | 'morphToMany' | 'morphedByMany' = 'belongsToMany') {
390
- const tagRows: Row[] = [
391
- { id: 't1', name: 'red' },
392
- { id: 't2', name: 'blue' },
393
- { id: 't3', name: 'green' },
394
- ]
395
- const TagModel: ModelLike = {
396
- async find(id) { return tagRows.find(r => String(r['id']) === String(id)) ?? null },
397
- async create() { throw new Error('not used') },
398
- async update() { throw new Error('not used') },
399
- async delete() { /* no-op */ },
400
- query() { return new StubQuery(tagRows) },
401
- }
402
-
403
- // Mutable pivot store: which tag ids are attached to each article.
404
- const pivot = new Map<string, Set<string>>([
405
- ['a1', new Set(['t1', 't2'])],
406
- ['a2', new Set([])],
407
- ])
408
-
409
- function makeRelatedAccessor(articleId: string) {
410
- return {
411
- where(_col: string, _op: string, val: unknown) {
412
- return {
413
- paginate: async (_p: number, _pp: number) => {
414
- const id = String(val)
415
- const attached = pivot.get(articleId) ?? new Set()
416
- const data = attached.has(id) ? [tagRows.find(r => String(r['id']) === id)!] : []
417
- return { data, total: data.length }
418
- },
419
- }
420
- },
421
- paginate: async (_p: number, _pp: number) => {
422
- const attached = pivot.get(articleId) ?? new Set()
423
- const data = tagRows.filter(r => attached.has(String(r['id'])))
424
- return { data, total: data.length }
425
- },
426
- attach: async (input: unknown) => {
427
- const ids = Array.isArray(input) ? input.map(String) : [String(input)]
428
- const set = pivot.get(articleId) ?? new Set()
429
- for (const id of ids) set.add(id)
430
- pivot.set(articleId, set)
431
- },
432
- detach: async (input: unknown) => {
433
- const ids = Array.isArray(input) ? input.map(String) : input === undefined ? [] : [String(input)]
434
- const set = pivot.get(articleId) ?? new Set()
435
- let n = 0
436
- for (const id of ids) { if (set.delete(id)) n++ }
437
- return n
438
- },
439
- }
440
- }
441
-
442
- const ArticleModel: ModelLike = {
443
- async find(id) {
444
- const articleId = String(id)
445
- if (!pivot.has(articleId)) return null
446
- return {
447
- id: articleId,
448
- related: (_n: string) => makeRelatedAccessor(articleId) as never,
449
- }
450
- },
451
- async create() { throw new Error('not used') },
452
- async update() { throw new Error('not used') },
453
- async delete() { /* no-op */ },
454
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
455
- }
456
- // Tag the Article-side relations map with the M2M discriminator so
457
- // `getRelationType` flips the manager mode to the requested variant.
458
- Object.assign(ArticleModel as object, {
459
- relations: { tags: { type: morphMode, model: () => TagModel } },
460
- })
461
-
462
- class TagResource extends Resource {
463
- static override label = 'Tags'
464
- static override labelSingular = 'Tag'
465
- static override slug = 'tags'
466
- static override get model() { return TagModel }
467
- }
468
- class TagsManager extends RelationManager {
469
- static override relationship = 'tags'
470
- static override table(t: Table): Table { return t.columns([Column.make('name').sortable()]) }
471
- }
472
- class ArticleResource extends Resource {
473
- static override label = 'Articles'
474
- static override slug = 'articles'
475
- static override get model() { return ArticleModel }
476
- static override relations() { return [TagsManager] }
477
- }
478
-
479
- const panel = Pilotiq.make('T').path('/admin').resources([ArticleResource, TagResource])
480
- return { panel, ArticleResource, TagResource, TagsManager, pivot, tagRows }
481
- }
482
-
483
- describe('relation routes — M2M registration', () => {
484
- let router: Router
485
- beforeEach(() => { router = new Router() })
486
-
487
- it('mounts manager-scoped _action and _detach routes for every manager', () => {
488
- const { panel } = buildWorld() // hasMany world — _action still mounts unconditionally
489
- registerPilotiqRoutes(router, panel)
490
- const paths = router.list().map(r => `${r.method} ${r.path}`)
491
- assert.ok(paths.includes('POST /admin/users/:id/posts/_action/:actionName'))
492
- assert.ok(paths.includes('POST /admin/users/:id/posts/:childId/_detach'))
493
- })
494
-
495
- it('mounts the same manager-scoped routes for M2M managers', () => {
496
- const { panel } = buildM2MWorld()
497
- registerPilotiqRoutes(router, panel)
498
- const paths = router.list().map(r => `${r.method} ${r.path}`)
499
- assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
500
- assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
501
- })
502
- })
503
-
504
- describe('relation routes — _detach (M2M)', () => {
505
- let router: Router
506
- beforeEach(() => { router = new Router() })
507
-
508
- it('detaches an attached tag and redirects to the list', async () => {
509
- const { panel, pivot } = buildM2MWorld()
510
- registerPilotiqRoutes(router, panel)
511
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
512
- const { res } = await callHandler(
513
- route.handler,
514
- fakeReq({ params: { id: 'a1', childId: 't1' } }),
515
- )
516
- assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
517
- assert.equal(res.redirectedTo?.code, 303)
518
- assert.equal(pivot.get('a1')?.has('t1'), false)
519
- assert.equal(pivot.get('a1')?.has('t2'), true)
520
- })
521
-
522
- it('IDOR-404s when the tag is not attached to this article', async () => {
523
- const { panel, pivot } = buildM2MWorld()
524
- registerPilotiqRoutes(router, panel)
525
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
526
- const { res } = await callHandler(
527
- route.handler,
528
- fakeReq({ params: { id: 'a1', childId: 't3' } }), // t3 isn't attached
529
- )
530
- assert.equal(res.statusCode, 404)
531
- // Pivot untouched.
532
- assert.equal(pivot.get('a1')?.size, 2)
533
- })
534
-
535
- it('404s when the manager mode is hasMany (not M2M)', async () => {
536
- const { panel, postRows } = buildWorld() // hasMany world
537
- registerPilotiqRoutes(router, panel)
538
- const route = router.list().find(r => r.path === '/admin/users/:id/posts/:childId/_detach' && r.method === 'POST')!
539
- const { res } = await callHandler(
540
- route.handler,
541
- fakeReq({ params: { id: 'u1', childId: 'p1' } }),
542
- )
543
- assert.equal(res.statusCode, 404)
544
- // Error message lists every M2M mode pilotiq accepts so the user
545
- // knows what to declare on the parent's `static relations` map.
546
- assert.match(String(res.sentBody), /belongsToMany/)
547
- assert.match(String(res.sentBody), /morphToMany/)
548
- assert.match(String(res.sentBody), /morphedByMany/)
549
- // Underlying record unchanged.
550
- assert.ok(postRows.some(r => r['id'] === 'p1'))
551
- })
552
-
553
- it('403s when manager.canDetach denies', async () => {
554
- const { panel } = buildM2MWorld()
555
- const M = panel.getConfig().resources[0]!.relations()[0]!
556
- ;(M as unknown as { canDetach: () => Promise<boolean> }).canDetach = async () => false
557
- registerPilotiqRoutes(router, panel)
558
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
559
- const { res } = await callHandler(
560
- route.handler,
561
- fakeReq({ params: { id: 'a1', childId: 't1' } }),
562
- )
563
- assert.equal(res.statusCode, 403)
564
- })
565
- })
566
-
567
- describe('relation routes — _action (manager-scoped)', () => {
568
- let router: Router
569
- beforeEach(() => { router = new Router() })
570
-
571
- it('dispatches a handler-style action with ctx.relation stamped', async () => {
572
- const { panel, pivot } = buildM2MWorld()
573
- // Wire up `relationAttach` on the manager's table so we have a
574
- // dispatchable handler-style action to fire. Using the real factory
575
- // exercises the full pipeline.
576
- const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
577
- const originalTable = TagsManager.table.bind(TagsManager)
578
- ;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
579
- return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
580
- }
581
-
582
- registerPilotiqRoutes(router, panel)
583
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
584
- const { res } = await callHandler(
585
- route.handler,
586
- fakeReq({
587
- params: { id: 'a2', actionName: 'relationAttach' },
588
- body: { _attachId: 't3', ids: [] },
589
- headers: { accept: 'application/json' },
590
- }),
591
- )
592
-
593
- const body = res.sentBody as { ok: boolean; redirect?: string; notifications?: Array<{ title: string }> }
594
- assert.equal(body.ok, true)
595
- // Pivot state mutated by the handler — proves ctx.relation was stamped.
596
- assert.equal(pivot.get('a2')?.has('t3'), true)
597
- assert.match(body.notifications?.[0]?.title ?? '', /attached/)
598
- })
599
-
600
- it('404s when the named action is not registered on the manager', async () => {
601
- const { panel } = buildM2MWorld()
602
- registerPilotiqRoutes(router, panel)
603
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
604
- const { res } = await callHandler(
605
- route.handler,
606
- fakeReq({
607
- params: { id: 'a1', actionName: 'unknownAction' },
608
- body: {},
609
- headers: { accept: 'application/json' },
610
- }),
611
- )
612
- assert.equal(res.statusCode, 404)
613
- })
614
-
615
- it('403s when parent canEdit denies', async () => {
616
- const { panel } = buildM2MWorld()
617
- const R = panel.getConfig().resources[0]!
618
- ;(R as unknown as { canEdit: () => Promise<boolean> }).canEdit = async () => false
619
- registerPilotiqRoutes(router, panel)
620
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
621
- const { res } = await callHandler(
622
- route.handler,
623
- fakeReq({
624
- params: { id: 'a1', actionName: 'relationAttach' },
625
- headers: { accept: 'application/json' },
626
- }),
627
- )
628
- assert.equal(res.statusCode, 403)
629
- })
630
- })
631
-
632
- // ── Polymorphic M2M follow-up: morphToMany / morphedByMany ──────────
633
- //
634
- // Both modes share the `belongsToMany` accessor surface (attach /
635
- // detach / sync). Pilotiq's plumbing is mode-agnostic beyond the
636
- // `_detach` 404 gate + visibility predicates — the tests below confirm
637
- // the same routes and stub accessors that worked for `belongsToMany`
638
- // also work for the morph variants.
639
-
640
- describe('relation routes — morphToMany (owning polymorphic side)', () => {
641
- let router: Router
642
- beforeEach(() => { router = new Router() })
643
-
644
- it('mounts the manager-scoped routes for morphToMany managers', () => {
645
- const { panel } = buildM2MWorld('morphToMany')
646
- registerPilotiqRoutes(router, panel)
647
- const paths = router.list().map(r => `${r.method} ${r.path}`)
648
- assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
649
- assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
650
- })
651
-
652
- it('detaches an attached tag and redirects to the list (morphToMany)', async () => {
653
- const { panel, pivot } = buildM2MWorld('morphToMany')
654
- registerPilotiqRoutes(router, panel)
655
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
656
- const { res } = await callHandler(
657
- route.handler,
658
- fakeReq({ params: { id: 'a1', childId: 't1' } }),
659
- )
660
- assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
661
- assert.equal(res.redirectedTo?.code, 303)
662
- assert.equal(pivot.get('a1')?.has('t1'), false)
663
- assert.equal(pivot.get('a1')?.has('t2'), true)
664
- })
665
-
666
- it('IDOR-404s when the tag is not attached (morphToMany)', async () => {
667
- const { panel, pivot } = buildM2MWorld('morphToMany')
668
- registerPilotiqRoutes(router, panel)
669
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
670
- const { res } = await callHandler(
671
- route.handler,
672
- fakeReq({ params: { id: 'a1', childId: 't3' } }),
673
- )
674
- assert.equal(res.statusCode, 404)
675
- assert.equal(pivot.get('a1')?.size, 2)
676
- })
677
-
678
- it('dispatches relationAttach with ctx.relation stamped (morphToMany)', async () => {
679
- const { panel, pivot } = buildM2MWorld('morphToMany')
680
- const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
681
- const originalTable = TagsManager.table.bind(TagsManager)
682
- ;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
683
- return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
684
- }
685
- registerPilotiqRoutes(router, panel)
686
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
687
- const { res } = await callHandler(
688
- route.handler,
689
- fakeReq({
690
- params: { id: 'a2', actionName: 'relationAttach' },
691
- body: { _attachId: 't3', ids: [] },
692
- headers: { accept: 'application/json' },
693
- }),
694
- )
695
- const body = res.sentBody as { ok: boolean }
696
- assert.equal(body.ok, true)
697
- assert.equal(pivot.get('a2')?.has('t3'), true)
698
- })
699
- })
700
-
701
- describe('relation routes — morphedByMany (inverse polymorphic side)', () => {
702
- let router: Router
703
- beforeEach(() => { router = new Router() })
704
-
705
- it('mounts the manager-scoped routes for morphedByMany managers', () => {
706
- const { panel } = buildM2MWorld('morphedByMany')
707
- registerPilotiqRoutes(router, panel)
708
- const paths = router.list().map(r => `${r.method} ${r.path}`)
709
- assert.ok(paths.includes('POST /admin/articles/:id/tags/_action/:actionName'))
710
- assert.ok(paths.includes('POST /admin/articles/:id/tags/:childId/_detach'))
711
- })
712
-
713
- it('detaches an attached tag and redirects to the list (morphedByMany)', async () => {
714
- const { panel, pivot } = buildM2MWorld('morphedByMany')
715
- registerPilotiqRoutes(router, panel)
716
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/:childId/_detach' && r.method === 'POST')!
717
- const { res } = await callHandler(
718
- route.handler,
719
- fakeReq({ params: { id: 'a1', childId: 't1' } }),
720
- )
721
- assert.equal(res.redirectedTo?.url, '/admin/articles/a1/tags')
722
- assert.equal(res.redirectedTo?.code, 303)
723
- assert.equal(pivot.get('a1')?.has('t1'), false)
724
- })
725
-
726
- it('dispatches relationAttach with ctx.relation stamped (morphedByMany)', async () => {
727
- const { panel, pivot } = buildM2MWorld('morphedByMany')
728
- const TagsManager = panel.getConfig().resources[0]!.relations()[0]!
729
- const originalTable = TagsManager.table.bind(TagsManager)
730
- ;(TagsManager as unknown as { table: typeof TagsManager.table }).table = (t, ctx) => {
731
- return originalTable(t, ctx).headerActions([Action.relationAttach(TagsManager, ctx)])
732
- }
733
- registerPilotiqRoutes(router, panel)
734
- const route = router.list().find(r => r.path === '/admin/articles/:id/tags/_action/:actionName' && r.method === 'POST')!
735
- const { res } = await callHandler(
736
- route.handler,
737
- fakeReq({
738
- params: { id: 'a2', actionName: 'relationAttach' },
739
- body: { _attachId: 't3', ids: [] },
740
- headers: { accept: 'application/json' },
741
- }),
742
- )
743
- const body = res.sentBody as { ok: boolean }
744
- assert.equal(body.ok, true)
745
- assert.equal(pivot.get('a2')?.has('t3'), true)
746
- })
747
- })
748
-
749
- // ── Polymorphic follow-up: morphMany auto-injection ─────────────────
750
-
751
- /** World builder for a polymorphic `morphMany` relation:
752
- * Post.comments → Comment.commentable
753
- * Video.comments → Comment.commentable
754
- * Children carry `commentableId` + `commentableType`. The discriminator
755
- * defaults to the parent's `class.morphAlias ?? class.name`. */
756
- function buildMorphWorld() {
757
- const commentRows: Row[] = [
758
- { id: 'c1', commentableId: 'p1', commentableType: 'Post', body: 'Existing on post' },
759
- { id: 'c2', commentableId: 'v1', commentableType: 'Video', body: 'Existing on video' },
760
- ]
761
-
762
- const CommentModel: ModelLike = {
763
- async find(id) { return commentRows.find(r => String(r['id']) === String(id)) ?? null },
764
- async create(data) {
765
- const n: Row = { id: `c${commentRows.length + 1}`, ...data }
766
- commentRows.push(n)
767
- return n
768
- },
769
- async update(id, data) {
770
- const r = commentRows.find(r => String(r['id']) === String(id))
771
- if (r) Object.assign(r, data)
772
- return r ?? null
773
- },
774
- async delete(id) {
775
- const i = commentRows.findIndex(r => String(r['id']) === String(id))
776
- if (i >= 0) commentRows.splice(i, 1)
777
- },
778
- query() { return new StubQuery(commentRows) },
779
- }
780
-
781
- // Parent factory — returns a record whose constructor.name doubles as
782
- // the morph discriminator (mirrors rudder's runtime where the live
783
- // record is an instance of `class Post extends Model {}`). We fake the
784
- // class identity via `Object.setPrototypeOf`.
785
- function makeParentRecord(klass: { name: string; morphAlias?: string; primaryKey?: string }, id: string) {
786
- const rec = {
787
- [klass.primaryKey ?? 'id']: id,
788
- related(_n: string) {
789
- return new StubQuery(commentRows.filter(r => r['commentableId'] === id && r['commentableType'] === (klass.morphAlias ?? klass.name)))
790
- },
791
- }
792
- Object.setPrototypeOf(rec, { constructor: klass })
793
- return rec
794
- }
795
-
796
- const PostClass = { name: 'Post', primaryKey: 'id' }
797
- const VideoClass = { name: 'Video', primaryKey: 'id' }
798
-
799
- const PostModel: ModelLike = {
800
- async find(id) {
801
- if (String(id) === 'p1') return makeParentRecord(PostClass, 'p1')
802
- return null
803
- },
804
- async create() { throw new Error('not used') },
805
- async update() { throw new Error('not used') },
806
- async delete() { /* no-op */ },
807
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
808
- }
809
- Object.assign(PostModel as object, {
810
- relations: { comments: { type: 'morphMany', model: () => CommentModel, morphName: 'commentable' } },
811
- })
812
-
813
- const VideoModel: ModelLike = {
814
- async find(id) {
815
- if (String(id) === 'v1') return makeParentRecord(VideoClass, 'v1')
816
- return null
817
- },
818
- async create() { throw new Error('not used') },
819
- async update() { throw new Error('not used') },
820
- async delete() { /* no-op */ },
821
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
822
- }
823
- Object.assign(VideoModel as object, {
824
- relations: { comments: { type: 'morphMany', model: () => CommentModel, morphName: 'commentable' } },
825
- })
826
-
827
- class CommentResource extends Resource {
828
- static override label = 'Comments'
829
- static override labelSingular = 'Comment'
830
- static override slug = 'comments'
831
- static override get model() { return CommentModel }
832
- static override form(form: Form): Form { return form.schema([TextField.make('body').required()]) }
833
- }
834
- class CommentsManager extends RelationManager {
835
- static override relationship = 'comments'
836
- static override table(t: Table): Table { return t.columns([Column.make('body')]) }
837
- static override form(f: Form): Form { return f.schema([TextField.make('body').required()]) }
838
- }
839
- class PostResource extends Resource {
840
- static override label = 'Posts'
841
- static override slug = 'posts'
842
- static override get model() { return PostModel }
843
- static override relations() { return [CommentsManager] }
844
- }
845
- class VideoResource extends Resource {
846
- static override label = 'Videos'
847
- static override slug = 'videos'
848
- static override get model() { return VideoModel }
849
- static override relations() { return [CommentsManager] }
850
- }
851
-
852
- const panel = Pilotiq.make('T').path('/admin').resources([PostResource, VideoResource, CommentResource])
853
- return { panel, PostResource, VideoResource, CommentResource, CommentsManager, commentRows }
854
- }
855
-
856
- describe('relation routes — polymorphic morphMany', () => {
857
- let router: Router
858
- beforeEach(() => { router = new Router() })
859
-
860
- it('auto-injects commentableId / commentableType on create POST', async () => {
861
- const { panel, commentRows } = buildMorphWorld()
862
- registerPilotiqRoutes(router, panel)
863
- const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
864
- const { res } = await callHandler(
865
- route.handler,
866
- fakeReq({ params: { id: 'p1' }, body: { body: 'Polymorphic child' } }),
867
- )
868
- assert.equal(res.redirectedTo?.code, 303)
869
- const created = commentRows.find(r => r['body'] === 'Polymorphic child')
870
- assert.ok(created, 'expected the new comment to be persisted')
871
- assert.equal(created['commentableId'], 'p1')
872
- assert.equal(created['commentableType'], 'Post')
873
- })
874
-
875
- it('uses the parent class.name (Video) as the discriminator for the second parent', async () => {
876
- const { panel, commentRows } = buildMorphWorld()
877
- registerPilotiqRoutes(router, panel)
878
- const route = router.list().find(r => r.path === '/admin/videos/:id/comments/create' && r.method === 'POST')!
879
- await callHandler(
880
- route.handler,
881
- fakeReq({ params: { id: 'v1' }, body: { body: 'On a video' } }),
882
- )
883
- const created = commentRows.find(r => r['body'] === 'On a video')
884
- assert.ok(created)
885
- assert.equal(created['commentableId'], 'v1')
886
- assert.equal(created['commentableType'], 'Video')
887
- })
888
-
889
- it('overwrites tampered commentableId / commentableType in the body (anti-tamper)', async () => {
890
- const { panel, commentRows } = buildMorphWorld()
891
- registerPilotiqRoutes(router, panel)
892
- const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
893
- await callHandler(
894
- route.handler,
895
- fakeReq({
896
- params: { id: 'p1' },
897
- // Attacker tries to redirect ownership to v1/Video.
898
- body: { body: 'Hijacked', commentableId: 'v1', commentableType: 'Video' },
899
- }),
900
- )
901
- const created = commentRows.find(r => r['body'] === 'Hijacked')
902
- assert.ok(created)
903
- // Framework wins — child still owned by the URL-scoped parent.
904
- assert.equal(created['commentableId'], 'p1')
905
- assert.equal(created['commentableType'], 'Post')
906
- })
907
-
908
- it('composes with a user-supplied mutateDataBeforeCreate (user runs first, framework wins last)', async () => {
909
- const { panel, commentRows } = buildMorphWorld()
910
- // Mutate the registered manager's form to add a default body via user hook.
911
- const M = panel.getConfig().resources[0]!.relations()[0]!
912
- ;(M as unknown as { form: (f: Form) => Form }).form = (f: Form) =>
913
- f.schema([TextField.make('body').required()])
914
- .mutateDataBeforeCreate(async (data) => ({ ...data, audited: true, commentableType: 'Tampered' }))
915
-
916
- registerPilotiqRoutes(router, panel)
917
- const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
918
- await callHandler(
919
- route.handler,
920
- fakeReq({ params: { id: 'p1' }, body: { body: 'Composed' } }),
921
- )
922
- const created = commentRows.find(r => r['body'] === 'Composed')
923
- assert.ok(created)
924
- // User hook ran (audited stamped) AND framework morph injection won
925
- // for the morph columns themselves.
926
- assert.equal(created['audited'], true)
927
- assert.equal(created['commentableId'], 'p1')
928
- assert.equal(created['commentableType'], 'Post')
929
- })
930
-
931
- it('honors parent.constructor.morphAlias when set', async () => {
932
- const { panel, commentRows } = buildMorphWorld()
933
- // Replace PostModel.find to return a record whose ctor exposes morphAlias.
934
- const PostR = panel.getConfig().resources[0]!
935
- const PostM = PostR.model!
936
- const original = PostM.find.bind(PostM)
937
- ;(PostM as unknown as { find: (id: unknown) => Promise<unknown> }).find = async (id: unknown) => {
938
- const rec = await original(id as string)
939
- if (!rec) return rec
940
- const klass = { name: 'Post', morphAlias: 'post', primaryKey: 'id' }
941
- Object.setPrototypeOf(rec as object, { constructor: klass })
942
- return rec
943
- }
944
-
945
- registerPilotiqRoutes(router, panel)
946
- const route = router.list().find(r => r.path === '/admin/posts/:id/comments/create' && r.method === 'POST')!
947
- await callHandler(
948
- route.handler,
949
- fakeReq({ params: { id: 'p1' }, body: { body: 'Aliased' } }),
950
- )
951
- const created = commentRows.find(r => r['body'] === 'Aliased')
952
- assert.ok(created)
953
- assert.equal(created['commentableType'], 'post') // alias, not class name
954
- })
955
-
956
- it('re-stamps morph columns on edit POST so a tampered body cannot reassign ownership', async () => {
957
- const { panel, commentRows } = buildMorphWorld()
958
- registerPilotiqRoutes(router, panel)
959
- const route = router.list().find(r => r.path === '/admin/posts/:id/comments/:childId/edit' && r.method === 'POST')!
960
- await callHandler(
961
- route.handler,
962
- fakeReq({
963
- params: { id: 'p1', childId: 'c1' },
964
- body: { body: 'Edited', commentableId: 'v1', commentableType: 'Video' },
965
- }),
966
- )
967
- const c1 = commentRows.find(r => r['id'] === 'c1')!
968
- assert.equal(c1['body'], 'Edited')
969
- assert.equal(c1['commentableId'], 'p1')
970
- assert.equal(c1['commentableType'], 'Post')
971
- })
972
- })