@pilotiq/pilotiq 0.24.1 → 0.24.3

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