@pilotiq/pilotiq 0.23.1 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (500) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/boost/guidelines.md +566 -0
  3. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  4. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  5. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  6. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  7. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  9. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  10. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  11. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  12. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  13. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  14. package/dist/actions/exportFactory.d.ts +10 -0
  15. package/dist/actions/exportFactory.d.ts.map +1 -1
  16. package/dist/actions/exportFactory.js +10 -0
  17. package/dist/actions/exportFactory.js.map +1 -1
  18. package/dist/react/CollabRoomContext.d.ts +5 -5
  19. package/dist/react/index.d.ts +0 -1
  20. package/dist/react/index.d.ts.map +1 -1
  21. package/dist/react/index.js +0 -1
  22. package/dist/react/index.js.map +1 -1
  23. package/dist/routes/helpers.d.ts.map +1 -1
  24. package/dist/routes/helpers.js +6 -2
  25. package/dist/routes/helpers.js.map +1 -1
  26. package/dist/routes/relations.d.ts.map +1 -1
  27. package/dist/routes/relations.js +12 -0
  28. package/dist/routes/relations.js.map +1 -1
  29. package/package.json +6 -1
  30. package/.turbo/turbo-build.log +0 -8
  31. package/CLAUDE.md +0 -265
  32. package/dist/react/useCollabSeed.d.ts +0 -23
  33. package/dist/react/useCollabSeed.d.ts.map +0 -1
  34. package/dist/react/useCollabSeed.js +0 -82
  35. package/dist/react/useCollabSeed.js.map +0 -1
  36. package/src/Cluster.test.ts +0 -283
  37. package/src/Cluster.ts +0 -83
  38. package/src/Column.test.ts +0 -199
  39. package/src/Column.ts +0 -710
  40. package/src/Global.test.ts +0 -367
  41. package/src/Global.ts +0 -169
  42. package/src/Page.test.ts +0 -114
  43. package/src/Page.ts +0 -208
  44. package/src/Pilotiq.perf.test.ts +0 -252
  45. package/src/Pilotiq.test.ts +0 -129
  46. package/src/Pilotiq.ts +0 -1158
  47. package/src/PilotiqRegistry.ts +0 -36
  48. package/src/PilotiqServiceProvider.ts +0 -121
  49. package/src/RelationManager.test.ts +0 -400
  50. package/src/RelationManager.ts +0 -527
  51. package/src/RenderHook.test.ts +0 -252
  52. package/src/RenderHook.ts +0 -242
  53. package/src/Resource.test.ts +0 -284
  54. package/src/Resource.ts +0 -526
  55. package/src/RightPanel.test.ts +0 -202
  56. package/src/RightPanel.ts +0 -132
  57. package/src/Tab.test.ts +0 -91
  58. package/src/Tab.ts +0 -156
  59. package/src/UserMenuItem.ts +0 -145
  60. package/src/actions/Action.test.ts +0 -2526
  61. package/src/actions/Action.ts +0 -1515
  62. package/src/actions/ActionGroup.test.ts +0 -112
  63. package/src/actions/ActionGroup.ts +0 -173
  64. package/src/actions/attachFactory.ts +0 -172
  65. package/src/actions/bulkFactories.ts +0 -168
  66. package/src/actions/crudFactories.ts +0 -220
  67. package/src/actions/exportFactory.ts +0 -215
  68. package/src/actions/factoryHelpers.ts +0 -177
  69. package/src/actions/importFactory.ts +0 -243
  70. package/src/actions/index.ts +0 -17
  71. package/src/actions/m2mFactories.ts +0 -193
  72. package/src/actions/relationFactories.ts +0 -372
  73. package/src/applyPageHooks.test.ts +0 -463
  74. package/src/applyPageHooks.ts +0 -330
  75. package/src/authorization.test.ts +0 -483
  76. package/src/breadcrumbs.test.ts +0 -238
  77. package/src/cells/coerce.test.ts +0 -85
  78. package/src/cells/coerce.ts +0 -84
  79. package/src/clusterPaths.ts +0 -35
  80. package/src/columns/BadgeColumn.test.ts +0 -54
  81. package/src/columns/BadgeColumn.ts +0 -32
  82. package/src/columns/BooleanColumn.test.ts +0 -41
  83. package/src/columns/BooleanColumn.ts +0 -18
  84. package/src/columns/ColorColumn.test.ts +0 -37
  85. package/src/columns/ColorColumn.ts +0 -38
  86. package/src/columns/IconColumn.test.ts +0 -54
  87. package/src/columns/IconColumn.ts +0 -37
  88. package/src/columns/ImageColumn.test.ts +0 -41
  89. package/src/columns/ImageColumn.ts +0 -28
  90. package/src/columns/SelectColumn.ts +0 -98
  91. package/src/columns/TextColumn.test.ts +0 -190
  92. package/src/columns/TextColumn.ts +0 -20
  93. package/src/columns/TextInputColumn.ts +0 -68
  94. package/src/columns/ToggleColumn.ts +0 -46
  95. package/src/columns/editableColumns.test.ts +0 -238
  96. package/src/columns/index.ts +0 -9
  97. package/src/defaultGlobalPages.ts +0 -95
  98. package/src/defaultPages.test.ts +0 -634
  99. package/src/defaultPages.ts +0 -617
  100. package/src/defaultViewPage.test.ts +0 -147
  101. package/src/elements/Form.test.ts +0 -223
  102. package/src/elements/Form.ts +0 -416
  103. package/src/elements/ListTabs.ts +0 -28
  104. package/src/elements/Table.test.ts +0 -422
  105. package/src/elements/Table.ts +0 -850
  106. package/src/elements/TableGroup.test.ts +0 -260
  107. package/src/elements/TableGroup.ts +0 -334
  108. package/src/elements/dispatchAction.test.ts +0 -463
  109. package/src/elements/dispatchAction.ts +0 -355
  110. package/src/elements/dispatchForm.test.ts +0 -477
  111. package/src/elements/dispatchForm.ts +0 -1993
  112. package/src/elements/dispatchTable.test.ts +0 -1514
  113. package/src/elements/dispatchTable.ts +0 -745
  114. package/src/elements/index.ts +0 -21
  115. package/src/entries/BadgeEntry.ts +0 -39
  116. package/src/entries/CodeEntry.test.ts +0 -40
  117. package/src/entries/CodeEntry.ts +0 -52
  118. package/src/entries/ColorEntry.ts +0 -63
  119. package/src/entries/ComponentEntry.test.ts +0 -173
  120. package/src/entries/ComponentEntry.ts +0 -95
  121. package/src/entries/Entry.ts +0 -304
  122. package/src/entries/IconEntry.ts +0 -49
  123. package/src/entries/ImageEntry.ts +0 -61
  124. package/src/entries/KeyValueEntry.ts +0 -47
  125. package/src/entries/RepeatableEntry.test.ts +0 -239
  126. package/src/entries/RepeatableEntry.ts +0 -173
  127. package/src/entries/TextEntry.test.ts +0 -394
  128. package/src/entries/TextEntry.ts +0 -60
  129. package/src/entries/index.ts +0 -12
  130. package/src/entries/leaves.test.ts +0 -306
  131. package/src/entries/registry.ts +0 -54
  132. package/src/fields/BuilderField.test.ts +0 -1188
  133. package/src/fields/BuilderField.ts +0 -605
  134. package/src/fields/BuilderRelationship.test.ts +0 -811
  135. package/src/fields/CheckboxField.test.ts +0 -44
  136. package/src/fields/CheckboxField.ts +0 -27
  137. package/src/fields/CheckboxListField.test.ts +0 -99
  138. package/src/fields/CheckboxListField.ts +0 -66
  139. package/src/fields/ColorPickerField.test.ts +0 -33
  140. package/src/fields/ColorPickerField.ts +0 -25
  141. package/src/fields/DateField.ts +0 -54
  142. package/src/fields/DateTimeField.test.ts +0 -55
  143. package/src/fields/EmailField.ts +0 -16
  144. package/src/fields/Field.test.ts +0 -654
  145. package/src/fields/Field.ts +0 -817
  146. package/src/fields/FileUploadField.test.ts +0 -143
  147. package/src/fields/FileUploadField.ts +0 -159
  148. package/src/fields/HiddenField.test.ts +0 -27
  149. package/src/fields/HiddenField.ts +0 -28
  150. package/src/fields/KeyValueField.test.ts +0 -105
  151. package/src/fields/KeyValueField.ts +0 -55
  152. package/src/fields/MarkdownField.test.ts +0 -167
  153. package/src/fields/MarkdownField.ts +0 -162
  154. package/src/fields/NumberField.ts +0 -33
  155. package/src/fields/RadioField.test.ts +0 -94
  156. package/src/fields/RadioField.ts +0 -67
  157. package/src/fields/RepeaterField.test.ts +0 -1806
  158. package/src/fields/RepeaterField.ts +0 -939
  159. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  160. package/src/fields/RepeaterSimple.test.ts +0 -248
  161. package/src/fields/RowButton.test.ts +0 -219
  162. package/src/fields/RowButton.ts +0 -135
  163. package/src/fields/SelectField.test.ts +0 -192
  164. package/src/fields/SelectField.ts +0 -235
  165. package/src/fields/SliderField.test.ts +0 -50
  166. package/src/fields/SliderField.ts +0 -53
  167. package/src/fields/SlugField.ts +0 -24
  168. package/src/fields/TagsInputField.test.ts +0 -154
  169. package/src/fields/TagsInputField.ts +0 -133
  170. package/src/fields/TextField.test.ts +0 -213
  171. package/src/fields/TextField.ts +0 -177
  172. package/src/fields/TextareaField.test.ts +0 -58
  173. package/src/fields/TextareaField.ts +0 -59
  174. package/src/fields/ToggleButtonsField.test.ts +0 -106
  175. package/src/fields/ToggleButtonsField.ts +0 -59
  176. package/src/fields/ToggleField.ts +0 -16
  177. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  178. package/src/fields/optionsResolver.ts +0 -95
  179. package/src/fields/resolveField.ts +0 -28
  180. package/src/filters/BooleanFilter.ts +0 -35
  181. package/src/filters/DateRangeFilter.test.ts +0 -194
  182. package/src/filters/DateRangeFilter.ts +0 -148
  183. package/src/filters/Filter.test.ts +0 -268
  184. package/src/filters/Filter.ts +0 -184
  185. package/src/filters/FormFilter.test.ts +0 -238
  186. package/src/filters/FormFilter.ts +0 -215
  187. package/src/filters/MultiSelectFilter.test.ts +0 -119
  188. package/src/filters/MultiSelectFilter.ts +0 -78
  189. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  190. package/src/filters/QueryBuilderFilter.ts +0 -398
  191. package/src/filters/SelectFilter.ts +0 -46
  192. package/src/filters/TernaryFilter.test.ts +0 -160
  193. package/src/filters/TernaryFilter.ts +0 -72
  194. package/src/filters/TrashedFilter.test.ts +0 -149
  195. package/src/filters/TrashedFilter.ts +0 -55
  196. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  197. package/src/filters/queryBuilder/Constraint.ts +0 -115
  198. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  199. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  200. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  201. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  202. package/src/filters/queryBuilder/index.ts +0 -12
  203. package/src/icons/index.ts +0 -2
  204. package/src/icons/lucide.ts +0 -204
  205. package/src/icons/registry.test.ts +0 -56
  206. package/src/icons/registry.ts +0 -41
  207. package/src/icons/types.ts +0 -47
  208. package/src/index.ts +0 -525
  209. package/src/io/csv.test.ts +0 -142
  210. package/src/io/csv.ts +0 -170
  211. package/src/nestedRelationManagerData.test.ts +0 -547
  212. package/src/notifications/Notification.test.ts +0 -210
  213. package/src/notifications/Notification.ts +0 -354
  214. package/src/notifications/broadcast.test.ts +0 -110
  215. package/src/notifications/broadcast.ts +0 -95
  216. package/src/notifications/database.test.ts +0 -383
  217. package/src/notifications/database.ts +0 -398
  218. package/src/notifications/databaseNotifications.test.ts +0 -187
  219. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  220. package/src/notifications/dispatchNotificationAction.ts +0 -142
  221. package/src/notifications/flash.test.ts +0 -89
  222. package/src/notifications/flash.ts +0 -71
  223. package/src/notifications/index.ts +0 -45
  224. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  225. package/src/notifications/registerBroadcastAuth.ts +0 -100
  226. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  227. package/src/notifications/resolveSavedNotification.ts +0 -59
  228. package/src/notifications/types.ts +0 -93
  229. package/src/orm/m2mAccessor.ts +0 -66
  230. package/src/orm/modelDefaults.test.ts +0 -633
  231. package/src/orm/modelDefaults.ts +0 -666
  232. package/src/pageData/breadcrumbs.ts +0 -288
  233. package/src/pageData/forms.ts +0 -578
  234. package/src/pageData/helpers.ts +0 -857
  235. package/src/pageData/misc.ts +0 -347
  236. package/src/pageData/navigation.ts +0 -842
  237. package/src/pageData/relationPages.ts +0 -1248
  238. package/src/pageData/relationTabs.ts +0 -286
  239. package/src/pageData/resourcePages.ts +0 -609
  240. package/src/pageData.test.ts +0 -1545
  241. package/src/pageData.ts +0 -341
  242. package/src/plugins/index.ts +0 -8
  243. package/src/plugins/themeEditor.test.ts +0 -36
  244. package/src/plugins/themeEditor.ts +0 -45
  245. package/src/react/AppShell.tsx +0 -251
  246. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  247. package/src/react/CollabRoomContext.ts +0 -98
  248. package/src/react/CollabTextRendererRegistry.ts +0 -102
  249. package/src/react/CommandPalette.tsx +0 -375
  250. package/src/react/CurrentUserContext.tsx +0 -50
  251. package/src/react/CustomPageWrapperGate.tsx +0 -69
  252. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  253. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  254. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  255. package/src/react/FieldPresenceRegistry.ts +0 -46
  256. package/src/react/FormCollabBindingRegistry.ts +0 -242
  257. package/src/react/FormStateContext.tsx +0 -591
  258. package/src/react/HeadHooks.tsx +0 -126
  259. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  260. package/src/react/MarkdownEditorRegistry.ts +0 -107
  261. package/src/react/NotificationActionStrip.tsx +0 -263
  262. package/src/react/NotificationBell.tsx +0 -426
  263. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  264. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  265. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  266. package/src/react/PendingSuggestionsContext.tsx +0 -172
  267. package/src/react/RecordWrapperGate.tsx +0 -58
  268. package/src/react/RecordWrapperRegistry.ts +0 -39
  269. package/src/react/RenderHookSlot.tsx +0 -32
  270. package/src/react/RightSidebar.tsx +0 -257
  271. package/src/react/RightSidebarContext.tsx +0 -234
  272. package/src/react/RightSidebarTrigger.tsx +0 -53
  273. package/src/react/RowCoordsContext.tsx +0 -23
  274. package/src/react/SchemaRenderer.tsx +0 -549
  275. package/src/react/SearchTrigger.tsx +0 -46
  276. package/src/react/ThemeProvider.tsx +0 -93
  277. package/src/react/ThemeSettingsPage.tsx +0 -579
  278. package/src/react/ThemeToggle.tsx +0 -20
  279. package/src/react/Toaster.tsx +0 -158
  280. package/src/react/UserMenu.tsx +0 -196
  281. package/src/react/WidgetDataContext.tsx +0 -157
  282. package/src/react/cells/EditableCell.tsx +0 -389
  283. package/src/react/component-slots.test.ts +0 -103
  284. package/src/react/component-slots.ts +0 -116
  285. package/src/react/fieldJsHandler.test.ts +0 -166
  286. package/src/react/fieldJsHandler.ts +0 -79
  287. package/src/react/fields/BuilderInput.tsx +0 -1078
  288. package/src/react/fields/CheckboxInput.tsx +0 -39
  289. package/src/react/fields/CheckboxListInput.tsx +0 -102
  290. package/src/react/fields/ColorInput.tsx +0 -71
  291. package/src/react/fields/DateFieldInput.tsx +0 -70
  292. package/src/react/fields/DateTimeInput.tsx +0 -62
  293. package/src/react/fields/FieldShell.tsx +0 -348
  294. package/src/react/fields/FileUploadInput.tsx +0 -639
  295. package/src/react/fields/HiddenInput.tsx +0 -17
  296. package/src/react/fields/KeyValueInput.tsx +0 -230
  297. package/src/react/fields/MarkdownInput.tsx +0 -560
  298. package/src/react/fields/RadioInput.tsx +0 -81
  299. package/src/react/fields/RepeaterInput.test.ts +0 -116
  300. package/src/react/fields/RepeaterInput.tsx +0 -1420
  301. package/src/react/fields/SelectFieldInput.tsx +0 -280
  302. package/src/react/fields/SliderInput.tsx +0 -81
  303. package/src/react/fields/TagsInput.tsx +0 -283
  304. package/src/react/fields/TextLikeInput.tsx +0 -256
  305. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  306. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  307. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  308. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  309. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  310. package/src/react/fields/repeaterReconcile.ts +0 -104
  311. package/src/react/fields/rowChromeButton.tsx +0 -336
  312. package/src/react/fields/rowState.ts +0 -106
  313. package/src/react/fields/syncRowGates.test.ts +0 -202
  314. package/src/react/fields/syncRowGates.ts +0 -66
  315. package/src/react/fields/textInputControls.tsx +0 -238
  316. package/src/react/fields/useRowReorderDnd.ts +0 -78
  317. package/src/react/formStateHelpers.test.ts +0 -508
  318. package/src/react/formStateHelpers.ts +0 -381
  319. package/src/react/hooks/use-mobile.ts +0 -19
  320. package/src/react/icon-context.tsx +0 -60
  321. package/src/react/index.ts +0 -195
  322. package/src/react/layouts/SidebarLayout.tsx +0 -250
  323. package/src/react/layouts/TopbarLayout.tsx +0 -258
  324. package/src/react/navigate.tsx +0 -37
  325. package/src/react/onProviderSynced.test.ts +0 -90
  326. package/src/react/parseRecordEditUrl.test.ts +0 -122
  327. package/src/react/parseRecordEditUrl.ts +0 -94
  328. package/src/react/persistedState.ts +0 -40
  329. package/src/react/registry.ts +0 -48
  330. package/src/react/right-panel-registry.tsx +0 -47
  331. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  332. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  333. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  334. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  335. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  336. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  337. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  338. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  339. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  340. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  341. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  342. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  343. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  344. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  345. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  346. package/src/react/schemaRenderer/constants.ts +0 -50
  347. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  348. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  349. package/src/react/schemaRenderer/helpers.tsx +0 -81
  350. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  351. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  352. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  353. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  354. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  355. package/src/react/schemaRenderer/table/links.tsx +0 -112
  356. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  357. package/src/react/schemaRenderer/table/url.tsx +0 -143
  358. package/src/react/theme-preview/apply.ts +0 -99
  359. package/src/react/theme-preview/build-html.ts +0 -436
  360. package/src/react/ui/button.tsx +0 -51
  361. package/src/react/ui/calendar.tsx +0 -67
  362. package/src/react/ui/checkbox.tsx +0 -29
  363. package/src/react/ui/dialog.tsx +0 -108
  364. package/src/react/ui/dropdown-menu.tsx +0 -97
  365. package/src/react/ui/input.tsx +0 -20
  366. package/src/react/ui/label.tsx +0 -21
  367. package/src/react/ui/popover.tsx +0 -50
  368. package/src/react/ui/select.tsx +0 -169
  369. package/src/react/ui/separator.tsx +0 -25
  370. package/src/react/ui/sheet.tsx +0 -136
  371. package/src/react/ui/sidebar.tsx +0 -723
  372. package/src/react/ui/skeleton.tsx +0 -13
  373. package/src/react/ui/slider.tsx +0 -34
  374. package/src/react/ui/switch.tsx +0 -28
  375. package/src/react/ui/table.tsx +0 -105
  376. package/src/react/ui/tabs.tsx +0 -63
  377. package/src/react/ui/textarea.tsx +0 -18
  378. package/src/react/ui/tooltip.tsx +0 -64
  379. package/src/react/useCollabSeed.ts +0 -86
  380. package/src/react/useResizableWidth.ts +0 -139
  381. package/src/react/utils.ts +0 -6
  382. package/src/react/widgetRegistry.test.ts +0 -43
  383. package/src/react/widgetRegistry.ts +0 -50
  384. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  385. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  386. package/src/react/widgets/ViewRenderer.tsx +0 -71
  387. package/src/relationManagerData.test.ts +0 -1595
  388. package/src/richtext/index.ts +0 -8
  389. package/src/richtext/registry.ts +0 -89
  390. package/src/routes/globals.ts +0 -148
  391. package/src/routes/guard.test.ts +0 -325
  392. package/src/routes/helpers.ts +0 -700
  393. package/src/routes/pages.ts +0 -175
  394. package/src/routes/panel.ts +0 -204
  395. package/src/routes/relations.ts +0 -1227
  396. package/src/routes/resources.ts +0 -781
  397. package/src/routes/theme.ts +0 -91
  398. package/src/routes-nested-relations.test.ts +0 -676
  399. package/src/routes-relations.test.ts +0 -972
  400. package/src/routes.test.ts +0 -2027
  401. package/src/routes.ts +0 -303
  402. package/src/schema/Alert.test.ts +0 -109
  403. package/src/schema/Alert.ts +0 -131
  404. package/src/schema/Block.ts +0 -169
  405. package/src/schema/Breadcrumbs.ts +0 -40
  406. package/src/schema/Card.ts +0 -35
  407. package/src/schema/Divider.ts +0 -20
  408. package/src/schema/Element.ts +0 -219
  409. package/src/schema/EmptyState.test.ts +0 -37
  410. package/src/schema/EmptyState.ts +0 -63
  411. package/src/schema/Fieldset.ts +0 -43
  412. package/src/schema/Grid.ts +0 -43
  413. package/src/schema/Group.ts +0 -30
  414. package/src/schema/Heading.ts +0 -39
  415. package/src/schema/Html.ts +0 -67
  416. package/src/schema/Icon.ts +0 -54
  417. package/src/schema/Image.ts +0 -57
  418. package/src/schema/LinkTag.ts +0 -41
  419. package/src/schema/Markdown.ts +0 -85
  420. package/src/schema/MetaTag.ts +0 -41
  421. package/src/schema/RelationTabs.ts +0 -71
  422. package/src/schema/ScriptTag.ts +0 -55
  423. package/src/schema/Section.ts +0 -160
  424. package/src/schema/ServerDataElement.test.ts +0 -140
  425. package/src/schema/ServerDataElement.ts +0 -156
  426. package/src/schema/SlotComponent.test.ts +0 -77
  427. package/src/schema/SlotComponent.ts +0 -71
  428. package/src/schema/Split.ts +0 -50
  429. package/src/schema/Stat.test.ts +0 -118
  430. package/src/schema/Stat.ts +0 -154
  431. package/src/schema/StatsOverview.test.ts +0 -141
  432. package/src/schema/StatsOverview.ts +0 -119
  433. package/src/schema/StyleTag.ts +0 -35
  434. package/src/schema/TableWidget.test.ts +0 -297
  435. package/src/schema/TableWidget.ts +0 -289
  436. package/src/schema/Tabs.ts +0 -79
  437. package/src/schema/Text.ts +0 -58
  438. package/src/schema/UnorderedList.ts +0 -49
  439. package/src/schema/View.test.ts +0 -111
  440. package/src/schema/View.ts +0 -127
  441. package/src/schema/Wizard.ts +0 -220
  442. package/src/schema/containers.test.ts +0 -564
  443. package/src/schema/headTags.test.ts +0 -134
  444. package/src/schema/index.ts +0 -40
  445. package/src/schema/primes.test.ts +0 -269
  446. package/src/schema/resolveSchema.test.ts +0 -379
  447. package/src/schema/resolveSchema.ts +0 -917
  448. package/src/schema/sanitize.ts +0 -58
  449. package/src/search.test.ts +0 -446
  450. package/src/search.ts +0 -178
  451. package/src/sessionFilters.test.ts +0 -375
  452. package/src/sessionFilters.ts +0 -143
  453. package/src/slot-components/index.ts +0 -10
  454. package/src/slot-components/registry.ts +0 -56
  455. package/src/styles/file-upload.css +0 -13
  456. package/src/summarizers/Summarizer.test.ts +0 -84
  457. package/src/summarizers/Summarizer.ts +0 -123
  458. package/src/summarizers/index.ts +0 -11
  459. package/src/theme/base-colors.ts +0 -68
  460. package/src/theme/chart-colors.ts +0 -50
  461. package/src/theme/colors.ts +0 -447
  462. package/src/theme/generate-css.test.ts +0 -139
  463. package/src/theme/generate-css.ts +0 -44
  464. package/src/theme/generate-scale.test.ts +0 -106
  465. package/src/theme/generate-scale.ts +0 -97
  466. package/src/theme/icon-map.ts +0 -42
  467. package/src/theme/index.ts +0 -34
  468. package/src/theme/migrate.test.ts +0 -178
  469. package/src/theme/migrate.ts +0 -81
  470. package/src/theme/presets.ts +0 -135
  471. package/src/theme/radius.ts +0 -18
  472. package/src/theme/resolve.test.ts +0 -238
  473. package/src/theme/resolve.ts +0 -96
  474. package/src/theme/spacing.ts +0 -18
  475. package/src/theme/storage.test.ts +0 -126
  476. package/src/theme/storage.ts +0 -106
  477. package/src/theme/theme-colors.ts +0 -88
  478. package/src/theme/types.ts +0 -125
  479. package/src/uploads/UploadAdapter.ts +0 -35
  480. package/src/uploads/index.ts +0 -2
  481. package/src/uploads/localUpload.test.ts +0 -70
  482. package/src/uploads/localUpload.ts +0 -84
  483. package/src/validation/Validator.ts +0 -49
  484. package/src/validation/index.ts +0 -28
  485. package/src/validation/rules.ts +0 -78
  486. package/src/validation/runValidators.ts +0 -435
  487. package/src/validation/uniqueValidator.test.ts +0 -196
  488. package/src/validation/uniqueValidator.ts +0 -133
  489. package/src/validation/validators.test.ts +0 -268
  490. package/src/vite.test.ts +0 -184
  491. package/src/vite.ts +0 -787
  492. package/src/widgets/index.ts +0 -10
  493. package/src/widgets/registry.ts +0 -45
  494. package/src/widgets.test.ts +0 -592
  495. package/tsconfig.build.json +0 -11
  496. package/tsconfig.json +0 -4
  497. package/tsconfig.test.json +0 -10
  498. package/views/react/Dashboard.tsx +0 -27
  499. package/views/react/Resources/Form.tsx +0 -102
  500. package/views/react/Resources/Index.tsx +0 -49
