@pilotiq/pilotiq 0.24.1 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (480) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/boost/guidelines.md +566 -0
  3. package/boost/skills/pilotiq-fields/SKILL.md +47 -0
  4. package/boost/skills/pilotiq-fields/rules/field-catalog.md +288 -0
  5. package/boost/skills/pilotiq-fields/rules/reactive-fields.md +199 -0
  6. package/boost/skills/pilotiq-fields/rules/validation.md +198 -0
  7. package/boost/skills/pilotiq-relations/SKILL.md +47 -0
  8. package/boost/skills/pilotiq-relations/rules/relation-managers.md +256 -0
  9. package/boost/skills/pilotiq-relations/rules/repeater-relationship.md +177 -0
  10. package/boost/skills/pilotiq-resource/SKILL.md +61 -0
  11. package/boost/skills/pilotiq-resource/rules/authorization.md +242 -0
  12. package/boost/skills/pilotiq-resource/rules/defining-resources.md +228 -0
  13. package/boost/skills/pilotiq-resource/rules/page-overrides.md +296 -0
  14. package/package.json +6 -1
  15. package/.turbo/turbo-build.log +0 -8
  16. package/CLAUDE.md +0 -265
  17. package/src/Cluster.test.ts +0 -283
  18. package/src/Cluster.ts +0 -83
  19. package/src/Column.test.ts +0 -199
  20. package/src/Column.ts +0 -710
  21. package/src/Global.test.ts +0 -367
  22. package/src/Global.ts +0 -169
  23. package/src/Page.test.ts +0 -114
  24. package/src/Page.ts +0 -208
  25. package/src/Pilotiq.perf.test.ts +0 -252
  26. package/src/Pilotiq.test.ts +0 -129
  27. package/src/Pilotiq.ts +0 -1158
  28. package/src/PilotiqRegistry.ts +0 -36
  29. package/src/PilotiqServiceProvider.ts +0 -121
  30. package/src/RelationManager.test.ts +0 -400
  31. package/src/RelationManager.ts +0 -527
  32. package/src/RenderHook.test.ts +0 -252
  33. package/src/RenderHook.ts +0 -242
  34. package/src/Resource.test.ts +0 -284
  35. package/src/Resource.ts +0 -526
  36. package/src/RightPanel.test.ts +0 -202
  37. package/src/RightPanel.ts +0 -132
  38. package/src/Tab.test.ts +0 -91
  39. package/src/Tab.ts +0 -156
  40. package/src/UserMenuItem.ts +0 -145
  41. package/src/actions/Action.test.ts +0 -2526
  42. package/src/actions/Action.ts +0 -1515
  43. package/src/actions/ActionGroup.test.ts +0 -112
  44. package/src/actions/ActionGroup.ts +0 -173
  45. package/src/actions/attachFactory.ts +0 -172
  46. package/src/actions/bulkFactories.ts +0 -168
  47. package/src/actions/crudFactories.ts +0 -220
  48. package/src/actions/exportFactory.ts +0 -225
  49. package/src/actions/factoryHelpers.ts +0 -177
  50. package/src/actions/importFactory.ts +0 -243
  51. package/src/actions/index.ts +0 -17
  52. package/src/actions/m2mFactories.ts +0 -193
  53. package/src/actions/relationFactories.ts +0 -372
  54. package/src/applyPageHooks.test.ts +0 -463
  55. package/src/applyPageHooks.ts +0 -330
  56. package/src/authorization.test.ts +0 -483
  57. package/src/breadcrumbs.test.ts +0 -238
  58. package/src/cells/coerce.test.ts +0 -85
  59. package/src/cells/coerce.ts +0 -84
  60. package/src/clusterPaths.ts +0 -35
  61. package/src/columns/BadgeColumn.test.ts +0 -54
  62. package/src/columns/BadgeColumn.ts +0 -32
  63. package/src/columns/BooleanColumn.test.ts +0 -41
  64. package/src/columns/BooleanColumn.ts +0 -18
  65. package/src/columns/ColorColumn.test.ts +0 -37
  66. package/src/columns/ColorColumn.ts +0 -38
  67. package/src/columns/IconColumn.test.ts +0 -54
  68. package/src/columns/IconColumn.ts +0 -37
  69. package/src/columns/ImageColumn.test.ts +0 -41
  70. package/src/columns/ImageColumn.ts +0 -28
  71. package/src/columns/SelectColumn.ts +0 -98
  72. package/src/columns/TextColumn.test.ts +0 -190
  73. package/src/columns/TextColumn.ts +0 -20
  74. package/src/columns/TextInputColumn.ts +0 -68
  75. package/src/columns/ToggleColumn.ts +0 -46
  76. package/src/columns/editableColumns.test.ts +0 -238
  77. package/src/columns/index.ts +0 -9
  78. package/src/defaultGlobalPages.ts +0 -95
  79. package/src/defaultPages.test.ts +0 -634
  80. package/src/defaultPages.ts +0 -617
  81. package/src/defaultViewPage.test.ts +0 -147
  82. package/src/elements/Form.test.ts +0 -223
  83. package/src/elements/Form.ts +0 -416
  84. package/src/elements/ListTabs.ts +0 -28
  85. package/src/elements/Table.test.ts +0 -422
  86. package/src/elements/Table.ts +0 -850
  87. package/src/elements/TableGroup.test.ts +0 -260
  88. package/src/elements/TableGroup.ts +0 -334
  89. package/src/elements/dispatchAction.test.ts +0 -463
  90. package/src/elements/dispatchAction.ts +0 -355
  91. package/src/elements/dispatchForm.test.ts +0 -477
  92. package/src/elements/dispatchForm.ts +0 -1993
  93. package/src/elements/dispatchTable.test.ts +0 -1514
  94. package/src/elements/dispatchTable.ts +0 -745
  95. package/src/elements/index.ts +0 -21
  96. package/src/entries/BadgeEntry.ts +0 -39
  97. package/src/entries/CodeEntry.test.ts +0 -40
  98. package/src/entries/CodeEntry.ts +0 -52
  99. package/src/entries/ColorEntry.ts +0 -63
  100. package/src/entries/ComponentEntry.test.ts +0 -173
  101. package/src/entries/ComponentEntry.ts +0 -95
  102. package/src/entries/Entry.ts +0 -304
  103. package/src/entries/IconEntry.ts +0 -49
  104. package/src/entries/ImageEntry.ts +0 -61
  105. package/src/entries/KeyValueEntry.ts +0 -47
  106. package/src/entries/RepeatableEntry.test.ts +0 -239
  107. package/src/entries/RepeatableEntry.ts +0 -173
  108. package/src/entries/TextEntry.test.ts +0 -394
  109. package/src/entries/TextEntry.ts +0 -60
  110. package/src/entries/index.ts +0 -12
  111. package/src/entries/leaves.test.ts +0 -306
  112. package/src/entries/registry.ts +0 -54
  113. package/src/fields/BuilderField.test.ts +0 -1188
  114. package/src/fields/BuilderField.ts +0 -605
  115. package/src/fields/BuilderRelationship.test.ts +0 -811
  116. package/src/fields/CheckboxField.test.ts +0 -44
  117. package/src/fields/CheckboxField.ts +0 -27
  118. package/src/fields/CheckboxListField.test.ts +0 -99
  119. package/src/fields/CheckboxListField.ts +0 -66
  120. package/src/fields/ColorPickerField.test.ts +0 -33
  121. package/src/fields/ColorPickerField.ts +0 -25
  122. package/src/fields/DateField.ts +0 -54
  123. package/src/fields/DateTimeField.test.ts +0 -55
  124. package/src/fields/EmailField.ts +0 -16
  125. package/src/fields/Field.test.ts +0 -654
  126. package/src/fields/Field.ts +0 -817
  127. package/src/fields/FileUploadField.test.ts +0 -143
  128. package/src/fields/FileUploadField.ts +0 -159
  129. package/src/fields/HiddenField.test.ts +0 -27
  130. package/src/fields/HiddenField.ts +0 -28
  131. package/src/fields/KeyValueField.test.ts +0 -105
  132. package/src/fields/KeyValueField.ts +0 -55
  133. package/src/fields/MarkdownField.test.ts +0 -167
  134. package/src/fields/MarkdownField.ts +0 -162
  135. package/src/fields/NumberField.ts +0 -33
  136. package/src/fields/RadioField.test.ts +0 -94
  137. package/src/fields/RadioField.ts +0 -67
  138. package/src/fields/RepeaterField.test.ts +0 -1806
  139. package/src/fields/RepeaterField.ts +0 -939
  140. package/src/fields/RepeaterRelationship.test.ts +0 -1923
  141. package/src/fields/RepeaterSimple.test.ts +0 -248
  142. package/src/fields/RowButton.test.ts +0 -219
  143. package/src/fields/RowButton.ts +0 -135
  144. package/src/fields/SelectField.test.ts +0 -192
  145. package/src/fields/SelectField.ts +0 -235
  146. package/src/fields/SliderField.test.ts +0 -50
  147. package/src/fields/SliderField.ts +0 -53
  148. package/src/fields/SlugField.ts +0 -24
  149. package/src/fields/TagsInputField.test.ts +0 -154
  150. package/src/fields/TagsInputField.ts +0 -133
  151. package/src/fields/TextField.test.ts +0 -213
  152. package/src/fields/TextField.ts +0 -177
  153. package/src/fields/TextareaField.test.ts +0 -58
  154. package/src/fields/TextareaField.ts +0 -59
  155. package/src/fields/ToggleButtonsField.test.ts +0 -106
  156. package/src/fields/ToggleButtonsField.ts +0 -59
  157. package/src/fields/ToggleField.ts +0 -16
  158. package/src/fields/disableOptionsWhenSelectedInSiblingRepeaterItems.test.ts +0 -319
  159. package/src/fields/optionsResolver.ts +0 -95
  160. package/src/fields/resolveField.ts +0 -28
  161. package/src/filters/BooleanFilter.ts +0 -35
  162. package/src/filters/DateRangeFilter.test.ts +0 -194
  163. package/src/filters/DateRangeFilter.ts +0 -148
  164. package/src/filters/Filter.test.ts +0 -268
  165. package/src/filters/Filter.ts +0 -184
  166. package/src/filters/FormFilter.test.ts +0 -238
  167. package/src/filters/FormFilter.ts +0 -215
  168. package/src/filters/MultiSelectFilter.test.ts +0 -119
  169. package/src/filters/MultiSelectFilter.ts +0 -78
  170. package/src/filters/QueryBuilderFilter.test.ts +0 -662
  171. package/src/filters/QueryBuilderFilter.ts +0 -398
  172. package/src/filters/SelectFilter.ts +0 -46
  173. package/src/filters/TernaryFilter.test.ts +0 -160
  174. package/src/filters/TernaryFilter.ts +0 -72
  175. package/src/filters/TrashedFilter.test.ts +0 -149
  176. package/src/filters/TrashedFilter.ts +0 -55
  177. package/src/filters/queryBuilder/BooleanConstraint.ts +0 -31
  178. package/src/filters/queryBuilder/Constraint.ts +0 -115
  179. package/src/filters/queryBuilder/DateConstraint.ts +0 -69
  180. package/src/filters/queryBuilder/NumberConstraint.ts +0 -66
  181. package/src/filters/queryBuilder/SelectConstraint.ts +0 -72
  182. package/src/filters/queryBuilder/TextConstraint.ts +0 -64
  183. package/src/filters/queryBuilder/index.ts +0 -12
  184. package/src/icons/index.ts +0 -2
  185. package/src/icons/lucide.ts +0 -204
  186. package/src/icons/registry.test.ts +0 -56
  187. package/src/icons/registry.ts +0 -41
  188. package/src/icons/types.ts +0 -47
  189. package/src/index.ts +0 -525
  190. package/src/io/csv.test.ts +0 -142
  191. package/src/io/csv.ts +0 -170
  192. package/src/nestedRelationManagerData.test.ts +0 -547
  193. package/src/notifications/Notification.test.ts +0 -210
  194. package/src/notifications/Notification.ts +0 -354
  195. package/src/notifications/broadcast.test.ts +0 -110
  196. package/src/notifications/broadcast.ts +0 -95
  197. package/src/notifications/database.test.ts +0 -383
  198. package/src/notifications/database.ts +0 -398
  199. package/src/notifications/databaseNotifications.test.ts +0 -187
  200. package/src/notifications/dispatchNotificationAction.test.ts +0 -341
  201. package/src/notifications/dispatchNotificationAction.ts +0 -142
  202. package/src/notifications/flash.test.ts +0 -89
  203. package/src/notifications/flash.ts +0 -71
  204. package/src/notifications/index.ts +0 -45
  205. package/src/notifications/registerBroadcastAuth.test.ts +0 -134
  206. package/src/notifications/registerBroadcastAuth.ts +0 -100
  207. package/src/notifications/resolveSavedNotification.test.ts +0 -82
  208. package/src/notifications/resolveSavedNotification.ts +0 -59
  209. package/src/notifications/types.ts +0 -93
  210. package/src/orm/m2mAccessor.ts +0 -66
  211. package/src/orm/modelDefaults.test.ts +0 -633
  212. package/src/orm/modelDefaults.ts +0 -666
  213. package/src/pageData/breadcrumbs.ts +0 -288
  214. package/src/pageData/forms.ts +0 -578
  215. package/src/pageData/helpers.ts +0 -857
  216. package/src/pageData/misc.ts +0 -347
  217. package/src/pageData/navigation.ts +0 -842
  218. package/src/pageData/relationPages.ts +0 -1248
  219. package/src/pageData/relationTabs.ts +0 -286
  220. package/src/pageData/resourcePages.ts +0 -609
  221. package/src/pageData.test.ts +0 -1545
  222. package/src/pageData.ts +0 -341
  223. package/src/plugins/index.ts +0 -8
  224. package/src/plugins/themeEditor.test.ts +0 -36
  225. package/src/plugins/themeEditor.ts +0 -45
  226. package/src/react/AppShell.tsx +0 -251
  227. package/src/react/CollabExtensionFactoryRegistry.ts +0 -55
  228. package/src/react/CollabRoomContext.ts +0 -98
  229. package/src/react/CollabTextRendererRegistry.ts +0 -102
  230. package/src/react/CommandPalette.tsx +0 -375
  231. package/src/react/CurrentUserContext.tsx +0 -50
  232. package/src/react/CustomPageWrapperGate.tsx +0 -69
  233. package/src/react/CustomPageWrapperRegistry.ts +0 -45
  234. package/src/react/FieldFocusReporterRegistry.ts +0 -37
  235. package/src/react/FieldLabelSlotRegistry.ts +0 -30
  236. package/src/react/FieldPresenceRegistry.ts +0 -46
  237. package/src/react/FormCollabBindingRegistry.ts +0 -242
  238. package/src/react/FormStateContext.tsx +0 -591
  239. package/src/react/HeadHooks.tsx +0 -126
  240. package/src/react/MarkdownEditorRegistry.test.ts +0 -38
  241. package/src/react/MarkdownEditorRegistry.ts +0 -107
  242. package/src/react/NotificationActionStrip.tsx +0 -263
  243. package/src/react/NotificationBell.tsx +0 -426
  244. package/src/react/PendingSuggestionApplierRegistry.test.ts +0 -97
  245. package/src/react/PendingSuggestionApplierRegistry.ts +0 -98
  246. package/src/react/PendingSuggestionOverlayRegistry.ts +0 -54
  247. package/src/react/PendingSuggestionsContext.tsx +0 -172
  248. package/src/react/RecordWrapperGate.tsx +0 -58
  249. package/src/react/RecordWrapperRegistry.ts +0 -39
  250. package/src/react/RenderHookSlot.tsx +0 -32
  251. package/src/react/RightSidebar.tsx +0 -257
  252. package/src/react/RightSidebarContext.tsx +0 -234
  253. package/src/react/RightSidebarTrigger.tsx +0 -53
  254. package/src/react/RowCoordsContext.tsx +0 -23
  255. package/src/react/SchemaRenderer.tsx +0 -549
  256. package/src/react/SearchTrigger.tsx +0 -46
  257. package/src/react/ThemeProvider.tsx +0 -93
  258. package/src/react/ThemeSettingsPage.tsx +0 -579
  259. package/src/react/ThemeToggle.tsx +0 -20
  260. package/src/react/Toaster.tsx +0 -158
  261. package/src/react/UserMenu.tsx +0 -196
  262. package/src/react/WidgetDataContext.tsx +0 -157
  263. package/src/react/cells/EditableCell.tsx +0 -389
  264. package/src/react/component-slots.test.ts +0 -103
  265. package/src/react/component-slots.ts +0 -116
  266. package/src/react/fieldJsHandler.test.ts +0 -166
  267. package/src/react/fieldJsHandler.ts +0 -79
  268. package/src/react/fields/BuilderInput.tsx +0 -1078
  269. package/src/react/fields/CheckboxInput.tsx +0 -39
  270. package/src/react/fields/CheckboxListInput.tsx +0 -102
  271. package/src/react/fields/ColorInput.tsx +0 -71
  272. package/src/react/fields/DateFieldInput.tsx +0 -70
  273. package/src/react/fields/DateTimeInput.tsx +0 -62
  274. package/src/react/fields/FieldShell.tsx +0 -348
  275. package/src/react/fields/FileUploadInput.tsx +0 -639
  276. package/src/react/fields/HiddenInput.tsx +0 -17
  277. package/src/react/fields/KeyValueInput.tsx +0 -230
  278. package/src/react/fields/MarkdownInput.tsx +0 -560
  279. package/src/react/fields/RadioInput.tsx +0 -81
  280. package/src/react/fields/RepeaterInput.test.ts +0 -116
  281. package/src/react/fields/RepeaterInput.tsx +0 -1420
  282. package/src/react/fields/SelectFieldInput.tsx +0 -280
  283. package/src/react/fields/SliderInput.tsx +0 -81
  284. package/src/react/fields/TagsInput.tsx +0 -283
  285. package/src/react/fields/TextLikeInput.tsx +0 -256
  286. package/src/react/fields/ToggleButtonsInput.tsx +0 -60
  287. package/src/react/fields/ToggleFieldInput.tsx +0 -56
  288. package/src/react/fields/relationshipRenameDispatch.test.ts +0 -106
  289. package/src/react/fields/relationshipRenameDispatch.ts +0 -97
  290. package/src/react/fields/repeaterReconcile.test.ts +0 -114
  291. package/src/react/fields/repeaterReconcile.ts +0 -104
  292. package/src/react/fields/rowChromeButton.tsx +0 -336
  293. package/src/react/fields/rowState.ts +0 -106
  294. package/src/react/fields/syncRowGates.test.ts +0 -202
  295. package/src/react/fields/syncRowGates.ts +0 -66
  296. package/src/react/fields/textInputControls.tsx +0 -238
  297. package/src/react/fields/useRowReorderDnd.ts +0 -78
  298. package/src/react/formStateHelpers.test.ts +0 -508
  299. package/src/react/formStateHelpers.ts +0 -381
  300. package/src/react/hooks/use-mobile.ts +0 -19
  301. package/src/react/icon-context.tsx +0 -60
  302. package/src/react/index.ts +0 -194
  303. package/src/react/layouts/SidebarLayout.tsx +0 -250
  304. package/src/react/layouts/TopbarLayout.tsx +0 -258
  305. package/src/react/navigate.tsx +0 -37
  306. package/src/react/onProviderSynced.test.ts +0 -90
  307. package/src/react/parseRecordEditUrl.test.ts +0 -122
  308. package/src/react/parseRecordEditUrl.ts +0 -94
  309. package/src/react/persistedState.ts +0 -40
  310. package/src/react/registry.ts +0 -48
  311. package/src/react/right-panel-registry.tsx +0 -47
  312. package/src/react/schemaRenderer/AlertRenderer.tsx +0 -112
  313. package/src/react/schemaRenderer/EntryRenderer.tsx +0 -501
  314. package/src/react/schemaRenderer/SectionRenderer.tsx +0 -120
  315. package/src/react/schemaRenderer/SimpleElements.tsx +0 -306
  316. package/src/react/schemaRenderer/TabsRenderer.tsx +0 -62
  317. package/src/react/schemaRenderer/WizardRenderer.tsx +0 -338
  318. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +0 -177
  319. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +0 -273
  320. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +0 -61
  321. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +0 -43
  322. package/src/react/schemaRenderer/action/MethodActionButton.tsx +0 -64
  323. package/src/react/schemaRenderer/action/buttons.tsx +0 -99
  324. package/src/react/schemaRenderer/action/helpers.ts +0 -140
  325. package/src/react/schemaRenderer/action/renderAction.tsx +0 -245
  326. package/src/react/schemaRenderer/columnFormat.ts +0 -65
  327. package/src/react/schemaRenderer/constants.ts +0 -50
  328. package/src/react/schemaRenderer/form/FormRenderer.tsx +0 -274
  329. package/src/react/schemaRenderer/form/renderField.tsx +0 -511
  330. package/src/react/schemaRenderer/helpers.tsx +0 -81
  331. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +0 -308
  332. package/src/react/schemaRenderer/table/TableRenderer.tsx +0 -123
  333. package/src/react/schemaRenderer/table/TableRendererBody.tsx +0 -974
  334. package/src/react/schemaRenderer/table/filters.tsx +0 -1233
  335. package/src/react/schemaRenderer/table/formatCell.tsx +0 -264
  336. package/src/react/schemaRenderer/table/links.tsx +0 -112
  337. package/src/react/schemaRenderer/table/renderRowActions.tsx +0 -52
  338. package/src/react/schemaRenderer/table/url.tsx +0 -143
  339. package/src/react/theme-preview/apply.ts +0 -99
  340. package/src/react/theme-preview/build-html.ts +0 -436
  341. package/src/react/ui/button.tsx +0 -51
  342. package/src/react/ui/calendar.tsx +0 -67
  343. package/src/react/ui/checkbox.tsx +0 -29
  344. package/src/react/ui/dialog.tsx +0 -108
  345. package/src/react/ui/dropdown-menu.tsx +0 -97
  346. package/src/react/ui/input.tsx +0 -20
  347. package/src/react/ui/label.tsx +0 -21
  348. package/src/react/ui/popover.tsx +0 -50
  349. package/src/react/ui/select.tsx +0 -169
  350. package/src/react/ui/separator.tsx +0 -25
  351. package/src/react/ui/sheet.tsx +0 -136
  352. package/src/react/ui/sidebar.tsx +0 -723
  353. package/src/react/ui/skeleton.tsx +0 -13
  354. package/src/react/ui/slider.tsx +0 -34
  355. package/src/react/ui/switch.tsx +0 -28
  356. package/src/react/ui/table.tsx +0 -105
  357. package/src/react/ui/tabs.tsx +0 -63
  358. package/src/react/ui/textarea.tsx +0 -18
  359. package/src/react/ui/tooltip.tsx +0 -64
  360. package/src/react/useResizableWidth.ts +0 -139
  361. package/src/react/utils.ts +0 -6
  362. package/src/react/widgetRegistry.test.ts +0 -43
  363. package/src/react/widgetRegistry.ts +0 -50
  364. package/src/react/widgets/StatsOverviewRenderer.tsx +0 -232
  365. package/src/react/widgets/TableWidgetRenderer.tsx +0 -231
  366. package/src/react/widgets/ViewRenderer.tsx +0 -71
  367. package/src/relationManagerData.test.ts +0 -1595
  368. package/src/richtext/index.ts +0 -8
  369. package/src/richtext/registry.ts +0 -89
  370. package/src/routes/globals.ts +0 -148
  371. package/src/routes/guard.test.ts +0 -325
  372. package/src/routes/helpers.ts +0 -704
  373. package/src/routes/pages.ts +0 -175
  374. package/src/routes/panel.ts +0 -204
  375. package/src/routes/relations.ts +0 -1243
  376. package/src/routes/resources.ts +0 -781
  377. package/src/routes/theme.ts +0 -91
  378. package/src/routes-nested-relations.test.ts +0 -676
  379. package/src/routes-relations.test.ts +0 -972
  380. package/src/routes.test.ts +0 -2027
  381. package/src/routes.ts +0 -303
  382. package/src/schema/Alert.test.ts +0 -109
  383. package/src/schema/Alert.ts +0 -131
  384. package/src/schema/Block.ts +0 -169
  385. package/src/schema/Breadcrumbs.ts +0 -40
  386. package/src/schema/Card.ts +0 -35
  387. package/src/schema/Divider.ts +0 -20
  388. package/src/schema/Element.ts +0 -219
  389. package/src/schema/EmptyState.test.ts +0 -37
  390. package/src/schema/EmptyState.ts +0 -63
  391. package/src/schema/Fieldset.ts +0 -43
  392. package/src/schema/Grid.ts +0 -43
  393. package/src/schema/Group.ts +0 -30
  394. package/src/schema/Heading.ts +0 -39
  395. package/src/schema/Html.ts +0 -67
  396. package/src/schema/Icon.ts +0 -54
  397. package/src/schema/Image.ts +0 -57
  398. package/src/schema/LinkTag.ts +0 -41
  399. package/src/schema/Markdown.ts +0 -85
  400. package/src/schema/MetaTag.ts +0 -41
  401. package/src/schema/RelationTabs.ts +0 -71
  402. package/src/schema/ScriptTag.ts +0 -55
  403. package/src/schema/Section.ts +0 -160
  404. package/src/schema/ServerDataElement.test.ts +0 -140
  405. package/src/schema/ServerDataElement.ts +0 -156
  406. package/src/schema/SlotComponent.test.ts +0 -77
  407. package/src/schema/SlotComponent.ts +0 -71
  408. package/src/schema/Split.ts +0 -50
  409. package/src/schema/Stat.test.ts +0 -118
  410. package/src/schema/Stat.ts +0 -154
  411. package/src/schema/StatsOverview.test.ts +0 -141
  412. package/src/schema/StatsOverview.ts +0 -119
  413. package/src/schema/StyleTag.ts +0 -35
  414. package/src/schema/TableWidget.test.ts +0 -297
  415. package/src/schema/TableWidget.ts +0 -289
  416. package/src/schema/Tabs.ts +0 -79
  417. package/src/schema/Text.ts +0 -58
  418. package/src/schema/UnorderedList.ts +0 -49
  419. package/src/schema/View.test.ts +0 -111
  420. package/src/schema/View.ts +0 -127
  421. package/src/schema/Wizard.ts +0 -220
  422. package/src/schema/containers.test.ts +0 -564
  423. package/src/schema/headTags.test.ts +0 -134
  424. package/src/schema/index.ts +0 -40
  425. package/src/schema/primes.test.ts +0 -269
  426. package/src/schema/resolveSchema.test.ts +0 -379
  427. package/src/schema/resolveSchema.ts +0 -917
  428. package/src/schema/sanitize.ts +0 -58
  429. package/src/search.test.ts +0 -446
  430. package/src/search.ts +0 -178
  431. package/src/sessionFilters.test.ts +0 -375
  432. package/src/sessionFilters.ts +0 -143
  433. package/src/slot-components/index.ts +0 -10
  434. package/src/slot-components/registry.ts +0 -56
  435. package/src/styles/file-upload.css +0 -13
  436. package/src/summarizers/Summarizer.test.ts +0 -84
  437. package/src/summarizers/Summarizer.ts +0 -123
  438. package/src/summarizers/index.ts +0 -11
  439. package/src/theme/base-colors.ts +0 -68
  440. package/src/theme/chart-colors.ts +0 -50
  441. package/src/theme/colors.ts +0 -447
  442. package/src/theme/generate-css.test.ts +0 -139
  443. package/src/theme/generate-css.ts +0 -44
  444. package/src/theme/generate-scale.test.ts +0 -106
  445. package/src/theme/generate-scale.ts +0 -97
  446. package/src/theme/icon-map.ts +0 -42
  447. package/src/theme/index.ts +0 -34
  448. package/src/theme/migrate.test.ts +0 -178
  449. package/src/theme/migrate.ts +0 -81
  450. package/src/theme/presets.ts +0 -135
  451. package/src/theme/radius.ts +0 -18
  452. package/src/theme/resolve.test.ts +0 -238
  453. package/src/theme/resolve.ts +0 -96
  454. package/src/theme/spacing.ts +0 -18
  455. package/src/theme/storage.test.ts +0 -126
  456. package/src/theme/storage.ts +0 -106
  457. package/src/theme/theme-colors.ts +0 -88
  458. package/src/theme/types.ts +0 -125
  459. package/src/uploads/UploadAdapter.ts +0 -35
  460. package/src/uploads/index.ts +0 -2
  461. package/src/uploads/localUpload.test.ts +0 -70
  462. package/src/uploads/localUpload.ts +0 -84
  463. package/src/validation/Validator.ts +0 -49
  464. package/src/validation/index.ts +0 -28
  465. package/src/validation/rules.ts +0 -78
  466. package/src/validation/runValidators.ts +0 -435
  467. package/src/validation/uniqueValidator.test.ts +0 -196
  468. package/src/validation/uniqueValidator.ts +0 -133
  469. package/src/validation/validators.test.ts +0 -268
  470. package/src/vite.test.ts +0 -184
  471. package/src/vite.ts +0 -787
  472. package/src/widgets/index.ts +0 -10
  473. package/src/widgets/registry.ts +0 -45
  474. package/src/widgets.test.ts +0 -592
  475. package/tsconfig.build.json +0 -11
  476. package/tsconfig.json +0 -4
  477. package/tsconfig.test.json +0 -10
  478. package/views/react/Dashboard.tsx +0 -27
  479. package/views/react/Resources/Form.tsx +0 -102
  480. package/views/react/Resources/Index.tsx +0 -49
