@pilotiq/pilotiq 0.23.1 → 0.24.2

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