@pilotiq/pilotiq 0.24.1 → 0.24.2

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