@@ -1,1545 +0,0 @@
1
- import { describe, it } from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import { Form } from './elements/Form.js'
5
- import { ListTab } from './Tab.js'
6
- import { ListTabs } from './elements/ListTabs.js'
7
- import {
8
- applyEditPageHydrators,
9
- applyFillPipeline,
10
- formCreateOptionData,
11
- formStateData,
12
- formWizardData,
13
- mentionResolveData,
14
- panelInfo,
15
- resolveActiveTab,
16
- tagFormStateUrls,
17
- tagRichTextMentionUrls,
18
- tagSelectCreateOptionUrls,
19
- tagTableReorderUrls,
20
- tagCellEditUrls,
21
- } from './pageData.js'
22
- import { Element } from './schema/Element.js'
23
- import { Table } from './elements/Table.js'
24
- import { Column } from './Column.js'
25
- import { TextInputColumn, ToggleColumn } from './columns/index.js'
26
- import { Pilotiq } from './Pilotiq.js'
27
- import { Resource } from './Resource.js'
28
- import { Global } from './Global.js'
29
- import { Page } from './Page.js'
30
- import { TextField } from './fields/TextField.js'
31
- import { SelectField } from './fields/SelectField.js'
32
- import { ToggleField } from './fields/ToggleField.js'
33
- import { Section } from './schema/Section.js'
34
- import { Wizard, Step } from './schema/Wizard.js'
35
- import { Repeater } from './fields/RepeaterField.js'
36
- import { Builder } from './fields/BuilderField.js'
37
- import { Block } from './schema/Block.js'
38
-
39
- describe('applyFillPipeline', () => {
40
- it('defaults to a shallow record copy when nothing is configured', async () => {
41
- const form = Form.make()
42
- const record = { id: 1, title: 'Hello' }
43
- const values = await applyFillPipeline(form, record)
44
- assert.deepEqual(values, { id: 1, title: 'Hello' })
45
- assert.notEqual(values, record)
46
- })
47
-
48
- it('runs mutateFormDataBeforeFill before fillFromRecord', async () => {
49
- const order: string[] = []
50
- const form = Form.make<{ id: number; tags: string[] }>()
51
- .mutateFormDataBeforeFill(v => { order.push('before'); return { ...v, tagsCsv: '' } })
52
- .fillFromRecord(r => { order.push('fill'); return { id: r.id, tagsCsv: r.tags.join(',') } })
53
-
54
- const values = await applyFillPipeline(form, { id: 1, tags: ['a', 'b'] })
55
- assert.deepEqual(order, ['before', 'fill'])
56
- assert.deepEqual(values, { id: 1, tagsCsv: 'a,b' })
57
- })
58
-
59
- it('runs mutateFormDataAfterFill after fillFromRecord', async () => {
60
- const form = Form.make<{ id: number; title: string }>()
61
- .fillFromRecord(r => ({ id: r.id, title: r.title }))
62
- .mutateFormDataAfterFill(v => ({ ...v, title: String(v['title']).toUpperCase() }))
63
-
64
- const values = await applyFillPipeline(form, { id: 1, title: 'hello' })
65
- assert.deepEqual(values, { id: 1, title: 'HELLO' })
66
- })
67
-
68
- it('passes the loaded record on ctx.record to both mutators', async () => {
69
- const seen: { before?: unknown; after?: unknown } = {}
70
- const form = Form.make<{ id: number; secret: string }>()
71
- .mutateFormDataBeforeFill((v, ctx) => { seen.before = ctx.record; return v })
72
- .mutateFormDataAfterFill((v, ctx) => { seen.after = ctx.record; return v })
73
-
74
- const record = { id: 1, secret: 'hidden' }
75
- await applyFillPipeline(form, record)
76
- assert.equal(seen.before, record)
77
- assert.equal(seen.after, record)
78
- })
79
-
80
- it('supports async mutators', async () => {
81
- const form = Form.make<{ id: number }>()
82
- .mutateFormDataAfterFill(async v => ({ ...v, async: true }))
83
- const values = await applyFillPipeline(form, { id: 1 })
84
- assert.deepEqual(values, { id: 1, async: true })
85
- })
86
-
87
- it('parses JSON-string values on Repeater slots into arrays', async () => {
88
- const form = Form.make().schema([
89
- TextField.make('title'),
90
- Repeater.make('metadata').schema([TextField.make('heading')]),
91
- ])
92
- const record = {
93
- id: 1,
94
- title: 'Hello',
95
- metadata: '[{"__id":"row-1","heading":"a"},{"__id":"row-2","heading":"b"}]',
96
- }
97
- const values = await applyFillPipeline(form, record)
98
- assert.deepEqual(values['metadata'], [
99
- { __id: 'row-1', heading: 'a' },
100
- { __id: 'row-2', heading: 'b' },
101
- ])
102
- assert.equal(values['title'], 'Hello')
103
- })
104
-
105
- it('parses JSON-string values on Builder slots into arrays', async () => {
106
- const form = Form.make().schema([
107
- Builder.make('content').blocks([
108
- Block.make('heading').schema([TextField.make('text')]),
109
- ]),
110
- ])
111
- const record = {
112
- content: '[{"__id":"row-1","type":"heading","data":{"text":"hi"}}]',
113
- }
114
- const values = await applyFillPipeline(form, record)
115
- assert.deepEqual(values['content'], [
116
- { __id: 'row-1', type: 'heading', data: { text: 'hi' } },
117
- ])
118
- })
119
-
120
- it('leaves non-JSON strings on array-field slots untouched', async () => {
121
- const form = Form.make().schema([
122
- Repeater.make('tags').schema([TextField.make('label')]),
123
- ])
124
- const record = { tags: 'not-json' }
125
- const values = await applyFillPipeline(form, record)
126
- assert.equal(values['tags'], 'not-json')
127
- })
128
-
129
- it('leaves JSON strings that deserialize to non-arrays untouched', async () => {
130
- const form = Form.make().schema([
131
- Repeater.make('tags').schema([TextField.make('label')]),
132
- ])
133
- const record = { tags: '{"not":"an-array"}' }
134
- const values = await applyFillPipeline(form, record)
135
- assert.equal(values['tags'], '{"not":"an-array"}')
136
- })
137
-
138
- it('passes through already-parsed arrays unchanged', async () => {
139
- const form = Form.make().schema([
140
- Repeater.make('metadata').schema([TextField.make('heading')]),
141
- ])
142
- const rows = [{ __id: 'row-1', heading: 'a' }]
143
- const values = await applyFillPipeline(form, { metadata: rows })
144
- assert.equal(values['metadata'], rows)
145
- })
146
-
147
- it('ignores top-level non-array fields whose value happens to be a JSON-string', async () => {
148
- const form = Form.make().schema([TextField.make('title')])
149
- const record = { title: '[1,2,3]' }
150
- const values = await applyFillPipeline(form, record)
151
- assert.equal(values['title'], '[1,2,3]')
152
- })
153
- })
154
-
155
- describe('resolveActiveTab', () => {
156
- it('is a no-op when the schema has no ListTabs', async () => {
157
- // Just shouldn't throw; nothing to assert besides the absence of side effects.
158
- await resolveActiveTab([Form.make()], {}, '/admin/articles')
159
- })
160
-
161
- it('marks the first tab active when ?tab= is absent and no tab is .default()', async () => {
162
- const all = ListTab.make('all').label('All')
163
- const drafts = ListTab.make('drafts').label('Drafts')
164
- const tabs = ListTabs.make().tabs([all, drafts])
165
-
166
- await resolveActiveTab([tabs], {}, '/admin/articles')
167
- assert.equal(all.isActive(), true)
168
- assert.equal(drafts.isActive(), false)
169
- })
170
-
171
- it('honors .default() over the first-tab fallback', async () => {
172
- const all = ListTab.make('all').label('All')
173
- const drafts = ListTab.make('drafts').label('Drafts').default()
174
- const tabs = ListTabs.make().tabs([all, drafts])
175
-
176
- await resolveActiveTab([tabs], {}, '/admin/articles')
177
- assert.equal(all.isActive(), false)
178
- assert.equal(drafts.isActive(), true)
179
- })
180
-
181
- it('resolves the URL-supplied ?tab=name to the matching tab', async () => {
182
- const all = ListTab.make('all').default()
183
- const drafts = ListTab.make('drafts')
184
- const tabs = ListTabs.make().tabs([all, drafts])
185
-
186
- await resolveActiveTab([tabs], { tab: 'drafts' }, '/admin/articles')
187
- assert.equal(all.isActive(), false)
188
- assert.equal(drafts.isActive(), true)
189
- })
190
-
191
- it('falls through to default when ?tab= names a non-existent tab', async () => {
192
- const all = ListTab.make('all').default()
193
- const drafts = ListTab.make('drafts')
194
- const tabs = ListTabs.make().tabs([all, drafts])
195
-
196
- await resolveActiveTab([tabs], { tab: 'bogus' }, '/admin/articles')
197
- assert.equal(all.isActive(), true)
198
- assert.equal(drafts.isActive(), false)
199
- })
200
-
201
- it('stamps per-tab URLs that carry forward search/sort/filters but reset page', async () => {
202
- const all = ListTab.make('all')
203
- const drafts = ListTab.make('drafts')
204
- const tabs = ListTabs.make().tabs([all, drafts])
205
-
206
- await resolveActiveTab(
207
- [tabs],
208
- { search: 'hi', sort: 'title:desc', page: '3', status: 'published' },
209
- '/admin/articles',
210
- )
211
-
212
- const allUrl = all.toMeta().url
213
- const draftsUrl = drafts.toMeta().url
214
- // Tab name + carry-forward params, no `page`.
215
- for (const url of [allUrl, draftsUrl]) {
216
- assert.ok(url.startsWith('/admin/articles?'), `${url} should be absolute under the index path`)
217
- assert.ok(url.includes('search=hi'), 'search carries forward')
218
- assert.ok(url.includes('sort=title%3Adesc') || url.includes('sort=title:desc'), 'sort carries forward')
219
- assert.ok(url.includes('status=published'), 'filter values carry forward')
220
- assert.ok(!url.includes('page='), 'page resets on tab change')
221
- }
222
- // `all` is the implicit default tab (first, none marked `.default()`)
223
- // — its canonical URL omits `?tab=`. Non-default tabs include it.
224
- assert.ok(!allUrl.includes('tab='), 'default tab URL omits ?tab=')
225
- assert.ok(draftsUrl.includes('tab=drafts'), 'non-default tab URL includes ?tab=')
226
- })
227
-
228
- it('default tab URL is the bare path when no other params are present', async () => {
229
- const all = ListTab.make('all')
230
- const drafts = ListTab.make('drafts')
231
- const tabs = ListTabs.make().tabs([all, drafts])
232
-
233
- await resolveActiveTab([tabs], {}, '/admin/articles')
234
-
235
- // Default tab → no query string at all.
236
- assert.equal(all.toMeta().url, '/admin/articles')
237
- // Non-default still names itself.
238
- assert.equal(drafts.toMeta().url, '/admin/articles?tab=drafts')
239
- })
240
-
241
- it('explicitly-marked .default() tab gets the paramless URL even when not first', async () => {
242
- const all = ListTab.make('all')
243
- const drafts = ListTab.make('drafts').default()
244
- const tabs = ListTabs.make().tabs([all, drafts])
245
-
246
- await resolveActiveTab([tabs], {}, '/admin/articles')
247
-
248
- assert.equal(drafts.toMeta().url, '/admin/articles', 'marked-default tab → bare path')
249
- assert.equal(all.toMeta().url, '/admin/articles?tab=all')
250
- })
251
-
252
- it('resolves badge handlers in parallel and stamps the result on each tab', async () => {
253
- const order: string[] = []
254
- const a = ListTab.make('a').badge(async () => {
255
- order.push('a-start')
256
- await new Promise(r => setTimeout(r, 10))
257
- order.push('a-end')
258
- return 1
259
- })
260
- const b = ListTab.make('b').badge(async () => {
261
- order.push('b-start')
262
- await new Promise(r => setTimeout(r, 5))
263
- order.push('b-end')
264
- return 2
265
- })
266
- const tabs = ListTabs.make().tabs([a, b])
267
-
268
- await resolveActiveTab([tabs], {}, '/admin/articles')
269
- assert.equal(a.toMeta().badge, '1')
270
- assert.equal(b.toMeta().badge, '2')
271
- // Both started before either finished — confirms Promise.all parallelism.
272
- assert.equal(order.indexOf('a-start') < order.indexOf('b-end'), true)
273
- assert.equal(order.indexOf('b-start') < order.indexOf('a-end'), true)
274
- })
275
-
276
- it('swallows errors thrown by badge handlers', async () => {
277
- const broken = ListTab.make('broken').badge(async () => { throw new Error('oops') })
278
- const tabs = ListTabs.make().tabs([broken])
279
- await resolveActiveTab([tabs], {}, '/admin/articles')
280
- // No badge stamped; meta has no `badge` key.
281
- assert.equal(broken.toMeta().badge, undefined)
282
- })
283
-
284
- it('badge handler returning undefined leaves the badge unset', async () => {
285
- const tab = ListTab.make('drafts').badge(async () => undefined)
286
- const tabs = ListTabs.make().tabs([tab])
287
- await resolveActiveTab([tabs], {}, '/admin/articles')
288
- assert.equal(tab.toMeta().badge, undefined)
289
- })
290
-
291
- it('static badge survives unchanged when no handler is set', async () => {
292
- const tab = ListTab.make('drafts').badge('5')
293
- const tabs = ListTabs.make().tabs([tab])
294
- await resolveActiveTab([tabs], {}, '/admin/articles')
295
- assert.equal(tab.toMeta().badge, '5')
296
- })
297
- })
298
-
299
- describe('panelInfo — icon serialization', () => {
300
- it('ships string-typed Resource.icon as-is', async () => {
301
- class StringIconResource extends Resource {
302
- static override label = 'Things'
303
- static override icon = 'newspaper'
304
- }
305
- const panel = Pilotiq.make('T').path('/admin').resources([StringIconResource])
306
- const info = await panelInfo(panel)
307
- const r = info.navigation[0]!
308
- assert.equal(r.icon, 'newspaper')
309
- assert.equal(r.name, 'StringIconResource')
310
- })
311
-
312
- it('ships component-typed Resource.icon as { class: ownerName }', async () => {
313
- const FakeIcon = () => null
314
- class CmpIconResource extends Resource {
315
- static override label = 'Things'
316
- static override icon = FakeIcon as unknown as string
317
- }
318
- const panel = Pilotiq.make('T').path('/admin').resources([CmpIconResource])
319
- const info = await panelInfo(panel)
320
- const r = info.navigation[0]!
321
- assert.deepEqual(r.icon, { class: 'CmpIconResource' })
322
- assert.equal(r.name, 'CmpIconResource')
323
- })
324
-
325
- it('serializes Global.icon and Page.icon the same way', async () => {
326
- const FakeIcon = () => null
327
- class CmpIconGlobal extends Global {
328
- static override label = 'Settings'
329
- static override icon = FakeIcon as unknown as string
330
- }
331
- const panel = Pilotiq.make('T').path('/admin').globals([CmpIconGlobal])
332
- const info = await panelInfo(panel)
333
- assert.deepEqual(info.navigation[0]!.icon, { class: 'CmpIconGlobal' })
334
- })
335
- })
336
-
337
- describe('panelInfo — navigation tree (Plan #9)', () => {
338
- it('builds a flat tree when no group / sort / parent metadata is set', async () => {
339
- class Articles extends Resource { static override label = 'Articles' }
340
- class Users extends Resource { static override label = 'Users' }
341
- const panel = Pilotiq.make('T').path('/admin').resources([Articles, Users])
342
- const info = await panelInfo(panel)
343
- assert.equal(info.navigation.length, 2)
344
- assert.equal(info.navigation[0]!.name, 'Articles')
345
- assert.equal(info.navigation[0]!.url, '/admin/articles')
346
- assert.equal(info.navigation[0]!.group, undefined)
347
- assert.equal(info.navigation[0]!.children, undefined)
348
- assert.equal(info.navigation[1]!.name, 'Users')
349
- })
350
-
351
- it('uses navigationLabel + navigationIcon when set, otherwise label + icon', async () => {
352
- const Pencil = () => null
353
- class Posts extends Resource {
354
- static override label = 'Articles'
355
- static override icon = 'newspaper'
356
- static override navigationLabel = 'Posts'
357
- static override navigationIcon = Pencil as unknown as string
358
- }
359
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
360
- assert.equal(info.navigation[0]!.label, 'Posts')
361
- assert.deepEqual(info.navigation[0]!.icon, { class: 'Posts' })
362
- })
363
-
364
- it('Globals default navigationGroup to "Settings"; explicit null opts out', async () => {
365
- class Brand extends Global { static override label = 'Brand' }
366
- class Site extends Global {
367
- static override label = 'Site'
368
- static override navigationGroup = null
369
- }
370
- const info = await panelInfo(Pilotiq.make('T').path('/admin').globals([Brand, Site]))
371
- const brand = info.navigation.find(n => n.name === 'Brand')!
372
- const site = info.navigation.find(n => n.name === 'Site')!
373
- assert.equal(brand.group, 'Settings')
374
- assert.equal(site.group, undefined)
375
- })
376
-
377
- it('preserves group order based on first appearance in registration', async () => {
378
- class A extends Resource { static override label = 'A'; static override navigationGroup = 'Beta' }
379
- class B extends Resource { static override label = 'B'; static override navigationGroup = 'Alpha' }
380
- class C extends Resource { static override label = 'C'; static override navigationGroup = 'Beta' }
381
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B, C]))
382
- // Items live flat on the tree carrying `group`; later code groups by it.
383
- // Order is A (Beta), B (Alpha), C (Beta) — Beta appeared first.
384
- assert.deepEqual(info.navigation.map(n => n.group), ['Beta', 'Alpha', 'Beta'])
385
- })
386
-
387
- it('sorts within siblings by navigationSort (asc), then registration order; sorted before unsorted', async () => {
388
- class A extends Resource { static override label = 'A'; static override navigationSort = 30 }
389
- class B extends Resource { static override label = 'B'; static override navigationSort = 10 }
390
- class C extends Resource { static override label = 'C' /* no sort */ }
391
- class D extends Resource { static override label = 'D'; static override navigationSort = 20 }
392
- class E extends Resource { static override label = 'E' /* no sort, comes after C */ }
393
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B, C, D, E]))
394
- assert.deepEqual(info.navigation.map(n => n.name), ['B', 'D', 'A', 'C', 'E'])
395
- })
396
-
397
- it('nests under navigationParentItem (class-name reference)', async () => {
398
- class Parent extends Resource { static override label = 'Parent' }
399
- class Child extends Resource {
400
- static override label = 'Child'
401
- static override navigationParentItem = 'Parent'
402
- }
403
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Parent, Child]))
404
- assert.equal(info.navigation.length, 1)
405
- assert.equal(info.navigation[0]!.name, 'Parent')
406
- assert.equal(info.navigation[0]!.children?.length, 1)
407
- assert.equal(info.navigation[0]!.children![0]!.name, 'Child')
408
- })
409
-
410
- it('renders dangling parent references at top level (no console error)', async () => {
411
- class Orphan extends Resource {
412
- static override label = 'Orphan'
413
- static override navigationParentItem = 'DoesNotExist'
414
- }
415
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Orphan]))
416
- assert.equal(info.navigation.length, 1)
417
- assert.equal(info.navigation[0]!.name, 'Orphan')
418
- assert.equal(info.navigation[0]!.children, undefined)
419
- })
420
-
421
- it('breaks parent cycles: A → B → A both render at top level', async () => {
422
- class A extends Resource { static override label = 'A'; static override navigationParentItem = 'B' }
423
- class B extends Resource { static override label = 'B'; static override navigationParentItem = 'A' }
424
- // Suppress the dev warning.
425
- const origWarn = console.warn
426
- console.warn = () => {}
427
- try {
428
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([A, B]))
429
- const names = info.navigation.map(n => n.name).sort()
430
- assert.deepEqual(names, ['A', 'B'])
431
- } finally {
432
- console.warn = origWarn
433
- }
434
- })
435
-
436
- it('resolves navigationBadge handlers in parallel and stamps the result', async () => {
437
- const order: string[] = []
438
- class Slow extends Resource {
439
- static override label = 'Slow'
440
- static override navigationBadge = async () => {
441
- order.push('slow-start')
442
- await new Promise(r => setTimeout(r, 10))
443
- order.push('slow-end')
444
- return 1
445
- }
446
- }
447
- class Fast extends Resource {
448
- static override label = 'Fast'
449
- static override navigationBadge = async () => {
450
- order.push('fast-start')
451
- await new Promise(r => setTimeout(r, 5))
452
- order.push('fast-end')
453
- return 2
454
- }
455
- }
456
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Slow, Fast]))
457
- const slow = info.navigation.find(n => n.name === 'Slow')!
458
- const fast = info.navigation.find(n => n.name === 'Fast')!
459
- assert.equal(slow.badge, '1')
460
- assert.equal(fast.badge, '2')
461
- // Both started before either finished — confirms Promise.all parallelism.
462
- assert.equal(order.indexOf('slow-start') < order.indexOf('fast-end'), true)
463
- assert.equal(order.indexOf('fast-start') < order.indexOf('slow-end'), true)
464
- })
465
-
466
- it('swallows badge handler errors so the page still renders', async () => {
467
- class Broken extends Resource {
468
- static override label = 'Broken'
469
- static override navigationBadge = async () => { throw new Error('boom') }
470
- }
471
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Broken]))
472
- assert.equal(info.navigation[0]!.badge, undefined)
473
- })
474
-
475
- it('omits badge when handler returns undefined or null', async () => {
476
- class Empty extends Resource {
477
- static override label = 'Empty'
478
- static override navigationBadge = async () => undefined
479
- }
480
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Empty]))
481
- assert.equal(info.navigation[0]!.badge, undefined)
482
- })
483
-
484
- it('exposes navigationBadgeColor when not "default"', async () => {
485
- class Drafty extends Resource {
486
- static override label = 'Drafty'
487
- static override navigationBadge = () => 3
488
- static override navigationBadgeColor = 'warning' as const
489
- }
490
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Drafty]))
491
- assert.equal(info.navigation[0]!.badge, '3')
492
- assert.equal(info.navigation[0]!.badgeColor, 'warning')
493
- })
494
- })
495
-
496
- describe('tagFormStateUrls (Plan #5)', () => {
497
- it('stamps stateUrl on forms that have at least one live() field', () => {
498
- const form = Form.make().formId('f1').schema([
499
- TextField.make('a'),
500
- TextField.make('b').live(),
501
- ])
502
- tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
503
- assert.equal(form.getStateUrl(), '/admin/x/_form/f1/state')
504
- })
505
-
506
- it('skips forms whose descendants are not live', () => {
507
- const form = Form.make().formId('f2').schema([TextField.make('a')])
508
- tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
509
- assert.equal(form.getStateUrl(), undefined)
510
- })
511
-
512
- it('walks nested containers to detect live fields', () => {
513
- const form = Form.make().formId('f3').schema([
514
- Section.make('s').schema([ToggleField.make('flag').live()]),
515
- ])
516
- tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
517
- assert.equal(form.getStateUrl(), '/admin/x/_form/f3/state')
518
- })
519
-
520
- it('handles multiple forms independently', () => {
521
- const live = Form.make().formId('live').schema([TextField.make('a').live()])
522
- const inert = Form.make().formId('inert').schema([TextField.make('b')])
523
- tagFormStateUrls([live, inert], (id) => `/x/${id}`)
524
- assert.equal(live.getStateUrl(), '/x/live')
525
- assert.equal(inert.getStateUrl(), undefined)
526
- })
527
-
528
- it('also stamps stateUrl on forms with afterStateUpdatedJs but no live()', () => {
529
- // JS-only forms still need FormStateProvider mounted (so $get/$set
530
- // can read + write the values map). The endpoint URL is unused for
531
- // these — the client never POSTs unless a field is `live()`.
532
- const form = Form.make().formId('js').schema([
533
- TextField.make('title').afterStateUpdatedJs(`$set('slug', $state)`),
534
- ])
535
- tagFormStateUrls([form], (id) => `/admin/x/_form/${id}/state`)
536
- assert.equal(form.getStateUrl(), '/admin/x/_form/js/state')
537
- })
538
- })
539
-
540
- describe('tagSelectCreateOptionUrls (audit row 2026-05-07 cont\'d⁸)', () => {
541
- it('stamps url on SelectFields configured with createOptionForm', () => {
542
- const sel = SelectField.make('categoryId').options([])
543
- .createOptionForm([TextField.make('name')])
544
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
545
- const form = Form.make().formId('post-create').schema([sel])
546
-
547
- tagSelectCreateOptionUrls(
548
- [form],
549
- (formId, fieldName) => `/admin/posts/_form/${formId}/create-option/${fieldName}`,
550
- )
551
- assert.equal(sel.getCreateOptionUrl(), '/admin/posts/_form/post-create/create-option/categoryId')
552
- })
553
-
554
- it('skips bare SelectFields with no createOptionForm', () => {
555
- const sel = SelectField.make('status').options([{ value: 'a', label: 'A' }])
556
- const form = Form.make().formId('f').schema([sel])
557
- tagSelectCreateOptionUrls([form], (id, name) => `/x/${id}/${name}`)
558
- assert.equal(sel.getCreateOptionUrl(), undefined)
559
- })
560
-
561
- it('walks nested layout containers', () => {
562
- const sel = SelectField.make('tagId').options([])
563
- .createOptionForm([TextField.make('name')])
564
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
565
- const form = Form.make().formId('nest').schema([
566
- Section.make('Meta').schema([sel]),
567
- ])
568
- tagSelectCreateOptionUrls(
569
- [form],
570
- (id, name) => `/p/_form/${id}/create-option/${name}`,
571
- )
572
- assert.equal(sel.getCreateOptionUrl(), '/p/_form/nest/create-option/tagId')
573
- })
574
-
575
- it('does not overwrite an already-stamped url', () => {
576
- const sel = SelectField.make('a').options([])
577
- .createOptionForm([TextField.make('name')])
578
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
579
- .withCreateOptionUrl('/preset')
580
- const form = Form.make().formId('f').schema([sel])
581
- tagSelectCreateOptionUrls([form], () => '/clobber')
582
- assert.equal(sel.getCreateOptionUrl(), '/preset')
583
- })
584
-
585
- it('stamps url on multiple selects independently', () => {
586
- const a = SelectField.make('a').options([])
587
- .createOptionForm([TextField.make('n')])
588
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
589
- const b = SelectField.make('b').options([])
590
- .createOptionForm([TextField.make('n')])
591
- .createOptionUsing(async () => ({ value: '2', label: 'y' }))
592
- const form = Form.make().formId('multi').schema([a, b])
593
- tagSelectCreateOptionUrls(
594
- [form],
595
- (id, name) => `/p/${id}/${name}`,
596
- )
597
- assert.equal(a.getCreateOptionUrl(), '/p/multi/a')
598
- assert.equal(b.getCreateOptionUrl(), '/p/multi/b')
599
- })
600
-
601
- it('stops at Repeater boundaries — inside-row SelectFields are not stamped', () => {
602
- const innerSel = SelectField.make('childCat').options([])
603
- .createOptionForm([TextField.make('n')])
604
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
605
- const outerSel = SelectField.make('rootCat').options([])
606
- .createOptionForm([TextField.make('n')])
607
- .createOptionUsing(async () => ({ value: '1', label: 'x' }))
608
- const form = Form.make().formId('rep').schema([
609
- outerSel,
610
- Repeater.make('rows').schema([innerSel]),
611
- ])
612
- tagSelectCreateOptionUrls([form], (id, name) => `/p/${id}/${name}`)
613
- assert.equal(outerSel.getCreateOptionUrl(), '/p/rep/rootCat')
614
- assert.equal(innerSel.getCreateOptionUrl(), undefined)
615
- })
616
- })
617
-
618
- describe('formCreateOptionData (audit row 2026-05-07 cont\'d⁸)', () => {
619
- function makePanelWithCreateOption(opts?: {
620
- handler?: (values: Record<string, unknown>) => Promise<{ value: string; label: string }>
621
- authorize?: import('./actions/Action.js').VisibilityRule
622
- createForm?: Element[]
623
- }) {
624
- const handler = opts?.handler ?? (async (v: Record<string, unknown>) => ({ value: 'new-id', label: String(v['name']) }))
625
- const createForm = opts?.createForm ?? [TextField.make('name')]
626
-
627
- class DemoPage extends Page {
628
- static override slug = 'demo'
629
- static override async schema() {
630
- const sel = SelectField.make('categoryId').options([])
631
- .createOptionForm(createForm)
632
- .createOptionUsing(handler)
633
- if (opts?.authorize !== undefined) sel.createOptionAuthorize(opts.authorize)
634
- // Two forms on the page so `selectFormById` doesn't fall back to
635
- // the only form (single-form fallback is intentional for SPA
636
- // edge cases — strict-match tests need 2+ forms).
637
- return [
638
- Form.make().formId('the-form').schema([sel]),
639
- Form.make().formId('other-form').schema([TextField.make('decoy')]),
640
- ]
641
- }
642
- }
643
- return Pilotiq.make('T').path('/admin').pages([DemoPage])
644
- }
645
-
646
- it('returns null when route prefix does not resolve', async () => {
647
- const panel = Pilotiq.make('T').path('/admin')
648
- const result = await formCreateOptionData(
649
- panel,
650
- { kind: 'page', pageSlug: 'no-such-page' },
651
- { formId: 'x', fieldName: 'y', values: {} },
652
- )
653
- assert.equal(result, null)
654
- })
655
-
656
- it('returns 404 when form is not found on page', async () => {
657
- const panel = makePanelWithCreateOption()
658
- const result = await formCreateOptionData(
659
- panel,
660
- { kind: 'page', pageSlug: 'demo' },
661
- { formId: 'wrong-form', fieldName: 'categoryId', values: { name: 'X' } },
662
- )
663
- assert.deepEqual(result, { ok: false, status: 404, error: 'Form "wrong-form" not found on page' })
664
- })
665
-
666
- it('returns 404 when SelectField is not found on form', async () => {
667
- const panel = makePanelWithCreateOption()
668
- const result = await formCreateOptionData(
669
- panel,
670
- { kind: 'page', pageSlug: 'demo' },
671
- { formId: 'the-form', fieldName: 'unknownField', values: {} },
672
- )
673
- const r = result as { ok: false; status: number; error: string }
674
- assert.equal(r.ok, false)
675
- assert.equal(r.status, 404)
676
- assert.match(r.error, /not found on form/)
677
- })
678
-
679
- it('returns 403 when authorize rule rejects', async () => {
680
- const panel = makePanelWithCreateOption({ authorize: false })
681
- const result = await formCreateOptionData(
682
- panel,
683
- { kind: 'page', pageSlug: 'demo' },
684
- { formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
685
- )
686
- assert.deepEqual(result, { ok: false, status: 403, error: 'createOptionAuthorize denied' })
687
- })
688
-
689
- it('returns 422 when validation fails', async () => {
690
- const panel = makePanelWithCreateOption({
691
- createForm: [TextField.make('name').required()],
692
- })
693
- const result = await formCreateOptionData(
694
- panel,
695
- { kind: 'page', pageSlug: 'demo' },
696
- { formId: 'the-form', fieldName: 'categoryId', values: { name: '' } },
697
- )
698
- const r = result as { ok: false; status: number; errors: Record<string, string[]> }
699
- assert.equal(r.ok, false)
700
- assert.equal(r.status, 422)
701
- assert.ok(r.errors['name'])
702
- })
703
-
704
- it('returns 200 + option on happy path', async () => {
705
- const panel = makePanelWithCreateOption()
706
- const result = await formCreateOptionData(
707
- panel,
708
- { kind: 'page', pageSlug: 'demo' },
709
- { formId: 'the-form', fieldName: 'categoryId', values: { name: 'Tech' } },
710
- )
711
- assert.deepEqual(result, { ok: true, option: { value: 'new-id', label: 'Tech' } })
712
- })
713
-
714
- it('returns 500 when handler throws', async () => {
715
- const panel = makePanelWithCreateOption({
716
- handler: async () => { throw new Error('boom') },
717
- })
718
- const result = await formCreateOptionData(
719
- panel,
720
- { kind: 'page', pageSlug: 'demo' },
721
- { formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
722
- )
723
- assert.deepEqual(result, { ok: false, status: 500, error: 'boom' })
724
- })
725
-
726
- it('returns 500 when handler returns malformed shape', async () => {
727
- const panel = makePanelWithCreateOption({
728
- // @ts-expect-error testing runtime shape guard
729
- handler: async () => ({ value: 'x' /* missing label */ }),
730
- })
731
- const result = await formCreateOptionData(
732
- panel,
733
- { kind: 'page', pageSlug: 'demo' },
734
- { formId: 'the-form', fieldName: 'categoryId', values: { name: 'X' } },
735
- )
736
- const r = result as { ok: false; status: number; error: string }
737
- assert.equal(r.ok, false)
738
- assert.equal(r.status, 500)
739
- assert.match(r.error, /\{ value: string, label: string \}/)
740
- })
741
- })
742
-
743
- /**
744
- * Minimal duck-typed RichTextField stand-in. The walker uses
745
- * `getType() === 'richtext'` + `hasAsyncMentions` + `withMentionsUrl` —
746
- * matching the same shape `@pilotiq/tiptap`'s real `RichTextField`
747
- * exposes. Pilotiq core never imports the adapter; the walker contract
748
- * has to be testable with a plain `Element` subclass.
749
- */
750
- class FakeRichTextField extends Element {
751
- readonly name: string
752
- private readonly _hasAsync: boolean
753
- public stamped: string | undefined = undefined
754
-
755
- constructor(name: string, hasAsync: boolean) {
756
- super()
757
- this.name = name
758
- this._hasAsync = hasAsync
759
- }
760
- override getType(): string { return 'richtext' }
761
- override toMeta(): Record<string, unknown> {
762
- return {
763
- type: 'field', fieldType: 'richtext', name: this.name,
764
- ...(this.stamped !== undefined ? { mentionsUrl: this.stamped } : {}),
765
- }
766
- }
767
- hasAsyncMentions(): boolean { return this._hasAsync }
768
- withMentionsUrl(url: string): this { this.stamped = url; return this }
769
- async resolveMention(
770
- trigger: string,
771
- query: string,
772
- _ctx: Record<string, unknown>,
773
- ): Promise<Array<{ id: string; label: string }> | null> {
774
- if (trigger === '@') return [{ id: query, label: `User:${query}` }]
775
- return null
776
- }
777
- }
778
-
779
- describe('tagRichTextMentionUrls (async mention items)', () => {
780
- it('stamps mentionsUrl on RichTextFields with async providers', () => {
781
- const f = new FakeRichTextField('body', true)
782
- const form = Form.make().formId('art').schema([f])
783
- tagRichTextMentionUrls([form], (id) => `/admin/articles/_form/${id}/mentions`)
784
- assert.equal(f.stamped, '/admin/articles/_form/art/mentions')
785
- })
786
-
787
- it('skips RichTextFields with only static providers', () => {
788
- const staticField = new FakeRichTextField('body', false)
789
- const form = Form.make().formId('art').schema([staticField])
790
- tagRichTextMentionUrls([form], (id) => `/x/${id}`)
791
- assert.equal(staticField.stamped, undefined)
792
- })
793
-
794
- it('walks nested containers to find rich-text fields', () => {
795
- const inner = new FakeRichTextField('body', true)
796
- const form = Form.make().formId('art').schema([
797
- Section.make('s').schema([inner]),
798
- ])
799
- tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
800
- assert.equal(inner.stamped, '/admin/_form/art/mentions')
801
- })
802
-
803
- it('handles multiple forms — each gets its own URL', () => {
804
- const a = new FakeRichTextField('body', true)
805
- const b = new FakeRichTextField('body', true)
806
- const formA = Form.make().formId('a').schema([a])
807
- const formB = Form.make().formId('b').schema([b])
808
- tagRichTextMentionUrls([formA, formB], (id) => `/x/_form/${id}/mentions`)
809
- assert.equal(a.stamped, '/x/_form/a/mentions')
810
- assert.equal(b.stamped, '/x/_form/b/mentions')
811
- })
812
-
813
- it('skips non-richtext elements that share method names by accident', () => {
814
- // The fast filter `getType() === 'richtext'` keeps a coincidental
815
- // duck-type collision (e.g. someone naming a custom element with
816
- // `withMentionsUrl`) from being mistakenly stamped.
817
- class WrongType extends Element {
818
- stamped: string | undefined = undefined
819
- override getType(): string { return 'custom' }
820
- override toMeta(): Record<string, unknown> { return { type: 'custom' } }
821
- hasAsyncMentions(): boolean { return true }
822
- withMentionsUrl(url: string): this { this.stamped = url; return this }
823
- }
824
- const wrong = new WrongType()
825
- const form = Form.make().formId('f').schema([wrong as unknown as Element])
826
- tagRichTextMentionUrls([form], (id) => `/x/${id}`)
827
- assert.equal(wrong.stamped, undefined)
828
- })
829
- })
830
-
831
- describe('tagTableReorderUrls (reorderable rows)', () => {
832
- it('stamps reorderUrl on tables with reorderable() opted in', () => {
833
- const t = Table.make().reorderable('sort').columns([Column.make('id')])
834
- tagTableReorderUrls([t], '/admin/posts/_reorder')
835
- assert.equal(t.getReorderUrl(), '/admin/posts/_reorder')
836
- })
837
-
838
- it('skips tables without reorderable()', () => {
839
- const t = Table.make().columns([Column.make('id')])
840
- tagTableReorderUrls([t], '/admin/posts/_reorder')
841
- assert.equal(t.getReorderUrl(), undefined)
842
- })
843
-
844
- it('preserves a previously stamped URL (idempotent)', () => {
845
- const t = Table.make().reorderable('sort').withReorderUrl('/x/_reorder')
846
- tagTableReorderUrls([t], '/y/_reorder')
847
- assert.equal(t.getReorderUrl(), '/x/_reorder')
848
- })
849
- })
850
-
851
- describe('tagCellEditUrls (editable columns)', () => {
852
- it('stamps _cellEditUrls only on rows that already carry _cellEditable', () => {
853
- const t = Table.make<Record<string, unknown>>()
854
- .columns([Column.make('id'), TextInputColumn.make('title')])
855
- .withRows([
856
- { id: '1', _cellEditable: { title: true } },
857
- { id: '2' /* canEdit was false — no editable map */ },
858
- ], 2)
859
-
860
- tagCellEditUrls([t], '/admin/posts')
861
- const rows = t.getRows() as Array<Record<string, unknown>>
862
- assert.deepEqual(rows[0]!['_cellEditUrls'], { title: '/admin/posts/1/_cell/title' })
863
- assert.equal(rows[1]!['_cellEditUrls'], undefined)
864
- })
865
-
866
- it('skips tables that have no editable columns', () => {
867
- const t = Table.make<Record<string, unknown>>()
868
- .columns([Column.make('id')])
869
- .withRows([{ id: '1' }], 1)
870
-
871
- tagCellEditUrls([t], '/admin/posts')
872
- const rows = t.getRows() as Array<Record<string, unknown>>
873
- assert.equal(rows[0]!['_cellEditUrls'], undefined)
874
- })
875
-
876
- it('builds a URL per editable column on the row', () => {
877
- const t = Table.make<Record<string, unknown>>()
878
- .columns([
879
- Column.make('id'),
880
- TextInputColumn.make('title'),
881
- ToggleColumn.make('featured'),
882
- ])
883
- .withRows([
884
- { id: '7', _cellEditable: { title: true, featured: true } },
885
- ], 1)
886
-
887
- tagCellEditUrls([t], '/admin/posts')
888
- const rows = t.getRows() as Array<Record<string, unknown>>
889
- assert.deepEqual(rows[0]!['_cellEditUrls'], {
890
- title: '/admin/posts/7/_cell/title',
891
- featured: '/admin/posts/7/_cell/featured',
892
- })
893
- })
894
-
895
- it('encodes the row id and column name', () => {
896
- const t = Table.make<Record<string, unknown>>()
897
- .columns([Column.make('id'), TextInputColumn.make('weird name')])
898
- .withRows([
899
- { id: 'a/b', _cellEditable: { 'weird name': true } },
900
- ], 1)
901
-
902
- tagCellEditUrls([t], '/admin/posts')
903
- const rows = t.getRows() as Array<Record<string, unknown>>
904
- assert.deepEqual(rows[0]!['_cellEditUrls'], {
905
- 'weird name': '/admin/posts/a%2Fb/_cell/weird%20name',
906
- })
907
- })
908
- })
909
-
910
- describe('formStateData (Plan #5)', () => {
911
- it('returns null when the page-scope is unknown', async () => {
912
- class Articles extends Resource {
913
- static override label = 'Articles'
914
- }
915
- const panel = Pilotiq.make('T').path('/admin').resources([Articles])
916
- const result = await formStateData(panel, { kind: 'resource-edit', slug: 'missing', recordId: '1' }, { formId: 'f', changed: 'x', values: {} })
917
- assert.equal(result, null)
918
- })
919
-
920
- it('returns 404 when the form id misses on a multi-form page', async () => {
921
- class TestPage extends Page {
922
- static override slug = 'demo'
923
- static override schema() {
924
- return [
925
- Form.make().formId('one').schema([TextField.make('x').live()]),
926
- Form.make().formId('two').schema([TextField.make('y').live()]),
927
- ]
928
- }
929
- }
930
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
931
- const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'wrong-id', changed: 'x', values: { x: 'v' } })
932
- assert.notEqual(result, null)
933
- assert.equal((result as { ok: false; status: number }).ok, false)
934
- assert.equal((result as { ok: false; status: number }).status, 404)
935
- })
936
-
937
- it('falls back to the only form when the formId misses on a single-form page', async () => {
938
- // Removes the auto-counter desync footgun for reactive demos —
939
- // see selectFormById in elements/dispatchForm.ts.
940
- class TestPage extends Page {
941
- static override slug = 'demo'
942
- static override schema() {
943
- return [Form.make().formId('the-form').schema([TextField.make('x').live()])]
944
- }
945
- }
946
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
947
- const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'mismatched-counter', changed: 'x', values: { x: 'v' } })
948
- assert.notEqual(result, null)
949
- assert.equal((result as { ok: true }).ok, true)
950
- })
951
-
952
- it('returns 422 when the changed field does not exist on the form', async () => {
953
- class TestPage extends Page {
954
- static override slug = 'demo'
955
- static override schema() {
956
- return [Form.make().formId('the-form').schema([TextField.make('x').live()])]
957
- }
958
- }
959
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
960
- const result = await formStateData(panel, { kind: 'page', pageSlug: 'demo' }, { formId: 'the-form', changed: 'missing', values: {} })
961
- assert.notEqual(result, null)
962
- assert.equal((result as { ok: false; status: number }).ok, false)
963
- assert.equal((result as { ok: false; status: number }).status, 422)
964
- })
965
-
966
- it('runs afterStateUpdated and returns the resolved form meta', async () => {
967
- class TestPage extends Page {
968
- static override slug = 'demo'
969
- static override schema() {
970
- return [Form.make().formId('the-form').schema([
971
- TextField.make('title').live().afterStateUpdated((value, { $set }) => {
972
- $set('slug', String(value).toLowerCase().replace(/\s+/g, '-'))
973
- }),
974
- TextField.make('slug'),
975
- ])]
976
- }
977
- }
978
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
979
- const result = await formStateData(
980
- panel,
981
- { kind: 'page', pageSlug: 'demo' },
982
- { formId: 'the-form', changed: 'title', values: { title: 'Hello World', slug: 'old' } },
983
- )
984
- assert.notEqual(result, null)
985
- if (result === null || !result.ok) throw new Error('expected ok result')
986
- assert.deepEqual(result.dirty.sort(), ['slug', 'title'])
987
- const formMeta = result.form as { values?: Record<string, unknown> }
988
- assert.equal(formMeta.values?.['slug'], 'hello-world')
989
- })
990
-
991
- // Regression lock — reactive `itemHidden` end-to-end. Server-side resolve
992
- // alone is covered in `RepeaterField.test.ts` / `BuilderField.test.ts`, and
993
- // the client-side row-gate sync is covered in `syncRowGates.test.ts`. This
994
- // covers the wire between them: applyStateUpdate of a row-leaf dotted
995
- // path, then full resolveSchema, with the `itemHidden` rule reading the
996
- // updated row value. If this regresses, peer A typing into a `live()`
997
- // inner field would never flip the row's chrome on a real form.
998
-
999
- it('re-evaluates Repeater itemHidden after a live() inner-leaf cycle', async () => {
1000
- class TestPage extends Page {
1001
- static override slug = 'demo'
1002
- static override schema() {
1003
- return [Form.make().formId('the-form').schema([
1004
- Repeater.make('items')
1005
- .schema([
1006
- TextField.make('mode').live(),
1007
- TextField.make('label'),
1008
- ])
1009
- .itemHidden(({ values }) => (values as Record<string, unknown>)['mode'] === 'hidden'),
1010
- ])]
1011
- }
1012
- }
1013
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1014
-
1015
- // Before: row is visible.
1016
- const visible = await formStateData(
1017
- panel,
1018
- { kind: 'page', pageSlug: 'demo' },
1019
- { formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'visible', label: 'one' }] } },
1020
- )
1021
- if (visible === null || !visible.ok) throw new Error('expected ok result')
1022
- const visibleMeta = visible.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
1023
- assert.equal(visibleMeta.children[0]?.rows[0]?.hidden, undefined)
1024
-
1025
- // After: same row, `mode` flipped to `'hidden'` — itemHidden re-evaluates.
1026
- const hidden = await formStateData(
1027
- panel,
1028
- { kind: 'page', pageSlug: 'demo' },
1029
- { formId: 'the-form', changed: 'items.0.mode', values: { items: [{ mode: 'hidden', label: 'one' }] } },
1030
- )
1031
- if (hidden === null || !hidden.ok) throw new Error('expected ok result')
1032
- const hiddenMeta = hidden.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
1033
- assert.equal(hiddenMeta.children[0]?.rows[0]?.hidden, true)
1034
- // Row id stays stable across the cycle — syncRowGates on the client
1035
- // matches on `id`, so an unstable id would silently skip the hidden
1036
- // flip.
1037
- assert.equal(hiddenMeta.children[0]?.rows[0]?.id, visibleMeta.children[0]?.rows[0]?.id)
1038
- })
1039
-
1040
- it('re-evaluates Builder itemHidden after a live() block-leaf cycle', async () => {
1041
- class TestPage extends Page {
1042
- static override slug = 'demo'
1043
- static override schema() {
1044
- return [Form.make().formId('the-form').schema([
1045
- Builder.make('content')
1046
- .blocks([
1047
- Block.make('heading').schema([
1048
- TextField.make('text').live(),
1049
- TextField.make('anchor'),
1050
- ]),
1051
- ])
1052
- .itemHidden(({ values }) => (values as Record<string, unknown>)['text'] === 'skip'),
1053
- ])]
1054
- }
1055
- }
1056
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1057
-
1058
- const keep = await formStateData(
1059
- panel,
1060
- { kind: 'page', pageSlug: 'demo' },
1061
- {
1062
- formId: 'the-form',
1063
- changed: 'content.0.data.text',
1064
- values: { content: [{ type: 'heading', data: { text: 'keep', anchor: '' } }] },
1065
- },
1066
- )
1067
- if (keep === null || !keep.ok) throw new Error('expected ok result')
1068
- const keepMeta = keep.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
1069
- assert.equal(keepMeta.children[0]?.rows[0]?.hidden, undefined)
1070
-
1071
- const skip = await formStateData(
1072
- panel,
1073
- { kind: 'page', pageSlug: 'demo' },
1074
- {
1075
- formId: 'the-form',
1076
- changed: 'content.0.data.text',
1077
- values: { content: [{ type: 'heading', data: { text: 'skip', anchor: '' } }] },
1078
- },
1079
- )
1080
- if (skip === null || !skip.ok) throw new Error('expected ok result')
1081
- const skipMeta = skip.form as { children: Array<{ rows: Array<{ id: string; hidden?: boolean }> }> }
1082
- assert.equal(skipMeta.children[0]?.rows[0]?.hidden, true)
1083
- assert.equal(skipMeta.children[0]?.rows[0]?.id, keepMeta.children[0]?.rows[0]?.id)
1084
- })
1085
- })
1086
-
1087
- describe('mentionResolveData (async mention items)', () => {
1088
- it('returns null when the page scope misses', async () => {
1089
- class Articles extends Resource {
1090
- static override label = 'Articles'
1091
- }
1092
- const panel = Pilotiq.make('T').path('/admin').resources([Articles])
1093
- const result = await mentionResolveData(
1094
- panel,
1095
- { kind: 'resource-edit', slug: 'missing', recordId: '1' },
1096
- { formId: 'f', field: 'body', trigger: '@', query: 'a' },
1097
- )
1098
- assert.equal(result, null)
1099
- })
1100
-
1101
- it('returns 404 when the form id misses on a multi-form page', async () => {
1102
- class TestPage extends Page {
1103
- static override slug = 'demo'
1104
- static override schema() {
1105
- return [
1106
- Form.make().formId('one').schema([new FakeRichTextField('body', true)]),
1107
- Form.make().formId('two').schema([TextField.make('a')]),
1108
- ]
1109
- }
1110
- }
1111
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1112
- const result = await mentionResolveData(
1113
- panel,
1114
- { kind: 'page', pageSlug: 'demo' },
1115
- { formId: 'wrong', field: 'body', trigger: '@', query: 'a' },
1116
- )
1117
- assert.notEqual(result, null)
1118
- if (result === null) throw new Error('expected non-null result')
1119
- assert.equal((result as { ok: false; status: number }).ok, false)
1120
- assert.equal((result as { ok: false; status: number }).status, 404)
1121
- })
1122
-
1123
- it('returns 404 when the field is not on the form', async () => {
1124
- class TestPage extends Page {
1125
- static override slug = 'demo'
1126
- static override schema() {
1127
- return [Form.make().formId('the-form').schema([new FakeRichTextField('intro', true)])]
1128
- }
1129
- }
1130
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1131
- const result = await mentionResolveData(
1132
- panel,
1133
- { kind: 'page', pageSlug: 'demo' },
1134
- { formId: 'the-form', field: 'body', trigger: '@', query: 'a' },
1135
- )
1136
- assert.notEqual(result, null)
1137
- assert.equal((result as { ok: false; status: number }).ok, false)
1138
- assert.equal((result as { ok: false; status: number }).status, 404)
1139
- })
1140
-
1141
- it('returns 404 when the trigger has no provider', async () => {
1142
- class TestPage extends Page {
1143
- static override slug = 'demo'
1144
- static override schema() {
1145
- return [Form.make().formId('the-form').schema([new FakeRichTextField('body', true)])]
1146
- }
1147
- }
1148
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1149
- const result = await mentionResolveData(
1150
- panel,
1151
- { kind: 'page', pageSlug: 'demo' },
1152
- { formId: 'the-form', field: 'body', trigger: '!', query: 'a' },
1153
- )
1154
- assert.notEqual(result, null)
1155
- if (result === null) throw new Error('expected non-null result')
1156
- assert.equal((result as { ok: false; status: number }).ok, false)
1157
- assert.equal((result as { ok: false; status: number }).status, 404)
1158
- })
1159
-
1160
- it('returns the resolved items for a known trigger', async () => {
1161
- class TestPage extends Page {
1162
- static override slug = 'demo'
1163
- static override schema() {
1164
- return [Form.make().formId('the-form').schema([new FakeRichTextField('body', true)])]
1165
- }
1166
- }
1167
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1168
- const result = await mentionResolveData(
1169
- panel,
1170
- { kind: 'page', pageSlug: 'demo' },
1171
- { formId: 'the-form', field: 'body', trigger: '@', query: 'sleman' },
1172
- )
1173
- assert.notEqual(result, null)
1174
- if (result === null || !result.ok) throw new Error('expected ok result')
1175
- assert.equal(result.items.length, 1)
1176
- assert.equal(result.items[0]!.id, 'sleman')
1177
- assert.equal(result.items[0]!.label, 'User:sleman')
1178
- })
1179
-
1180
- it('resolves a RichTextField nested inside a Repeater row via dotted path', async () => {
1181
- class TestPage extends Page {
1182
- static override slug = 'demo'
1183
- static override schema() {
1184
- return [Form.make().formId('the-form').schema([
1185
- Repeater.make('items').schema([new FakeRichTextField('body', true)]),
1186
- ])]
1187
- }
1188
- }
1189
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1190
- const result = await mentionResolveData(
1191
- panel,
1192
- { kind: 'page', pageSlug: 'demo' },
1193
- { formId: 'the-form', field: 'items.0.body', trigger: '@', query: 'sleman' },
1194
- )
1195
- assert.notEqual(result, null)
1196
- if (result === null || !result.ok) throw new Error('expected ok result')
1197
- assert.equal(result.items[0]!.id, 'sleman')
1198
- })
1199
-
1200
- it('resolves a RichTextField nested inside a Builder block via dotted path', async () => {
1201
- class TestPage extends Page {
1202
- static override slug = 'demo'
1203
- static override schema() {
1204
- return [Form.make().formId('the-form').schema([
1205
- Builder.make('blocks').blocks([
1206
- Block.make('callout').schema([new FakeRichTextField('body', true)]),
1207
- ]),
1208
- ])]
1209
- }
1210
- }
1211
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1212
- const result = await mentionResolveData(
1213
- panel,
1214
- { kind: 'page', pageSlug: 'demo' },
1215
- { formId: 'the-form', field: 'blocks.0.data.body', trigger: '@', query: 'sleman' },
1216
- )
1217
- assert.notEqual(result, null)
1218
- if (result === null || !result.ok) throw new Error('expected ok result')
1219
- assert.equal(result.items[0]!.id, 'sleman')
1220
- })
1221
-
1222
- it('returns 404 when a Repeater dotted path does not match any inner field', async () => {
1223
- class TestPage extends Page {
1224
- static override slug = 'demo'
1225
- static override schema() {
1226
- return [Form.make().formId('the-form').schema([
1227
- Repeater.make('items').schema([new FakeRichTextField('body', true)]),
1228
- ])]
1229
- }
1230
- }
1231
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1232
- const result = await mentionResolveData(
1233
- panel,
1234
- { kind: 'page', pageSlug: 'demo' },
1235
- { formId: 'the-form', field: 'items.0.missing', trigger: '@', query: 'a' },
1236
- )
1237
- assert.notEqual(result, null)
1238
- assert.equal((result as { ok: false; status: number }).ok, false)
1239
- assert.equal((result as { ok: false; status: number }).status, 404)
1240
- })
1241
-
1242
- it('returns 404 for a Builder path missing the literal `data` segment', async () => {
1243
- class TestPage extends Page {
1244
- static override slug = 'demo'
1245
- static override schema() {
1246
- return [Form.make().formId('the-form').schema([
1247
- Builder.make('blocks').blocks([
1248
- Block.make('callout').schema([new FakeRichTextField('body', true)]),
1249
- ]),
1250
- ])]
1251
- }
1252
- }
1253
- const panel = Pilotiq.make('T').path('/admin').pages([TestPage])
1254
- // Repeater-shaped path doesn't reach a Builder leaf.
1255
- const result = await mentionResolveData(
1256
- panel,
1257
- { kind: 'page', pageSlug: 'demo' },
1258
- { formId: 'the-form', field: 'blocks.0.body', trigger: '@', query: 'a' },
1259
- )
1260
- assert.notEqual(result, null)
1261
- assert.equal((result as { ok: false; status: number }).ok, false)
1262
- assert.equal((result as { ok: false; status: number }).status, 404)
1263
- })
1264
- })
1265
-
1266
- describe('formWizardData — Step.beforeValidation / afterValidation hooks', () => {
1267
- function panelWithWizard(steps: Step[]) {
1268
- class TestPage extends Page {
1269
- static override slug = 'demo'
1270
- static override schema() {
1271
- return [Form.make().formId('the-form').schema([Wizard.make().steps(steps)])]
1272
- }
1273
- }
1274
- return Pilotiq.make('T').path('/admin').pages([TestPage])
1275
- }
1276
-
1277
- const dispatch = (panel: ReturnType<typeof Pilotiq.make>, body: { formId: string; step: number; values: Record<string, unknown> }) =>
1278
- formWizardData(panel, { kind: 'page', pageSlug: 'demo' }, body)
1279
-
1280
- it('returns ok:true when no hooks are set and validation passes', async () => {
1281
- const panel = panelWithWizard([Step.make('a').schema([TextField.make('x')])])
1282
- const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
1283
- assert.deepEqual(result, { ok: true })
1284
- })
1285
-
1286
- it('runs beforeValidation before validators and lets it mutate values in place', async () => {
1287
- const seen: string[] = []
1288
- const panel = panelWithWizard([
1289
- Step.make('a').schema([TextField.make('email').required()])
1290
- .beforeValidation((values) => {
1291
- seen.push('before')
1292
- values['email'] = 'auto@example.com'
1293
- }),
1294
- ])
1295
- const result = await dispatch(panel, { formId: 'the-form', step: 0, values: {} })
1296
- assert.deepEqual(result, { ok: true })
1297
- assert.deepEqual(seen, ['before'])
1298
- })
1299
-
1300
- it('throwing from beforeValidation halts with 422 under the _step key', async () => {
1301
- const panel = panelWithWizard([
1302
- Step.make('a').schema([TextField.make('x')])
1303
- .beforeValidation(async () => { throw new Error('email already in use') }),
1304
- ])
1305
- const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
1306
- assert.equal((result as { ok: false; status: number }).ok, false)
1307
- assert.equal((result as { ok: false; status: number }).status, 422)
1308
- assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['email already in use'] })
1309
- })
1310
-
1311
- it('runs afterValidation only when validators pass', async () => {
1312
- let afterRan = false
1313
- const panel = panelWithWizard([
1314
- Step.make('a').schema([TextField.make('x').required()])
1315
- .afterValidation(() => { afterRan = true }),
1316
- ])
1317
- // Failing field validators short-circuit before afterValidation fires.
1318
- const failed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: '' } })
1319
- assert.equal((failed as { ok: false }).ok, false)
1320
- assert.equal(afterRan, false)
1321
- // Passing values let afterValidation run.
1322
- const passed = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
1323
- assert.deepEqual(passed, { ok: true })
1324
- assert.equal(afterRan, true)
1325
- })
1326
-
1327
- it('throwing from afterValidation halts with 422 under the _step key', async () => {
1328
- const panel = panelWithWizard([
1329
- Step.make('a').schema([TextField.make('x')])
1330
- .afterValidation(() => { throw new Error('cross-field invariant failed') }),
1331
- ])
1332
- const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
1333
- assert.equal((result as { ok: false; status: number }).status, 422)
1334
- assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['cross-field invariant failed'] })
1335
- })
1336
-
1337
- it('non-Error throws still produce a usable message', async () => {
1338
- const panel = panelWithWizard([
1339
- Step.make('a').schema([TextField.make('x')])
1340
- .beforeValidation(() => { throw 'plain string failure' as unknown as Error }),
1341
- ])
1342
- const result = await dispatch(panel, { formId: 'the-form', step: 0, values: { x: 'v' } })
1343
- assert.deepEqual((result as { errors: Record<string, string[]> }).errors, { _step: ['plain string failure'] })
1344
- })
1345
- })
1346
-
1347
- describe('tagRichTextMentionUrls — nested Repeater + Builder rows', () => {
1348
- it('stamps a Repeater template field via the form-level URL', () => {
1349
- const inner = new FakeRichTextField('body', true)
1350
- const form = Form.make().formId('art').schema([
1351
- Repeater.make('items').schema([inner]),
1352
- ])
1353
- tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
1354
- assert.equal(inner.stamped, '/admin/_form/art/mentions')
1355
- })
1356
-
1357
- it('stamps a Builder block leaf even though Builder.getChildren() is undefined', () => {
1358
- const inner = new FakeRichTextField('body', true)
1359
- const form = Form.make().formId('art').schema([
1360
- Builder.make('blocks').blocks([
1361
- Block.make('callout').schema([inner]),
1362
- ]),
1363
- ])
1364
- tagRichTextMentionUrls([form], (id) => `/admin/_form/${id}/mentions`)
1365
- assert.equal(inner.stamped, '/admin/_form/art/mentions')
1366
- })
1367
- })
1368
-
1369
- describe('applyEditPageHydrators (Pilotiq.editPageHydrator)', () => {
1370
- class Posts extends Resource { static override label = 'Posts' }
1371
- const ctx = (currentValues: Record<string, unknown> = {}) => ({
1372
- resource: Posts,
1373
- recordId: '42',
1374
- currentValues,
1375
- })
1376
-
1377
- it('empty hydrators array → empty overlay', async () => {
1378
- const overlay = await applyEditPageHydrators([], ctx())
1379
- assert.deepEqual(overlay, {})
1380
- })
1381
-
1382
- it('hydrator returning null → empty overlay', async () => {
1383
- const overlay = await applyEditPageHydrators([
1384
- async () => null,
1385
- ], ctx())
1386
- assert.deepEqual(overlay, {})
1387
- })
1388
-
1389
- it('hydrator returning a partial → overlay carries the keys', async () => {
1390
- const overlay = await applyEditPageHydrators([
1391
- async () => ({ title: 'Y-Title', body: 'Y-Body' }),
1392
- ], ctx({ title: 'DB-Title', body: 'DB-Body', author: 'DB-Author' }))
1393
- assert.deepEqual(overlay, { title: 'Y-Title', body: 'Y-Body' })
1394
- })
1395
-
1396
- it('two hydrators merge in registration order (later wins on conflict)', async () => {
1397
- const overlay = await applyEditPageHydrators([
1398
- async () => ({ title: 'first', shared: 'first-shared' }),
1399
- async () => ({ body: 'second', shared: 'second-shared' }),
1400
- ], ctx())
1401
- assert.deepEqual(overlay, {
1402
- title: 'first',
1403
- body: 'second',
1404
- shared: 'second-shared',
1405
- })
1406
- })
1407
-
1408
- it('hydrator that throws is swallowed; siblings still contribute', async () => {
1409
- // Stub console.warn so the test output stays clean; restore after.
1410
- const originalWarn = console.warn
1411
- let warned = false
1412
- console.warn = (..._args: unknown[]) => { warned = true }
1413
- try {
1414
- const overlay = await applyEditPageHydrators([
1415
- async () => { throw new Error('boom') },
1416
- async () => ({ title: 'sibling-survived' }),
1417
- ], ctx())
1418
- assert.deepEqual(overlay, { title: 'sibling-survived' })
1419
- assert.equal(warned, true, 'console.warn should fire for thrown hydrators')
1420
- } finally {
1421
- console.warn = originalWarn
1422
- }
1423
- })
1424
-
1425
- it('hydrator returning a non-object is skipped', async () => {
1426
- const overlay = await applyEditPageHydrators([
1427
- // @ts-expect-error — deliberately exercising the runtime guard
1428
- async () => 'not-an-object',
1429
- async () => ({ title: 'real-result' }),
1430
- ], ctx())
1431
- assert.deepEqual(overlay, { title: 'real-result' })
1432
- })
1433
-
1434
- it('hydrator receives current fill-pipeline values via ctx.currentValues', async () => {
1435
- let seen: Record<string, unknown> | undefined
1436
- await applyEditPageHydrators([
1437
- async (ctx) => { seen = ctx.currentValues; return null },
1438
- ], ctx({ title: 'DB-Title', body: 'DB-Body' }))
1439
- assert.deepEqual(seen, { title: 'DB-Title', body: 'DB-Body' })
1440
- })
1441
-
1442
- it('hydrator receives resource class + recordId in ctx', async () => {
1443
- let seenResource: unknown
1444
- let seenRecordId: unknown
1445
- await applyEditPageHydrators([
1446
- async (ctx) => { seenResource = ctx.resource; seenRecordId = ctx.recordId; return null },
1447
- ], ctx())
1448
- assert.equal(seenResource, Posts)
1449
- assert.equal(seenRecordId, '42')
1450
- })
1451
- })
1452
-
1453
- describe('Pilotiq.editPageHydrator builder method', () => {
1454
- it('stores hydrators on the config in registration order', () => {
1455
- const fn1 = async () => ({ a: 1 })
1456
- const fn2 = async () => ({ b: 2 })
1457
- const panel = Pilotiq.make('Admin')
1458
- .editPageHydrator(fn1)
1459
- .editPageHydrator(fn2)
1460
- assert.deepEqual(panel.getConfig().editPageHydrators, [fn1, fn2])
1461
- })
1462
-
1463
- it('absent when no hydrator registered', () => {
1464
- const panel = Pilotiq.make('Admin')
1465
- assert.equal(panel.getConfig().editPageHydrators, undefined)
1466
- })
1467
- })
1468
-
1469
- describe('panelInfo — recordCollab map (resource collab opt-in)', () => {
1470
- it('absent when no resource opts in', async () => {
1471
- class Posts extends Resource { static override label = 'Posts' }
1472
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
1473
- assert.equal((info as { recordCollab?: unknown }).recordCollab, undefined)
1474
- })
1475
-
1476
- it('emits an entry for each opted-in resource keyed by URL slug', async () => {
1477
- class Posts extends Resource {
1478
- static override label = 'Posts'
1479
- static override collab = true as const
1480
- }
1481
- class Users extends Resource {
1482
- static override label = 'Users'
1483
- // No collab — should NOT appear in the map.
1484
- }
1485
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts, Users]))
1486
- const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
1487
- assert.deepEqual(map, {
1488
- posts: { pages: ['edit'], presence: true },
1489
- })
1490
- })
1491
-
1492
- it('honors object form of static collab (pages + presence override defaults)', async () => {
1493
- class Posts extends Resource {
1494
- static override label = 'Posts'
1495
- static override collab = { pages: ['edit', 'view'] as const, presence: false }
1496
- }
1497
- const info = await panelInfo(Pilotiq.make('T').path('/admin').resources([Posts]))
1498
- const map = (info as { recordCollab?: Record<string, unknown> }).recordCollab
1499
- assert.deepEqual(map, {
1500
- posts: { pages: ['edit', 'view'], presence: false },
1501
- })
1502
- })
1503
- })
1504
-
1505
- describe('panelInfo — pageCollab map (custom-page collab opt-in)', () => {
1506
- it('absent when no page opts in', async () => {
1507
- class Analytics extends Page {
1508
- static override slug = 'analytics'
1509
- static override label = 'Analytics'
1510
- }
1511
- const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Analytics]))
1512
- assert.equal((info as { pageCollab?: unknown }).pageCollab, undefined)
1513
- })
1514
-
1515
- it('emits an entry per opted-in custom page keyed by URL slug', async () => {
1516
- class Settings extends Page {
1517
- static override slug = 'settings'
1518
- static override label = 'Settings'
1519
- static override collab = { room: 'settings-general' }
1520
- }
1521
- class Analytics extends Page {
1522
- static override slug = 'analytics'
1523
- static override label = 'Analytics'
1524
- // No collab — should NOT appear in the map.
1525
- }
1526
- const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings, Analytics]))
1527
- const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1528
- assert.deepEqual(map, {
1529
- settings: { room: 'settings-general', presence: true },
1530
- })
1531
- })
1532
-
1533
- it('object form can suppress presence', async () => {
1534
- class Settings extends Page {
1535
- static override slug = 'settings'
1536
- static override label = 'Settings'
1537
- static override collab = { room: 'settings', presence: false }
1538
- }
1539
- const info = await panelInfo(Pilotiq.make('T').path('/admin').pages([Settings]))
1540
- const map = (info as { pageCollab?: Record<string, unknown> }).pageCollab
1541
- assert.deepEqual(map, {
1542
- settings: { room: 'settings', presence: false },
1543
- })
1544
- })
1545
- })