@@ -1,1595 +0,0 @@
1
- import { describe, it, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { Pilotiq } from './Pilotiq.js'
5
- import { Resource } from './Resource.js'
6
- import { Page } from './Page.js'
7
- import { RelationManager } from './RelationManager.js'
8
- import { Form } from './elements/Form.js'
9
- import { Table } from './elements/Table.js'
10
- import { Column } from './Column.js'
11
- import { TextField } from './fields/TextField.js'
12
- import { Heading } from './schema/Heading.js'
13
- import { findRelatedResource, relationManagerData, dispatchPageData, resourceEditData, resourceViewData, resourceRecordPageData, safeManagerPolicy } from './pageData.js'
14
- import { PilotiqRegistry } from './PilotiqRegistry.js'
15
- import type { ModelLike, ModelQuery } from './orm/modelDefaults.js'
16
-
17
- // ── Test doubles ───────────────────────────────────────────────────
18
-
19
- interface QueryRow extends Record<string, unknown> { id: string | number }
20
-
21
- class StubQuery implements ModelQuery {
22
- private filters: Array<{ col: string; op?: string; val: unknown }> = []
23
- constructor(private rows: QueryRow[]) {}
24
-
25
- where(...args: unknown[]): ModelQuery {
26
- if (args.length === 2) this.filters.push({ col: args[0] as string, val: args[1] })
27
- else this.filters.push({ col: args[0] as string, op: args[1] as string, val: args[2] })
28
- return this
29
- }
30
- orWhere(...args: unknown[]): ModelQuery {
31
- return this.where(...args)
32
- }
33
- orderBy(_c: string, _d?: 'ASC' | 'DESC'): ModelQuery { return this }
34
-
35
- async paginate(_page: number, _perPage?: number) {
36
- let data = this.rows
37
- for (const f of this.filters) {
38
- if (f.op === '=' || f.op === undefined) {
39
- data = data.filter(r => r[f.col] === f.val)
40
- }
41
- }
42
- return { data, total: data.length }
43
- }
44
- }
45
-
46
- function stubModel(opts: { rows?: QueryRow[]; primaryKey?: string } = {}): ModelLike {
47
- const rows = opts.rows ?? []
48
- const M: ModelLike = {
49
- async find(id) { return rows.find(r => r['id'] === id || String(r['id']) === String(id)) ?? null },
50
- async create(data) { const next = { id: rows.length + 1, ...data } as QueryRow; rows.push(next); return next },
51
- async update(id, data) { const r = rows.find(r => r['id'] === id); if (r) Object.assign(r, data); return r ?? null },
52
- async delete(id) { const i = rows.findIndex(r => r['id'] === id); if (i >= 0) rows.splice(i, 1) },
53
- query() { return new StubQuery(rows) },
54
- }
55
- if (opts.primaryKey !== undefined) M.primaryKey = opts.primaryKey
56
- return M
57
- }
58
-
59
- /** Build a parent record that exposes `.related(name)` (rudder convention)
60
- * yielding a StubQuery filtered by the foreign key. */
61
- function makeParentWithChildren(parentId: string | number, childRows: QueryRow[], fk = 'parentId') {
62
- return {
63
- id: parentId,
64
- related(_name: string): ModelQuery {
65
- // Return a StubQuery pre-filtered to just this parent's children.
66
- return new StubQuery(childRows.filter(r => r[fk] === parentId))
67
- },
68
- }
69
- }
70
-
71
- /**
72
- * Adapt a stub `find(id)` to the `query().where(pk, id).paginate(1, 1)`
73
- * shape that pilotiq's `findRecord(R, id, ctx)` now drives. Returns a
74
- * `ModelQuery` that captures the last where-clause value and resolves
75
- * via the supplied finder on `paginate()`. Lets these tests keep their
76
- * `find(id)` stub data without rewriting fixtures into row arrays.
77
- */
78
- function findAdapter(find: (id: string) => Promise<unknown>): ModelQuery {
79
- let captured: unknown
80
- const q: ModelQuery = {
81
- where(...args: unknown[]): ModelQuery {
82
- captured = args.length === 2 ? args[1] : args[2]
83
- return q
84
- },
85
- orWhere(...args: unknown[]): ModelQuery {
86
- captured = args.length === 2 ? args[1] : args[2]
87
- return q
88
- },
89
- orderBy(): ModelQuery { return q },
90
- async paginate() {
91
- const r = await find(String(captured))
92
- return { data: r ? [r] : [], total: r ? 1 : 0 }
93
- },
94
- }
95
- return q
96
- }
97
-
98
- // ── findRelatedResource — discovery via override + rudder convention ──
99
-
100
- describe('findRelatedResource (Plan #11)', () => {
101
- it('returns the explicit relatedResource override without touching ORM metadata', () => {
102
- class TargetResource extends Resource {
103
- static override slug = 'targets'
104
- }
105
- class M extends RelationManager {
106
- static override relationship = 'targets'
107
- static override relatedResource = TargetResource
108
- }
109
- class Parent extends Resource {
110
- static override slug = 'parents'
111
- static override relations() { return [M] }
112
- }
113
- const panel = Pilotiq.make('T').path('/admin').resources([Parent, TargetResource])
114
- const got = findRelatedResource(M, Parent, panel.getConfig())
115
- assert.equal(got, TargetResource)
116
- })
117
-
118
- it('discovers via rudder relations[name].model() match against cfg.resources', () => {
119
- const ChildModel = stubModel()
120
- const ParentModel = {
121
- ...stubModel(),
122
- relations: { posts: { model: () => ChildModel } },
123
- } as ModelLike
124
-
125
- class PostResource extends Resource {
126
- static override slug = 'posts'
127
- static override get model() { return ChildModel }
128
- }
129
- class PostsManager extends RelationManager {
130
- static override relationship = 'posts'
131
- }
132
- class UserResource extends Resource {
133
- static override slug = 'users'
134
- static override get model() { return ParentModel }
135
- static override relations() { return [PostsManager] }
136
- }
137
- const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
138
- const got = findRelatedResource(PostsManager, UserResource, panel.getConfig())
139
- assert.equal(got, PostResource)
140
- })
141
-
142
- it('returns undefined when neither override nor rudder metadata locates a Resource', () => {
143
- class M extends RelationManager {
144
- static override relationship = 'orphans'
145
- }
146
- class Parent extends Resource {
147
- static override slug = 'parents'
148
- static override relations() { return [M] }
149
- }
150
- const panel = Pilotiq.make('T').path('/admin').resources([Parent])
151
- const got = findRelatedResource(M, Parent, panel.getConfig())
152
- assert.equal(got, undefined)
153
- })
154
- })
155
-
156
- // ── relationManagerData — the three scopes ────────────────────────
157
-
158
- describe('relationManagerData (Plan #11)', () => {
159
- /** Build a User → Posts test world. Returns the panel + key models so
160
- * individual tests can poke records or override hooks. */
161
- function buildWorld(opts: { managerOverrides?: Partial<typeof RelationManager> } = {}) {
162
- const postRows: QueryRow[] = [
163
- { id: 'p1', parentId: 'u1', title: 'Post One' },
164
- { id: 'p2', parentId: 'u1', title: 'Post Two' },
165
- { id: 'p3', parentId: 'u2', title: 'Other User Post' },
166
- ]
167
- const PostModel = stubModel({ rows: postRows })
168
-
169
- // Parent records carry their own .related() so relation-list works
170
- // without touching ParentModel.relations metadata; for relation-edit
171
- // we ALSO need ParentModel.relations[].model() to discover Related
172
- // Resource.
173
- const parents = new Map<string, ReturnType<typeof makeParentWithChildren>>([
174
- ['u1', makeParentWithChildren('u1', postRows)],
175
- ['u2', makeParentWithChildren('u2', postRows)],
176
- ])
177
- // `query()` drives the new `findRecord(R, id, ctx)` path used to load
178
- // parent records (and policy-record lookups). Build a StubQuery over
179
- // the parents-as-rows so `where('id', '=', X).paginate(1, 1)` resolves
180
- // the same shape `find()` historically returned.
181
- const parentRows: QueryRow[] = [...parents.values()].map(p => p as unknown as QueryRow)
182
- const ParentModel: ModelLike = {
183
- async find(id) { return parents.get(String(id)) ?? null },
184
- async create() { throw new Error('not used') },
185
- async update() { throw new Error('not used') },
186
- async delete() { /* ok */ },
187
- query() { return new StubQuery(parentRows) },
188
- }
189
- Object.assign(ParentModel as object, {
190
- relations: { posts: { model: () => PostModel } },
191
- })
192
-
193
- class PostResource extends Resource {
194
- static override label = 'Posts'
195
- static override labelSingular = 'Post'
196
- static override slug = 'posts'
197
- static override get model() { return PostModel }
198
- static override form(form: Form): Form {
199
- return form.schema([TextField.make('title').required()])
200
- }
201
- }
202
-
203
- class PostsManager extends RelationManager {
204
- static override relationship = 'posts'
205
- static override label = 'Posts'
206
-
207
- static override table(table: Table): Table {
208
- return table.columns([Column.make('title').sortable()])
209
- }
210
- static override form(form: Form): Form {
211
- return form.schema([TextField.make('title').required()])
212
- }
213
- }
214
- if (opts.managerOverrides) {
215
- Object.assign(PostsManager, opts.managerOverrides)
216
- }
217
-
218
- class UserResource extends Resource {
219
- static override label = 'Users'
220
- static override slug = 'users'
221
- static override recordTitleAttribute = 'name'
222
- static override get model() { return ParentModel }
223
- static override relations() { return [PostsManager] }
224
- }
225
-
226
- const panel = Pilotiq.make('T').path('/admin').resources([UserResource, PostResource])
227
- return { panel, UserResource, PostResource, PostsManager, ParentModel, PostModel, postRows, parents }
228
- }
229
-
230
- it('returns null when the parent slug is unknown', async () => {
231
- const { panel } = buildWorld()
232
- const out = await relationManagerData(panel, {
233
- kind: 'relation-list', slug: 'missing', recordId: 'u1', relationship: 'posts',
234
- })
235
- assert.equal(out, null)
236
- })
237
-
238
- it('returns null when the manager relationship is unknown on the resource', async () => {
239
- const { panel } = buildWorld()
240
- const out = await relationManagerData(panel, {
241
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'comments',
242
- })
243
- assert.equal(out, null)
244
- })
245
-
246
- it('returns null when the parent record cannot be loaded', async () => {
247
- const { panel } = buildWorld()
248
- const out = await relationManagerData(panel, {
249
- kind: 'relation-list', slug: 'users', recordId: 'unknown', relationship: 'posts',
250
- })
251
- assert.equal(out, null)
252
- })
253
-
254
- it('throws when the parent has relations() but no static model', () => {
255
- class M extends RelationManager {
256
- static override relationship = 'posts'
257
- }
258
- class Bare extends Resource {
259
- static override slug = 'bare'
260
- static override relations() { return [M] }
261
- }
262
- const panel = Pilotiq.make('T').path('/admin').resources([Bare])
263
- return assert.rejects(
264
- () => relationManagerData(panel, {
265
- kind: 'relation-list', slug: 'bare', recordId: '1', relationship: 'posts',
266
- }),
267
- /has relations\(.*\) but no static model/,
268
- )
269
- })
270
-
271
- describe('authorization gating', () => {
272
- it('403 when parent canAccess fails', async () => {
273
- class Locked extends Resource {
274
- static override slug = 'users'
275
- static override async canAccess() { return false }
276
- static override relations() { return [class extends RelationManager {
277
- static override relationship = 'posts'
278
- }] }
279
- }
280
- const panel = Pilotiq.make('T').path('/admin').resources([Locked])
281
- const out = await relationManagerData(panel, {
282
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
283
- })
284
- assert.deepEqual(out, { ok: false, status: 403 })
285
- })
286
-
287
- it('403 when parent canEdit fails', async () => {
288
- const { ParentModel, PostModel } = buildWorld()
289
- class M extends RelationManager {
290
- static override relationship = 'posts'
291
- }
292
- class R2 extends Resource {
293
- static override slug = 'users'
294
- static override get model() { return ParentModel }
295
- static override async canEdit() { return false }
296
- static override relations() { return [M] }
297
- }
298
- class Posts extends Resource {
299
- static override slug = 'posts'
300
- static override get model() { return PostModel }
301
- }
302
- const panel = Pilotiq.make('T').path('/admin').resources([R2, Posts])
303
- const out = await relationManagerData(panel, {
304
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
305
- })
306
- assert.deepEqual(out, { ok: false, status: 403 })
307
- })
308
-
309
- it('403 on relation-list when manager.canViewAny fails', async () => {
310
- const { panel } = buildWorld({
311
- managerOverrides: {
312
- canViewAny: async () => false,
313
- } as Partial<typeof RelationManager>,
314
- })
315
- const out = await relationManagerData(panel, {
316
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
317
- })
318
- assert.deepEqual(out, { ok: false, status: 403 })
319
- })
320
-
321
- it('403 on relation-create when manager.canCreate fails', async () => {
322
- const { panel } = buildWorld({
323
- managerOverrides: {
324
- canCreate: async () => false,
325
- } as Partial<typeof RelationManager>,
326
- })
327
- const out = await relationManagerData(panel, {
328
- kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
329
- })
330
- assert.deepEqual(out, { ok: false, status: 403 })
331
- })
332
-
333
- it('403 on relation-edit when manager.canEdit fails', async () => {
334
- const { panel } = buildWorld({
335
- managerOverrides: {
336
- canEdit: async () => false,
337
- } as Partial<typeof RelationManager>,
338
- })
339
- const out = await relationManagerData(panel, {
340
- kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
341
- })
342
- assert.deepEqual(out, { ok: false, status: 403 })
343
- })
344
-
345
- it('403 on relation-view when manager.canView fails', async () => {
346
- const { panel } = buildWorld({
347
- managerOverrides: {
348
- canView: async () => false,
349
- } as Partial<typeof RelationManager>,
350
- })
351
- const out = await relationManagerData(panel, {
352
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
353
- })
354
- assert.deepEqual(out, { ok: false, status: 403 })
355
- })
356
- })
357
-
358
- describe('relation-list scope', () => {
359
- it('returns schemaData with the manager table, parent-scoped via .related()', async () => {
360
- const { panel } = buildWorld()
361
- const out = await relationManagerData(panel, {
362
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
363
- })
364
- assert.notEqual(out, null)
365
- assert.notEqual((out as { ok?: boolean }).ok, false)
366
- const data = out as Record<string, unknown>
367
- assert.equal(data['pageType'], 'relation-list')
368
- const relation = data['relation'] as Record<string, unknown>
369
- assert.equal(relation['relationship'], 'posts')
370
- assert.equal(relation['relatedSlug'], 'posts')
371
- const parent = data['parent'] as Record<string, unknown>
372
- assert.equal(parent['id'], 'u1')
373
-
374
- // Auto-wired records loader produced rows scoped to u1's children.
375
- const schema = data['schemaData'] as Array<Record<string, unknown>>
376
- const tableMeta = schema.find(s => s['type'] === 'table')
377
- assert.ok(tableMeta, 'expected a table element in schemaData')
378
- const rows = (tableMeta['rows'] as Array<Record<string, unknown>>) ?? []
379
- assert.equal(rows.length, 2) // u1 has p1 + p2 only, never p3
380
- assert.deepEqual(rows.map(r => r['id']).sort(), ['p1', 'p2'])
381
- })
382
- })
383
-
384
- describe('relation-create scope', () => {
385
- it('returns schemaData with form + create url stamped', async () => {
386
- const { panel } = buildWorld()
387
- const out = await relationManagerData(panel, {
388
- kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
389
- })
390
- assert.notEqual(out, null)
391
- const data = out as Record<string, unknown>
392
- assert.equal(data['pageType'], 'relation-create')
393
- assert.equal(data['mode'], 'create')
394
-
395
- const schema = data['schemaData'] as Array<Record<string, unknown>>
396
- const formMeta = schema.find(s => s['type'] === 'form')
397
- assert.ok(formMeta, 'expected a form element in schemaData')
398
- assert.equal(formMeta['action'], '/admin/users/u1/posts/create')
399
- })
400
-
401
- it('honors prefill values and errors', async () => {
402
- const { panel } = buildWorld()
403
- const out = await relationManagerData(panel, {
404
- kind: 'relation-create', slug: 'users', recordId: 'u1', relationship: 'posts',
405
- prefill: { values: { title: 'Draft' }, errors: { title: ['Required'] } },
406
- })
407
- const data = out as Record<string, unknown>
408
- assert.equal(data['hasErrors'], true)
409
- const schema = data['schemaData'] as Array<Record<string, unknown>>
410
- const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
411
- assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'Draft')
412
- assert.deepEqual((formMeta['errors'] as Record<string, string[]>)['title'], ['Required'])
413
- })
414
- })
415
-
416
- describe('relation-view scope', () => {
417
- it('loads child + verifies it belongs to the parent (anti-IDOR)', async () => {
418
- const { panel } = buildWorld({
419
- managerOverrides: {
420
- // Override detail() so we can assert the child + parent reach the
421
- // schema. Heading text echoes the child's title.
422
- detail(record: unknown, parentRecord: unknown) {
423
- const child = record as Record<string, unknown>
424
- const parent = parentRecord as Record<string, unknown>
425
- return [Heading.make(`${parent['id']}: ${child['title']}`)]
426
- },
427
- } as Partial<typeof RelationManager>,
428
- })
429
- const out = await relationManagerData(panel, {
430
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
431
- })
432
- assert.notEqual(out, null)
433
- const data = out as Record<string, unknown>
434
- assert.equal(data['pageType'], 'relation-view')
435
- assert.equal(data['mode'], 'view')
436
- assert.equal(data['childId'], 'p1')
437
-
438
- const schema = data['schemaData'] as Array<Record<string, unknown>>
439
- const heading = schema.find(s => s['type'] === 'heading') as Record<string, unknown>
440
- // detail(child, parent) was invoked with both records.
441
- assert.equal(heading['content'], 'u1: Post One')
442
- })
443
-
444
- it('returns null when the child belongs to a different parent (IDOR)', async () => {
445
- const { panel } = buildWorld()
446
- // p3 is u2's post — trying to view it under u1 must fail.
447
- const out = await relationManagerData(panel, {
448
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p3',
449
- })
450
- assert.equal(out, null)
451
- })
452
-
453
- it('returns null when the child does not exist at all', async () => {
454
- const { panel } = buildWorld()
455
- const out = await relationManagerData(panel, {
456
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'nonexistent',
457
- })
458
- assert.equal(out, null)
459
- })
460
-
461
- it('renders an empty schema (RelationTabs only) when the manager does not override detail()', async () => {
462
- const { panel } = buildWorld()
463
- const out = await relationManagerData(panel, {
464
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
465
- })
466
- const data = out as Record<string, unknown>
467
- const schema = data['schemaData'] as Array<Record<string, unknown>>
468
- // Default Manager.detail() returns []; the page surfaces only the
469
- // breadcrumbs (Phase C) + the RelationTabs strip — no detail body.
470
- assert.deepEqual(
471
- schema.map(s => s['type']),
472
- ['breadcrumbs', 'relation-tabs'],
473
- )
474
- })
475
-
476
- it('marks the manager tab active in the RelationTabs strip', async () => {
477
- const { panel } = buildWorld()
478
- const out = await relationManagerData(panel, {
479
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
480
- })
481
- const data = out as Record<string, unknown>
482
- const schema = data['schemaData'] as Array<Record<string, unknown>>
483
- const tabs = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
484
- const tabList = tabs['tabs'] as Array<Record<string, unknown>>
485
- const postsTab = tabList.find(t => t['key'] === 'posts')
486
- assert.ok(postsTab, 'posts manager tab should be present')
487
- assert.equal(postsTab!['active'], true)
488
- // Sibling parent tabs render but are inactive.
489
- const viewTab = tabList.find(t => t['key'] === '__view')
490
- assert.equal(viewTab?.['active'], false)
491
- })
492
-
493
- it('breadcrumb leaf reads RelationManager.recordTitleAttribute over Resource fallback', async () => {
494
- // Manager picks `parentId` for the leaf title; the related Resource
495
- // doesn't set recordTitleAttribute, so without the manager override
496
- // the fallback chain would land on `title` ("Post One").
497
- const { panel } = buildWorld({
498
- managerOverrides: { recordTitleAttribute: 'parentId' } as Partial<typeof RelationManager>,
499
- })
500
- const out = await relationManagerData(panel, {
501
- kind: 'relation-view', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
502
- })
503
- const data = out as Record<string, unknown>
504
- const schema = data['schemaData'] as Array<Record<string, unknown>>
505
- const crumbs = schema.find(s => s['type'] === 'breadcrumbs') as Record<string, unknown>
506
- const items = crumbs['items'] as Array<Record<string, unknown>>
507
- assert.equal(items.at(-1)!['label'], 'u1')
508
- })
509
- })
510
-
511
- describe('relation-edit scope', () => {
512
- it('loads child + verifies it belongs to the parent (anti-IDOR)', async () => {
513
- const { panel } = buildWorld()
514
- const out = await relationManagerData(panel, {
515
- kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
516
- })
517
- assert.notEqual(out, null)
518
- const data = out as Record<string, unknown>
519
- assert.equal(data['pageType'], 'relation-edit')
520
- assert.equal(data['mode'], 'edit')
521
- assert.equal(data['childId'], 'p1')
522
-
523
- const schema = data['schemaData'] as Array<Record<string, unknown>>
524
- const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
525
- // Child p1 belongs to u1 → its title should be filled in.
526
- assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'Post One')
527
- assert.equal(formMeta['action'], '/admin/users/u1/posts/p1/edit')
528
- })
529
-
530
- it('returns null when the child belongs to a different parent (IDOR)', async () => {
531
- const { panel } = buildWorld()
532
- // p3 is u2's post — trying to edit it under u1 must fail.
533
- const out = await relationManagerData(panel, {
534
- kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p3',
535
- })
536
- assert.equal(out, null)
537
- })
538
-
539
- it('returns null when the child does not exist at all', async () => {
540
- const { panel } = buildWorld()
541
- const out = await relationManagerData(panel, {
542
- kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'nonexistent',
543
- })
544
- assert.equal(out, null)
545
- })
546
-
547
- it('honors prefill on a 422 re-render', async () => {
548
- const { panel } = buildWorld()
549
- const out = await relationManagerData(panel, {
550
- kind: 'relation-edit', slug: 'users', recordId: 'u1', relationship: 'posts', childId: 'p1',
551
- prefill: { values: { title: 'User-typed value' }, errors: { title: ['Too short'] } },
552
- })
553
- const data = out as Record<string, unknown>
554
- const schema = data['schemaData'] as Array<Record<string, unknown>>
555
- const formMeta = schema.find(s => s['type'] === 'form') as Record<string, unknown>
556
- assert.equal((formMeta['values'] as Record<string, unknown>)['title'], 'User-typed value')
557
- assert.deepEqual((formMeta['errors'] as Record<string, string[]>)['title'], ['Too short'])
558
- assert.equal(data['hasErrors'], true)
559
- })
560
- })
561
- })
562
-
563
- // ── Plan #11 — safeManagerPolicy related-resource fall-through (Step 8) ─
564
-
565
- describe('safeManagerPolicy (Plan #11 step 8)', () => {
566
- it('runs the manager predicate when overridden', async () => {
567
- let called = false
568
- class M extends RelationManager {
569
- static override relationship = 'posts'
570
- static override async canCreate(_u: unknown, _p: unknown) { called = true; return true }
571
- }
572
- const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
573
- assert.equal(result, true)
574
- assert.equal(called, true, 'overridden manager predicate should be invoked')
575
- })
576
-
577
- it('falls through to Related.canX when the manager predicate is the default', async () => {
578
- let managerCalled = false
579
- let relatedCalled = false
580
- class M extends RelationManager {
581
- static override relationship = 'posts'
582
- // NOT overridden — inherits from RelationManager
583
- }
584
- class Related extends Resource {
585
- static override slug = 'posts'
586
- static override async canCreate(_u: unknown) { relatedCalled = true; return false }
587
- }
588
- // Spy on the inherited default to ensure we DIDN'T call it.
589
- const origDefault = RelationManager.canCreate
590
- const spy: typeof RelationManager.canCreate = async () => { managerCalled = true; return true }
591
- RelationManager.canCreate = spy
592
- try {
593
- const result = await safeManagerPolicy(M, 'canCreate', Related, 'user', { id: 1 })
594
- assert.equal(result, false)
595
- assert.equal(managerCalled, false, 'default manager predicate should be skipped when Related is configured')
596
- assert.equal(relatedCalled, true, 'Related predicate should run when manager is default')
597
- } finally {
598
- RelationManager.canCreate = origDefault
599
- }
600
- })
601
-
602
- it('strips the parent argument when calling the related Resource predicate', async () => {
603
- const captured: unknown[][] = []
604
- class M extends RelationManager {
605
- static override relationship = 'posts'
606
- }
607
- class Related extends Resource {
608
- static override slug = 'posts'
609
- static override async canEdit(...args: unknown[]) { captured.push(args); return true }
610
- }
611
- await safeManagerPolicy(M, 'canEdit', Related, 'user', { id: 'parent-1' }, { id: 'child-1' })
612
- // Resource.canEdit signature is (user, record) — the parent arg is dropped.
613
- assert.deepEqual(captured, [['user', { id: 'child-1' }]])
614
- })
615
-
616
- it('allows when both manager and Related are default', async () => {
617
- class M extends RelationManager { static override relationship = 'posts' }
618
- const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
619
- assert.equal(result, true)
620
- })
621
-
622
- it('fails closed when an overridden predicate throws', async () => {
623
- class M extends RelationManager {
624
- static override relationship = 'posts'
625
- static override async canCreate(): Promise<boolean> { throw new Error('boom') }
626
- }
627
- const result = await safeManagerPolicy(M, 'canCreate', undefined, 'user', { id: 1 })
628
- assert.equal(result, false)
629
- })
630
-
631
- it('integrates: relation-list 403 when Related.canViewAny denies and manager is default', async () => {
632
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
633
- const PostModel = stubModel({ rows: postRows })
634
- const ParentModel: ModelLike = {
635
- async find(_id) { return makeParentWithChildren('u1', postRows) },
636
- async create() { throw new Error('not used') },
637
- async update() { throw new Error('not used') },
638
- async delete() { /* ok */ },
639
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
640
- }
641
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
642
-
643
- class PostResource extends Resource {
644
- static override slug = 'posts'
645
- static override get model() { return PostModel }
646
- // Related denies — manager is default → fall-through must propagate.
647
- static override async canViewAny() { return false }
648
- }
649
- class PostsManager extends RelationManager {
650
- static override relationship = 'posts'
651
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
652
- }
653
- class UserResource extends Resource {
654
- static override slug = 'users'
655
- static override get model() { return ParentModel }
656
- static override relations() { return [PostsManager] }
657
- }
658
- const panel = Pilotiq.make('FT-' + Math.random()).path('/admin').resources([UserResource, PostResource])
659
- const out = await relationManagerData(panel, {
660
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
661
- })
662
- assert.deepEqual(out, { ok: false, status: 403 })
663
- })
664
-
665
- it('integrates: manager override beats Related — even when Related allows', async () => {
666
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
667
- const PostModel = stubModel({ rows: postRows })
668
- const ParentModel: ModelLike = {
669
- async find(_id) { return makeParentWithChildren('u1', postRows) },
670
- async create() { throw new Error('not used') },
671
- async update() { throw new Error('not used') },
672
- async delete() { /* ok */ },
673
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
674
- }
675
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
676
-
677
- class PostResource extends Resource {
678
- static override slug = 'posts'
679
- static override get model() { return PostModel }
680
- static override async canViewAny() { return true } // Related allows
681
- }
682
- class PostsManager extends RelationManager {
683
- static override relationship = 'posts'
684
- static override async canViewAny() { return false } // manager denies — wins
685
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
686
- }
687
- class UserResource extends Resource {
688
- static override slug = 'users'
689
- static override get model() { return ParentModel }
690
- static override relations() { return [PostsManager] }
691
- }
692
- const panel = Pilotiq.make('FT2-' + Math.random()).path('/admin').resources([UserResource, PostResource])
693
- const out = await relationManagerData(panel, {
694
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
695
- })
696
- assert.deepEqual(out, { ok: false, status: 403 })
697
- })
698
- })
699
-
700
- // ── Plan #11 — auto-mounted RelationTabs strip (Step 7) ─────────────
701
-
702
- describe('relation tabs auto-mount (Plan #11)', () => {
703
- it('relation-list page prepends RelationTabs with the manager tab active', async () => {
704
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
705
- const PostModel = stubModel({ rows: postRows })
706
- const ParentModel: ModelLike = {
707
- async find(_id) { return makeParentWithChildren('u1', postRows) },
708
- async create() { throw new Error('not used') },
709
- async update() { throw new Error('not used') },
710
- async delete() { /* ok */ },
711
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
712
- }
713
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
714
-
715
- class PostResource extends Resource {
716
- static override slug = 'posts'
717
- static override get model() { return PostModel }
718
- }
719
- class PostsManager extends RelationManager {
720
- static override relationship = 'posts'
721
- static override label = 'Posts'
722
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
723
- }
724
- class CommentsManager extends RelationManager {
725
- static override relationship = 'comments'
726
- static override label = 'Comments'
727
- static override table(t: Table): Table { return t.columns([Column.make('body')]) }
728
- }
729
- class UserResource extends Resource {
730
- static override slug = 'users'
731
- static override get model() { return ParentModel }
732
- static override relations() { return [PostsManager, CommentsManager] }
733
- }
734
- const panel = Pilotiq.make('TabsT-' + Math.random()).path('/admin').resources([UserResource, PostResource])
735
-
736
- const out = await relationManagerData(panel, {
737
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
738
- })
739
- const data = out as Record<string, unknown>
740
- const schema = data['schemaData'] as Array<Record<string, unknown>>
741
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
742
- assert.ok(tabsMeta, 'expected relation-tabs strip prepended')
743
-
744
- const tabs = tabsMeta['tabs'] as Array<{ key: string; label: string; url: string; active: boolean }>
745
- // Sub-nav follow-up: View + Edit are now sibling tabs, so the
746
- // strip is `[View, Edit, Posts, Comments]` rather than the prior
747
- // `[Edit, Posts, Comments]`.
748
- assert.equal(tabs.length, 4)
749
- assert.equal(tabs[0]?.key, '__view')
750
- assert.equal(tabs[0]?.label, 'View')
751
- assert.equal(tabs[0]?.url, '/admin/users/u1')
752
- assert.equal(tabs[0]?.active, false)
753
- assert.equal(tabs[1]?.key, '__edit')
754
- assert.equal(tabs[1]?.label, 'Edit')
755
- assert.equal(tabs[1]?.url, '/admin/users/u1/edit')
756
- assert.equal(tabs[1]?.active, false)
757
- assert.equal(tabs[2]?.key, 'posts')
758
- assert.equal(tabs[2]?.url, '/admin/users/u1/posts')
759
- assert.equal(tabs[2]?.active, true) // posts is the active tab
760
- assert.equal(tabs[3]?.key, 'comments')
761
- assert.equal(tabs[3]?.active, false)
762
- })
763
-
764
- it('skips the strip entirely when the resource has no relation managers', async () => {
765
- class OnlyR extends Resource {
766
- static override slug = 'only'
767
- }
768
- const panel = Pilotiq.make('NoRel-' + Math.random()).path('/admin').resources([OnlyR])
769
- // Touch resourceIndex/resourceCreate; we only care about Edit which depends
770
- // on R.model and pages. Easier: assert directly that buildRelationTabs would
771
- // not run by checking a manager-less relation-list call returns null (no
772
- // manager named 'whatever' exists), which is the expected guard.
773
- const out = await relationManagerData(panel, {
774
- kind: 'relation-list', slug: 'only', recordId: '1', relationship: 'whatever',
775
- })
776
- assert.equal(out, null)
777
- })
778
-
779
- it('resource-edit page prepends RelationTabs with the Edit tab active', async () => {
780
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
781
- const PostModel = stubModel({ rows: postRows })
782
- const ParentModel: ModelLike = stubModel({
783
- rows: [{ id: 'u1', name: 'Alice' }, { id: 'u2', name: 'Bob' }],
784
- })
785
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
786
-
787
- class PostResource extends Resource {
788
- static override slug = 'posts'
789
- static override get model() { return PostModel }
790
- }
791
- class PostsManager extends RelationManager {
792
- static override relationship = 'posts'
793
- static override label = 'Posts'
794
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
795
- }
796
- class UserResource extends Resource {
797
- static override slug = 'users'
798
- static override recordTitleAttribute = 'name'
799
- static override get model() { return ParentModel }
800
- static override form(form: Form): Form { return form.schema([TextField.make('name')]) }
801
- static override relations() { return [PostsManager] }
802
- }
803
- const panel = Pilotiq.make('EditTab-' + Math.random()).path('/admin').resources([UserResource, PostResource])
804
- const out = await resourceEditData(panel, 'users', 'u1')
805
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
806
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
807
- assert.ok(tabsMeta, 'resource-edit should auto-mount RelationTabs')
808
- const tabs = tabsMeta['tabs'] as Array<{ key: string; active: boolean; url: string }>
809
- // Sub-nav: View tab now sits ahead of Edit. Edit stays the active
810
- // tab on the resource-edit page.
811
- assert.equal(tabs[0]?.key, '__view')
812
- assert.equal(tabs[0]?.active, false)
813
- assert.equal(tabs[1]?.key, '__edit')
814
- assert.equal(tabs[1]?.active, true)
815
- assert.equal(tabs[2]?.key, 'posts')
816
- assert.equal(tabs[2]?.active, false)
817
- })
818
-
819
- it('resource-view page prepends RelationTabs with the View tab active', async () => {
820
- const postRows: QueryRow[] = []
821
- const PostModel = stubModel({ rows: postRows })
822
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
823
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
824
-
825
- class PostResource extends Resource {
826
- static override slug = 'posts'
827
- static override get model() { return PostModel }
828
- }
829
- class PostsManager extends RelationManager {
830
- static override relationship = 'posts'
831
- }
832
- class UserResource extends Resource {
833
- static override slug = 'users'
834
- static override get model() { return ParentModel }
835
- static override detail() { return [] }
836
- static override relations() { return [PostsManager] }
837
- }
838
- const panel = Pilotiq.make('ViewTab-' + Math.random()).path('/admin').resources([UserResource, PostResource])
839
- const out = await resourceViewData(panel, 'users', 'u1')
840
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
841
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown> | undefined
842
- assert.ok(tabsMeta, 'resource-view should auto-mount RelationTabs')
843
- const tabs = tabsMeta['tabs'] as Array<{ key: string; label: string; url: string; active: boolean }>
844
- assert.equal(tabs[0]?.key, '__view')
845
- assert.equal(tabs[0]?.label, 'View')
846
- assert.equal(tabs[0]?.url, '/admin/users/u1')
847
- assert.equal(tabs[0]?.active, true)
848
- // Edit tab is now a sibling on the View page too.
849
- assert.equal(tabs[1]?.key, '__edit')
850
- assert.equal(tabs[1]?.label, 'Edit')
851
- assert.equal(tabs[1]?.url, '/admin/users/u1/edit')
852
- assert.equal(tabs[1]?.active, false)
853
- })
854
-
855
- it('drops the View tab when ViewPage is pruned via static pages()', async () => {
856
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
857
- const PostModel = stubModel({ rows: postRows })
858
- const ParentModel: ModelLike = {
859
- async find(_id) { return makeParentWithChildren('u1', postRows) },
860
- async create() { throw new Error('not used') },
861
- async update() { throw new Error('not used') },
862
- async delete() { /* ok */ },
863
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
864
- }
865
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
866
-
867
- class PostResource extends Resource {
868
- static override slug = 'posts'
869
- static override get model() { return PostModel }
870
- }
871
- class PostsManager extends RelationManager {
872
- static override relationship = 'posts'
873
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
874
- }
875
- class UserResource extends Resource {
876
- static override slug = 'users'
877
- static override get model() { return ParentModel }
878
- static override relations() { return [PostsManager] }
879
- // Prune ViewPage — defaults shipped one but the user opted out.
880
- static override pages() { return { view: undefined as never } }
881
- }
882
- const panel = Pilotiq.make('NoView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
883
-
884
- const out = await relationManagerData(panel, {
885
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
886
- })
887
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
888
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
889
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
890
- // No __view, just __edit + the manager.
891
- assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
892
- })
893
-
894
- it('drops the Edit tab when EditPage is pruned via static pages()', async () => {
895
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
896
- const PostModel = stubModel({ rows: postRows })
897
- const ParentModel: ModelLike = {
898
- async find(_id) { return makeParentWithChildren('u1', postRows) },
899
- async create() { throw new Error('not used') },
900
- async update() { throw new Error('not used') },
901
- async delete() { /* ok */ },
902
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
903
- }
904
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
905
-
906
- class PostResource extends Resource {
907
- static override slug = 'posts'
908
- static override get model() { return PostModel }
909
- }
910
- class PostsManager extends RelationManager {
911
- static override relationship = 'posts'
912
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
913
- }
914
- class UserResource extends Resource {
915
- static override slug = 'users'
916
- static override get model() { return ParentModel }
917
- static override relations() { return [PostsManager] }
918
- static override pages() { return { edit: undefined as never } }
919
- }
920
- const panel = Pilotiq.make('NoEdit-' + Math.random()).path('/admin').resources([UserResource, PostResource])
921
-
922
- const out = await relationManagerData(panel, {
923
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
924
- })
925
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
926
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
927
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
928
- assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
929
- })
930
-
931
- // ── Per-tab canX gating ──────────────────────────────────
932
-
933
- it('hides the View tab when R.canView returns false for this record', async () => {
934
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
935
- const PostModel = stubModel({ rows: postRows })
936
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
937
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
938
-
939
- class PostResource extends Resource {
940
- static override slug = 'posts'
941
- static override get model() { return PostModel }
942
- }
943
- class PostsManager extends RelationManager {
944
- static override relationship = 'posts'
945
- }
946
- class UserResource extends Resource {
947
- static override slug = 'users'
948
- static override get model() { return ParentModel }
949
- static override detail() { return [] }
950
- static override relations() { return [PostsManager] }
951
- static override async canView(): Promise<boolean> { return false }
952
- }
953
- const panel = Pilotiq.make('NoCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
954
-
955
- // Use resource-edit so the route doesn't 403 before we render — we
956
- // want to see the strip itself drop the View tab.
957
- const out = await resourceEditData(panel, 'users', 'u1')
958
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
959
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
960
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
961
- assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
962
- })
963
-
964
- it('hides the Edit tab when R.canEdit returns false for this record', async () => {
965
- const postRows: QueryRow[] = []
966
- const PostModel = stubModel({ rows: postRows })
967
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
968
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
969
-
970
- class PostResource extends Resource {
971
- static override slug = 'posts'
972
- static override get model() { return PostModel }
973
- }
974
- class PostsManager extends RelationManager {
975
- static override relationship = 'posts'
976
- }
977
- class UserResource extends Resource {
978
- static override slug = 'users'
979
- static override get model() { return ParentModel }
980
- static override detail() { return [] }
981
- static override relations() { return [PostsManager] }
982
- static override async canEdit(): Promise<boolean> { return false }
983
- }
984
- const panel = Pilotiq.make('NoCanEdit-' + Math.random()).path('/admin').resources([UserResource, PostResource])
985
-
986
- const out = await resourceViewData(panel, 'users', 'u1')
987
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
988
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
989
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
990
- assert.deepEqual(tabs.map(t => t.key), ['__view', 'posts'])
991
- })
992
-
993
- it('hides a manager tab when M.canViewAny returns false', async () => {
994
- const postRows: QueryRow[] = []
995
- const commentRows: QueryRow[] = []
996
- const PostModel = stubModel({ rows: postRows })
997
- const CommentModel = stubModel({ rows: commentRows })
998
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
999
- Object.assign(ParentModel as object, { relations: {
1000
- posts: { model: () => PostModel },
1001
- comments: { model: () => CommentModel },
1002
- } })
1003
-
1004
- class PostResource extends Resource {
1005
- static override slug = 'posts'
1006
- static override get model() { return PostModel }
1007
- }
1008
- class CommentResource extends Resource {
1009
- static override slug = 'comments'
1010
- static override get model() { return CommentModel }
1011
- }
1012
- class PostsManager extends RelationManager {
1013
- static override relationship = 'posts'
1014
- }
1015
- class CommentsManager extends RelationManager {
1016
- static override relationship = 'comments'
1017
- static override async canViewAny(): Promise<boolean> { return false }
1018
- }
1019
- class UserResource extends Resource {
1020
- static override slug = 'users'
1021
- static override get model() { return ParentModel }
1022
- static override detail() { return [] }
1023
- static override relations() { return [PostsManager, CommentsManager] }
1024
- }
1025
- const panel = Pilotiq.make('GatedMgr-' + Math.random()).path('/admin').resources([UserResource, PostResource, CommentResource])
1026
-
1027
- const out = await resourceViewData(panel, 'users', 'u1')
1028
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1029
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
1030
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
1031
- // CommentsManager is gone — Posts survives because it inherits the
1032
- // default `canViewAny → true`.
1033
- assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'posts'])
1034
- })
1035
-
1036
- it('falls through to Related.canViewAny when manager has not overridden', async () => {
1037
- const postRows: QueryRow[] = []
1038
- const PostModel = stubModel({ rows: postRows })
1039
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
1040
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1041
-
1042
- class PostResource extends Resource {
1043
- static override slug = 'posts'
1044
- static override get model() { return PostModel }
1045
- // Related-side gate fires through safeManagerPolicy fall-through
1046
- // since PostsManager doesn't override canViewAny.
1047
- static override async canViewAny(): Promise<boolean> { return false }
1048
- }
1049
- class PostsManager extends RelationManager {
1050
- static override relationship = 'posts'
1051
- static override relatedResource = PostResource
1052
- }
1053
- class UserResource extends Resource {
1054
- static override slug = 'users'
1055
- static override get model() { return ParentModel }
1056
- static override detail() { return [] }
1057
- static override relations() { return [PostsManager] }
1058
- }
1059
- const panel = Pilotiq.make('RelatedGate-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1060
-
1061
- const out = await resourceViewData(panel, 'users', 'u1')
1062
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1063
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown> | undefined
1064
- // With Posts gone, only the parent View+Edit tabs survive. The
1065
- // strip drops to under 2 manager-able entries so it stays mounted
1066
- // (View+Edit isn't worth-it; the depth-1 code path keeps the strip
1067
- // because the dropped tab was a manager, not a parent tab).
1068
- const tabs = (tabsMeta?.['tabs'] as Array<{ key: string }>) ?? []
1069
- assert.equal(tabs.find(t => t.key === 'posts'), undefined)
1070
- })
1071
-
1072
- it('throwing canX predicate fails closed (tab hidden)', async () => {
1073
- const postRows: QueryRow[] = []
1074
- const PostModel = stubModel({ rows: postRows })
1075
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
1076
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1077
-
1078
- class PostResource extends Resource {
1079
- static override slug = 'posts'
1080
- static override get model() { return PostModel }
1081
- }
1082
- class PostsManager extends RelationManager {
1083
- static override relationship = 'posts'
1084
- }
1085
- class UserResource extends Resource {
1086
- static override slug = 'users'
1087
- static override get model() { return ParentModel }
1088
- static override detail() { return [] }
1089
- static override relations() { return [PostsManager] }
1090
- static override async canView(): Promise<boolean> { throw new Error('boom') }
1091
- }
1092
- const panel = Pilotiq.make('ThrowCanView-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1093
-
1094
- const out = await resourceEditData(panel, 'users', 'u1')
1095
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1096
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
1097
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
1098
- // canView threw → fail closed (hidden). canEdit + Posts survive.
1099
- assert.deepEqual(tabs.map(t => t.key), ['__edit', 'posts'])
1100
- })
1101
-
1102
- it('drops the strip entirely when every manager tab is gated away on the View page', async () => {
1103
- const postRows: QueryRow[] = []
1104
- const PostModel = stubModel({ rows: postRows })
1105
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
1106
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1107
-
1108
- class PostResource extends Resource {
1109
- static override slug = 'posts'
1110
- static override get model() { return PostModel }
1111
- }
1112
- class PostsManager extends RelationManager {
1113
- static override relationship = 'posts'
1114
- static override async canViewAny(): Promise<boolean> { return false }
1115
- }
1116
- class UserResource extends Resource {
1117
- static override slug = 'users'
1118
- static override get model() { return ParentModel }
1119
- static override detail() { return [] }
1120
- static override relations() { return [PostsManager] }
1121
- static override async canView(): Promise<boolean> { return false }
1122
- static override async canEdit(): Promise<boolean> { return false }
1123
- }
1124
- const panel = Pilotiq.make('AllGated-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1125
-
1126
- const out = await resourceEditData(panel, 'users', 'u1')
1127
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1128
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
1129
- // No tabs survive — strip omitted entirely.
1130
- assert.equal(tabsMeta, undefined)
1131
- })
1132
- })
1133
-
1134
- // ── Plan #11 — dispatchPageData wiring (Vike +data SPA path) ────────
1135
-
1136
- describe('dispatchPageData → relation pages (Plan #11)', () => {
1137
- function buildPanel() {
1138
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Post One' }]
1139
- const PostModel = stubModel({ rows: postRows })
1140
- const parents = new Map([
1141
- ['u1', makeParentWithChildren('u1', postRows)],
1142
- ])
1143
- const ParentModel: ModelLike = {
1144
- async find(id) { return parents.get(String(id)) ?? null },
1145
- async create() { throw new Error('not used') },
1146
- async update() { throw new Error('not used') },
1147
- async delete() { /* no-op */ },
1148
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
1149
- }
1150
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1151
-
1152
- class PostResource extends Resource {
1153
- static override slug = 'posts'
1154
- static override get model() { return PostModel }
1155
- static override form(form: Form): Form { return form.schema([TextField.make('title').required()]) }
1156
- }
1157
- class PostsManager extends RelationManager {
1158
- static override relationship = 'posts'
1159
- static override table(t: Table): Table { return t.columns([Column.make('title')]) }
1160
- static override form(f: Form): Form { return f.schema([TextField.make('title').required()]) }
1161
- }
1162
- class UserResource extends Resource {
1163
- static override slug = 'users'
1164
- static override get model() { return ParentModel }
1165
- static override relations() { return [PostsManager] }
1166
- }
1167
-
1168
- PilotiqRegistry.reset()
1169
- const panel = Pilotiq.make('TestPanel-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1170
- PilotiqRegistry.register(panel)
1171
- return panel
1172
- }
1173
-
1174
- it('routes relation-list page id through to relationManagerData', async () => {
1175
- buildPanel()
1176
- const out = await dispatchPageData({
1177
- pageId: '/pages/(pilotiq)/relation-list',
1178
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts' },
1179
- urlParsed: { search: {} },
1180
- })
1181
- assert.notEqual(out, null)
1182
- assert.equal((out as Record<string, unknown>)['pageType'], 'relation-list')
1183
- })
1184
-
1185
- it('routes relation-create page id through', async () => {
1186
- buildPanel()
1187
- const out = await dispatchPageData({
1188
- pageId: '/pages/(pilotiq)/relation-create',
1189
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts' },
1190
- urlParsed: { search: {} },
1191
- })
1192
- assert.equal((out as Record<string, unknown>)['pageType'], 'relation-create')
1193
- })
1194
-
1195
- it('routes relation-edit page id through', async () => {
1196
- buildPanel()
1197
- const out = await dispatchPageData({
1198
- pageId: '/pages/(pilotiq)/relation-edit',
1199
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'posts', childId: 'p1' },
1200
- urlParsed: { search: {} },
1201
- })
1202
- assert.equal((out as Record<string, unknown>)['pageType'], 'relation-edit')
1203
- })
1204
-
1205
- it('returns null when the panel base path is unknown', async () => {
1206
- PilotiqRegistry.reset()
1207
- const out = await dispatchPageData({
1208
- pageId: '/pages/(pilotiq)/relation-list',
1209
- routeParams: { basePath: 'nonexistent', slug: 'users', id: 'u1', relationship: 'posts' },
1210
- urlParsed: { search: {} },
1211
- })
1212
- assert.equal(out, null)
1213
- })
1214
-
1215
- it('returns null when route params are incomplete', async () => {
1216
- buildPanel()
1217
- const out = await dispatchPageData({
1218
- pageId: '/pages/(pilotiq)/relation-edit',
1219
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1' }, // missing relationship + childId
1220
- urlParsed: { search: {} },
1221
- })
1222
- assert.equal(out, null)
1223
- })
1224
- })
1225
-
1226
- // ── Plan #13 polish — manager TrashedFilter auto-injection ──────────
1227
-
1228
- describe('relation-list TrashedFilter auto-inject (Plan #13 polish)', () => {
1229
- /** Build a User → Posts world where the related Resource opts into
1230
- * soft deletes. */
1231
- function buildSoftDeleteWorld(opts: {
1232
- relatedSoftDeletes?: boolean
1233
- } = {}) {
1234
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1', title: 'Live' }]
1235
- const PostModel = stubModel({ rows: postRows })
1236
- const ParentModel: ModelLike = {
1237
- async find(_id) { return makeParentWithChildren('u1', postRows) },
1238
- async create() { throw new Error('not used') },
1239
- async update() { throw new Error('not used') },
1240
- async delete() { /* ok */ },
1241
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
1242
- }
1243
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1244
-
1245
- class PostResource extends Resource {
1246
- static override slug = 'posts'
1247
- static override softDeletes = opts.relatedSoftDeletes ?? false
1248
- static override get model() { return PostModel }
1249
- }
1250
-
1251
- class PostsManager extends RelationManager {
1252
- static override relationship = 'posts'
1253
- static override table(t: Table): Table {
1254
- return t.columns([Column.make('title')])
1255
- }
1256
- }
1257
- class UserResource extends Resource {
1258
- static override slug = 'users'
1259
- static override get model() { return ParentModel }
1260
- static override relations() { return [PostsManager] }
1261
- }
1262
-
1263
- const panel = Pilotiq.make('TF-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1264
- return { panel, PostsManager, PostResource }
1265
- }
1266
-
1267
- /** Helper — pull filter children from a resolved Table meta. Filters
1268
- * serialize as children with a `kind` field (Filter.toMeta) so we
1269
- * filter on `kind in c` to distinguish them from columns. */
1270
- function tableFilterChildren(tableMeta: Record<string, unknown>): Array<Record<string, unknown>> {
1271
- const children = (tableMeta['children'] as Array<Record<string, unknown>>) ?? []
1272
- return children.filter(c => c['type'] === 'filter')
1273
- }
1274
-
1275
- it('auto-injects TrashedFilter when the related Resource has softDeletes=true', async () => {
1276
- const { panel } = buildSoftDeleteWorld({ relatedSoftDeletes: true })
1277
- const out = await relationManagerData(panel, {
1278
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
1279
- })
1280
- const data = out as Record<string, unknown>
1281
- const schema = data['schemaData'] as Array<Record<string, unknown>>
1282
- const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
1283
- const filters = tableFilterChildren(tableMeta)
1284
- const trashed = filters.find(f => f['name'] === 'trashed')
1285
- assert.ok(trashed, 'expected an auto-injected TrashedFilter on the manager table')
1286
- assert.equal(trashed!['kind'], 'select')
1287
- })
1288
-
1289
- it('does NOT inject TrashedFilter when the related Resource has softDeletes=false (default)', async () => {
1290
- const { panel } = buildSoftDeleteWorld({ relatedSoftDeletes: false })
1291
- const out = await relationManagerData(panel, {
1292
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
1293
- })
1294
- const data = out as Record<string, unknown>
1295
- const schema = data['schemaData'] as Array<Record<string, unknown>>
1296
- const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
1297
- const filters = tableFilterChildren(tableMeta)
1298
- const trashed = filters.find(f => f['name'] === 'trashed')
1299
- assert.equal(trashed, undefined)
1300
- })
1301
-
1302
- it('does not double-inject when the manager already attached a TrashedFilter', async () => {
1303
- const { TrashedFilter } = await import('./filters/TrashedFilter.js')
1304
-
1305
- const postRows: QueryRow[] = [{ id: 'p1', parentId: 'u1' }]
1306
- const PostModel = stubModel({ rows: postRows })
1307
- const ParentModel: ModelLike = {
1308
- async find(_id) { return makeParentWithChildren('u1', postRows) },
1309
- async create() { throw new Error('not used') },
1310
- async update() { throw new Error('not used') },
1311
- async delete() { /* ok */ },
1312
- query(): ModelQuery { return findAdapter((this as ModelLike).find as (id: string) => Promise<unknown>) },
1313
- }
1314
- Object.assign(ParentModel as object, { relations: { posts: { model: () => PostModel } } })
1315
-
1316
- class PostResource extends Resource {
1317
- static override slug = 'posts'
1318
- static override softDeletes = true
1319
- static override get model() { return PostModel }
1320
- }
1321
-
1322
- class PostsManager extends RelationManager {
1323
- static override relationship = 'posts'
1324
- static override table(t: Table): Table {
1325
- return t
1326
- .columns([Column.make('title')])
1327
- .filters([TrashedFilter.make().label('Custom trashed label')])
1328
- }
1329
- }
1330
- class UserResource extends Resource {
1331
- static override slug = 'users'
1332
- static override get model() { return ParentModel }
1333
- static override relations() { return [PostsManager] }
1334
- }
1335
- const panel = Pilotiq.make('TF2-' + Math.random()).path('/admin').resources([UserResource, PostResource])
1336
-
1337
- const out = await relationManagerData(panel, {
1338
- kind: 'relation-list', slug: 'users', recordId: 'u1', relationship: 'posts',
1339
- })
1340
- const data = out as Record<string, unknown>
1341
- const schema = data['schemaData'] as Array<Record<string, unknown>>
1342
- const tableMeta = schema.find(s => s['type'] === 'table') as Record<string, unknown>
1343
- const children = (tableMeta['children'] as Array<Record<string, unknown>>) ?? []
1344
- const trashedFilters = children.filter(c => c['type'] === 'filter' && c['name'] === 'trashed')
1345
- assert.equal(trashedFilters.length, 1, 'should not double-inject')
1346
- assert.equal(trashedFilters[0]?.['label'], 'Custom trashed label',
1347
- 'user-supplied filter should win over the auto-injected default')
1348
- })
1349
- })
1350
-
1351
- // ── Record sub-pages ─────────────────────────────────────
1352
-
1353
- describe('record sub-pages (pages().record)', () => {
1354
- class ActivityPage extends Page {
1355
- static override slug = 'activity'
1356
- static override label = 'Activity'
1357
- static override schema() {
1358
- return [Heading.make('Activity heading')]
1359
- }
1360
- }
1361
- class ProfilePage extends Page {
1362
- static override slug = 'profile'
1363
- static override label = 'Profile'
1364
- static override schema() {
1365
- return [Heading.make('Profile heading')]
1366
- }
1367
- }
1368
-
1369
- // ActivityPage / ProfilePage are module-scope so tests can reference
1370
- // them inline. `canAccess` is monkey-patched by individual tests via
1371
- // `buildPanel({ activityCanAccess })`; reset to the default-true
1372
- // predicate before every test so order of execution stays
1373
- // independent.
1374
- beforeEach(() => {
1375
- ;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
1376
- async () => true
1377
- ;(ProfilePage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
1378
- async () => true
1379
- })
1380
-
1381
- function buildPanel(opts: {
1382
- activityCanAccess?: () => boolean | Promise<boolean>
1383
- userCanView?: () => boolean | Promise<boolean>
1384
- } = {}) {
1385
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
1386
- class UserResource extends Resource {
1387
- static override slug = 'users'
1388
- static override recordTitleAttribute = 'name'
1389
- static override get model() { return ParentModel }
1390
- static override detail() { return [] }
1391
- static override pages() {
1392
- return { record: { activity: ActivityPage, profile: ProfilePage } }
1393
- }
1394
- }
1395
- if (opts.userCanView) {
1396
- (UserResource as unknown as { canView: () => unknown }).canView = opts.userCanView
1397
- }
1398
- if (opts.activityCanAccess) {
1399
- (ActivityPage as unknown as { canAccess: () => unknown }).canAccess = opts.activityCanAccess
1400
- } else {
1401
- ;(ActivityPage as unknown as { canAccess: () => Promise<boolean> }).canAccess =
1402
- async () => true
1403
- }
1404
- const panel = Pilotiq.make('RecPg-' + Math.random()).path('/admin').resources([UserResource])
1405
- return { panel, UserResource }
1406
- }
1407
-
1408
- // ── ResourcePages.record widening ──────────────────
1409
-
1410
- it('Resource.getRecordPages() returns the record map', () => {
1411
- const { UserResource } = buildPanel()
1412
- const recordPages = UserResource.getRecordPages()
1413
- assert.equal(recordPages['activity'], ActivityPage)
1414
- assert.equal(recordPages['profile'], ProfilePage)
1415
- })
1416
-
1417
- it('Resource.getRecordPages() returns {} when no record map is declared', () => {
1418
- class R extends Resource { static override slug = 'r' }
1419
- assert.deepEqual(R.getRecordPages(), {})
1420
- })
1421
-
1422
- // ── Data builder ──────────────────────────────────
1423
-
1424
- it('resourceRecordPageData returns null when slug not found', async () => {
1425
- const { panel } = buildPanel()
1426
- const out = await resourceRecordPageData(panel, 'nope', 'u1', 'activity')
1427
- assert.equal(out, null)
1428
- })
1429
-
1430
- it('resourceRecordPageData returns null when sub-page slug not registered', async () => {
1431
- const { panel } = buildPanel()
1432
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'nope')
1433
- assert.equal(out, null)
1434
- })
1435
-
1436
- it('resourceRecordPageData renders the sub-page schema on success', async () => {
1437
- const { panel } = buildPanel()
1438
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
1439
- const data = out as Record<string, unknown>
1440
- assert.equal(data['pageType'], 'record-page')
1441
- assert.equal(data['mode'], 'record')
1442
- assert.equal((data['subPage'] as Record<string, unknown>)['slug'], 'activity')
1443
- assert.equal((data['subPage'] as Record<string, unknown>)['label'], 'Activity')
1444
- const schema = data['schemaData'] as Array<Record<string, unknown>>
1445
- // Activity heading lives inside the page body, prepended by tabs strip.
1446
- const heading = schema.find(s => s['type'] === 'heading')
1447
- assert.ok(heading, 'expected the sub-page heading to render')
1448
- assert.equal(heading!['content'], 'Activity heading')
1449
- })
1450
-
1451
- it('resourceRecordPageData 403s when R.canView returns false', async () => {
1452
- const { panel } = buildPanel({ userCanView: async () => false })
1453
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
1454
- assert.deepEqual(out, { ok: false, status: 403 })
1455
- })
1456
-
1457
- it('resourceRecordPageData 403s when SubPage.canAccess returns false', async () => {
1458
- const { panel } = buildPanel({ activityCanAccess: async () => false })
1459
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
1460
- assert.deepEqual(out, { ok: false, status: 403 })
1461
- })
1462
-
1463
- it('resourceRecordPageData fails closed when SubPage.canAccess throws', async () => {
1464
- const { panel } = buildPanel({ activityCanAccess: async () => { throw new Error('boom') } })
1465
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
1466
- assert.deepEqual(out, { ok: false, status: 403 })
1467
- })
1468
-
1469
- // ── RelationTabs insertion ────────────────────────
1470
-
1471
- it('RelationTabs inserts a tab per sub-page between Edit and managers', async () => {
1472
- const ParentModel: ModelLike = stubModel({ rows: [{ id: 'u1', name: 'Alice' }] })
1473
- Object.assign(ParentModel as object, { relations: { posts: { model: () => stubModel({ rows: [] }) } } })
1474
-
1475
- class PostsManager extends RelationManager {
1476
- static override relationship = 'posts'
1477
- }
1478
- class UserResource extends Resource {
1479
- static override slug = 'users'
1480
- static override get model() { return ParentModel }
1481
- static override detail() { return [] }
1482
- static override relations() { return [PostsManager] }
1483
- static override pages() {
1484
- return { record: { activity: ActivityPage } }
1485
- }
1486
- }
1487
- const panel = Pilotiq.make('RecPgTabs-' + Math.random()).path('/admin').resources([UserResource])
1488
-
1489
- const out = await resourceViewData(panel, 'users', 'u1')
1490
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1491
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
1492
- const tabs = tabsMeta['tabs'] as Array<{ key: string; url: string; active: boolean }>
1493
- assert.deepEqual(tabs.map(t => t.key), ['__view', '__edit', 'activity', 'posts'])
1494
- assert.equal(tabs.find(t => t.key === 'activity')?.url, '/admin/users/u1/activity')
1495
- })
1496
-
1497
- it('RelationTabs marks the sub-page tab active when rendering through the sub-page', async () => {
1498
- const { panel } = buildPanel()
1499
- const out = await resourceRecordPageData(panel, 'users', 'u1', 'activity')
1500
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1501
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
1502
- const tabs = tabsMeta['tabs'] as Array<{ key: string; active: boolean }>
1503
- const activity = tabs.find(t => t.key === 'activity')
1504
- assert.equal(activity?.active, true)
1505
- })
1506
-
1507
- it('RelationTabs hides a sub-page tab when its canAccess returns false', async () => {
1508
- const { panel } = buildPanel({ activityCanAccess: async () => false })
1509
- // resourceViewData renders __view-active strip; activity sub-page
1510
- // should drop. profile (default canAccess=true) survives.
1511
- const out = await resourceViewData(panel, 'users', 'u1')
1512
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1513
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs') as Record<string, unknown>
1514
- const tabs = tabsMeta['tabs'] as Array<{ key: string }>
1515
- assert.equal(tabs.find(t => t.key === 'activity'), undefined)
1516
- assert.ok(tabs.find(t => t.key === 'profile'), 'profile sub-page should remain visible')
1517
- })
1518
-
1519
- it('RelationTabs mounts the strip even when only sub-pages exist (no relations)', async () => {
1520
- // No relation managers — pre-feature, the strip would not mount.
1521
- // With record sub-pages, the strip mounts to surface them.
1522
- const { panel } = buildPanel()
1523
- const out = await resourceViewData(panel, 'users', 'u1')
1524
- const schema = (out as Record<string, unknown>)['schemaData'] as Array<Record<string, unknown>>
1525
- const tabsMeta = schema.find(s => s['type'] === 'relation-tabs')
1526
- assert.ok(tabsMeta, 'strip should mount when sub-pages are registered')
1527
- })
1528
-
1529
- // ── dispatchPageData fallthrough ──────────────────
1530
-
1531
- it('dispatchPageData routes a known sub-page slug through resourceRecordPageData', async () => {
1532
- PilotiqRegistry.reset()
1533
- const { panel } = buildPanel()
1534
- PilotiqRegistry.register(panel)
1535
- const out = await dispatchPageData({
1536
- pageId: '/pages/(pilotiq)/relation-list',
1537
- urlPathname: '/admin/users/u1/activity',
1538
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'activity' },
1539
- urlParsed: { search: {} as Record<string, string> } as never,
1540
- } as never)
1541
- const data = out as Record<string, unknown>
1542
- assert.equal(data['pageType'], 'record-page')
1543
- })
1544
-
1545
- it('dispatchPageData still returns null when neither manager nor sub-page matches', async () => {
1546
- PilotiqRegistry.reset()
1547
- const { panel } = buildPanel()
1548
- PilotiqRegistry.register(panel)
1549
- const out = await dispatchPageData({
1550
- pageId: '/pages/(pilotiq)/relation-list',
1551
- urlPathname: '/admin/users/u1/nope',
1552
- routeParams: { basePath: 'admin', slug: 'users', id: 'u1', relationship: 'nope' },
1553
- urlParsed: { search: {} as Record<string, string> } as never,
1554
- } as never)
1555
- assert.equal(out, null)
1556
- })
1557
-
1558
- // ── Boot validation ──────────────────────────────
1559
-
1560
- it('boot rejects a record sub-page slug colliding with a relation manager', () => {
1561
- class CollideManager extends RelationManager {
1562
- static override relationship = 'activity'
1563
- }
1564
- class UserResource extends Resource {
1565
- static override slug = 'users'
1566
- static override relations() { return [CollideManager] }
1567
- static override pages() {
1568
- return { record: { activity: ActivityPage } }
1569
- }
1570
- }
1571
- // Boot validation runs inside `registerPilotiqRoutes`; emulate by
1572
- // calling it through the test plumbing if available. For now we
1573
- // assert the validation by reading the slugs and confirming the
1574
- // collision is detectable — full route-registration runs in the
1575
- // integration test below.
1576
- const managerSlugs = new Set(UserResource.relations().map(M => M.getRelationship()))
1577
- const recordSlugs = Object.keys(UserResource.getRecordPages())
1578
- const collisions = recordSlugs.filter(s => managerSlugs.has(s))
1579
- assert.deepEqual(collisions, ['activity'])
1580
- })
1581
-
1582
- it('boot rejects a record sub-page slug with invalid characters', () => {
1583
- class UserResource extends Resource {
1584
- static override slug = 'users'
1585
- static override pages() {
1586
- return { record: { 'bad slug!': ActivityPage } }
1587
- }
1588
- }
1589
- const slugs = Object.keys(UserResource.getRecordPages())
1590
- // Pattern validation lives in `registerPilotiqRoutes`; here we just
1591
- // assert the recorded slug round-trips so the validator's input is
1592
- // what the user typed.
1593
- assert.deepEqual(slugs, ['bad slug!'])
1594
- })
1595
- })