@pilotiq/pilotiq 0.7.1 → 0.8.0

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 (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +154 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +31 -10
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +31 -9
package/src/pageData.ts CHANGED
@@ -9,4742 +9,133 @@
9
9
  * data needs to come from the same builder. Routing both paths through a
10
10
  * single builder keeps them in sync.
11
11
  */
12
- import type { Pilotiq, PilotiqConfig } from './Pilotiq.js'
13
12
  import { PilotiqRegistry } from './PilotiqRegistry.js'
14
- import type { Page } from './Page.js'
15
- import type { ResourceClass, NavigationBadgeColor } from './Resource.js'
16
- import type { GlobalClass } from './Global.js'
17
- import { resourceBasePath, globalBasePath, pageBasePath, clusterBasePath } from './clusterPaths.js'
18
- import type { ClusterClass } from './Cluster.js'
19
- import { Element, type ElementMeta } from './schema/Element.js'
20
- import { Field } from './fields/Field.js'
21
- import { resolveSchema, type RenderContext, type SchemaContext } from './schema/resolveSchema.js'
22
- import { isServerDataElement, type ServerDataElement } from './schema/ServerDataElement.js'
23
- import { Form } from './elements/Form.js'
24
- import { Table } from './elements/Table.js'
25
- import { Column } from './Column.js'
26
- import { applyStateUpdate, coerceFormValues, findForms, findWizardStep, loadRelationRows, selectFormById } from './elements/dispatchForm.js'
27
- import { isRepeaterField, RepeaterField } from './fields/RepeaterField.js'
28
- import { isBuilderField, BuilderField } from './fields/BuilderField.js'
29
- import { SelectField } from './fields/SelectField.js'
30
- import { validateSchema } from './validation/index.js'
31
- import { searchAllResources, type GlobalSearchResult } from './search.js'
32
- import { loadTableRecords, findTables, type QueryParams } from './elements/dispatchTable.js'
33
- import { findActions, findRowExtraActions } from './elements/dispatchAction.js'
34
- import { Filter } from './filters/Filter.js'
35
- import { TrashedFilter } from './filters/TrashedFilter.js'
36
- import { ListTabs } from './elements/ListTabs.js'
37
- import { ListTab } from './Tab.js'
38
- import { resolveTheme } from './theme/resolve.js'
39
- import type { ThemeMeta } from './theme/types.js'
40
- import { consumeFlashedNotifications } from './notifications/flash.js'
41
- import {
42
- notificationChannel,
43
- NOTIFICATION_CREATED_EVENT,
44
- } from './notifications/broadcast.js'
45
- import { serializeIcon, type SerializedIcon, type IconValue } from './icons/types.js'
46
- import {
47
- RIGHT_PANEL_DEFAULT_WIDTH,
48
- RIGHT_PANEL_MIN_WIDTH,
49
- RIGHT_PANEL_MAX_WIDTH,
50
- } from './RightPanel.js'
51
- import type { UserMenuItemMeta } from './UserMenuItem.js'
52
- import {
53
- RelationManager,
54
- safeManagerPolicy as safeManagerPolicyImpl,
55
- type ManagerCanMethod as ManagerCanMethodType,
56
- type RelationManagerContext,
57
- } from './RelationManager.js'
58
- import { RelationTabs, relationTab, type RelationTabMeta } from './schema/RelationTabs.js'
59
- import { Breadcrumbs, type BreadcrumbItem } from './schema/Breadcrumbs.js'
60
- import {
61
- resolveRenderHooks,
62
- CHROME_HOOK_NAMES,
63
- type RenderHookContext,
64
- type RenderHookMap,
65
- type RenderHookName,
66
- } from './RenderHook.js'
67
- import { applyPageHooks, pageHooksFor, type PageRole } from './applyPageHooks.js'
68
- import {
69
- modelSave, modelLoadRecord, modelRelationTableRecords, findRecord, getPrimaryKey,
70
- getRelationType,
71
- getMorphRelationDescriptor,
72
- type ModelLike, type ModelQuery,
73
- } from './orm/modelDefaults.js'
74
- import { normalizeRelationMode, type RelationMode } from './RelationManager.js'
75
-
76
- // ─── Shared helpers ──────────────────────────────────────────
77
-
78
- /**
79
- * Top-right user dropdown shipped to the renderer in `viewProps.panel`.
80
- * `null` when no `Pilotiq.user(req => …)` resolver is configured or the
81
- * resolver returns `null` (no logged-in user) — the renderer suppresses
82
- * the dropdown entirely in that case.
83
- *
84
- * `user.name / user.email / user.avatar` are duck-typed off the
85
- * resolver's return value; whichever fields are present round-trip into
86
- * the dropdown trigger (initials fall back to the first two letters of
87
- * `name` when no avatar URL is set).
88
- */
89
- export interface UserMenuMeta {
90
- user: { name?: string; email?: string; avatar?: string }
91
- items: UserMenuItemMeta[]
92
- signOut?: { url: string; label: string; method: 'POST' | 'GET' }
93
- }
94
-
95
- /**
96
- * Bell-icon dropdown configuration shipped under `viewProps.panel`. Sparse —
97
- * absent when `Pilotiq.databaseNotifications()` wasn't called OR when no
98
- * user resolves (anonymous request → no inbox to surface). Renderer mounts
99
- * the bell only when this is set.
100
- *
101
- * Routes are absolute URLs (panel `basePath` already applied). Client
102
- * substitutes `:id` per row when calling read / unread; `_widget`-style
103
- * params aren't used here because the bell only ever issues these four
104
- * fetch shapes.
105
- *
106
- * `polling` mirrors `DatabaseNotificationsConfig.polling` — `null` ships
107
- * over the wire to disable client-side polling. The bell still fetches on
108
- * mount + after every mark-read mutation.
109
- */
110
- export interface DatabaseNotificationsMeta {
111
- position: 'topbar' | 'sidebar'
112
- polling: number | null
113
- pageSize: number
114
- badgeColor: NavigationBadgeColor
115
- trigger?: { icon?: string; label?: string }
116
- listUrl: string
117
- readAllUrl: string
118
- /** Template URL with literal `:id` placeholder. Client replaces. */
119
- readUrl: string
120
- /** Template URL with literal `:id` placeholder. Client replaces. */
121
- unreadUrl: string
122
- /**
123
- * Template URL for the notification-action dispatch endpoint with
124
- * literal `:id` and `:actionName` placeholders. Bell client builds
125
- * per-action URLs by substituting both at render time. Used only by
126
- * `handler`-mode actions; `url` / `post` actions ride their own URL
127
- * verbatim.
128
- */
129
- actionUrl: string
130
- /**
131
- * Phase 2 — broadcast hint. Sparse — absent when
132
- * `databaseNotifications({ broadcast: true })` wasn't set OR when no
133
- * resolved user has an `id` to scope the channel to.
134
- *
135
- * Client connects to `wsUrl` via `@rudderjs/broadcast`'s
136
- * `RudderSocket`, subscribes to the `channel` (already includes the
137
- * `private-` prefix), and listens for `event` to trigger refetches.
138
- */
139
- broadcast?: {
140
- wsUrl: string
141
- channel: string
142
- event: string
143
- }
144
- }
145
-
146
- /**
147
- * Right-sidebar shipped under `viewProps.panel.rightSidebar`. Sparse —
148
- * absent from `panelInfo()` when no contributions are registered, every
149
- * registered contribution failed `canAccess(user)`, or every visible
150
- * contribution is `hidden: true`. Renderer mounts the chrome only when
151
- * this is set.
152
- *
153
- * The React component reference for each contribution does NOT travel
154
- * here — only its tab-strip metadata. The actual body component is
155
- * resolved client-side from the Vite plugin's `_components.ts` manifest
156
- * keyed by contribution `id`, mirroring the icon-class round-trip.
157
- *
158
- * `defaultWidth` rolls up: contribution-level value when one
159
- * contribution was registered with one, otherwise the panel-level
160
- * baseline (`RIGHT_PANEL_DEFAULT_WIDTH`). Client also clamps
161
- * localStorage values to `[minWidth, maxWidth]`.
162
- */
163
- export interface RightPanelMeta {
164
- id: string
165
- label: string
166
- icon?: SerializedIcon
167
- defaultWidth: number
168
- }
169
-
170
- export interface RightSidebarMeta {
171
- panels: RightPanelMeta[]
172
- defaultWidth: number
173
- minWidth: number
174
- maxWidth: number
175
- }
176
-
177
- /**
178
- * Single nav-tree entry. `name` is the JS class name (`R.name` /
179
- * `G.name` / `P.name`) — also the lookup key into the build-time
180
- * `_components.ts` manifest the Vite plugin emits, so component-typed
181
- * icons resolve from the same identifier.
182
- */
183
- export interface NavItem {
184
- name: string
185
- label: string
186
- url: string
187
- icon?: SerializedIcon
188
- group?: string
189
- sort?: number
190
- badge?: string
191
- badgeColor?: NavigationBadgeColor
192
- children?: NavItem[]
193
- }
194
-
195
- /**
196
- * Build the panel header summary + the unified navigation tree.
197
- *
198
- * Pipeline:
199
- * 1. flatten resources + globals + pages into raw NavItem records
200
- * 2. drop items whose `canAccess(user)` (Plan #10) returns false
201
- * 3. resolve `navigationParentItem` references → nest under parents
202
- * (cycles broken with a console warn; dangling parents render at top level)
203
- * 4. sort within each grouping (top-level *and* every parent's children)
204
- * by `navigationSort` ascending → registration order
205
- * 5. resolve every `navigationBadge()` in parallel via `Promise.all`;
206
- * handler errors are swallowed (badge omitted) so a flaky count
207
- * never blanks the page
208
- *
209
- * `req` is the active request; pilotiq calls `pilotiq.resolveUser(req)`
210
- * once and threads the user into every Resource/Global/Page `canAccess`
211
- * check. When `Pilotiq.user(fn)` isn't configured, user is `null` and the
212
- * default `canAccess` returns true → no items dropped.
213
- */
214
- /**
215
- * Optional route-context for `panelInfo()`. When set, render-hook
216
- * `scope: { resource | page | global }` filters fire correctly for the
217
- * active route. Missing keys mean the slot has no scope-able identifier
218
- * (chrome-only routes); scope-less hooks still fire either way.
219
- *
220
- * `url` defaults to `cfg.path` when unset. `recordId` rides through to
221
- * `RenderHookContext.recordId` for hooks that need it.
222
- */
223
- export interface PanelInfoRoute {
224
- resource?: ResourceClass
225
- page?: typeof Page
226
- global?: GlobalClass
227
- recordId?: string
228
- url?: string
229
- }
230
-
231
- export async function panelInfo(
232
- pilotiq: Pilotiq,
233
- req?: unknown,
234
- route: PanelInfoRoute = {},
235
- ) {
236
- const cfg = pilotiq.getConfig()
237
- const merged = pilotiq.getMergedTheme()
238
- const theme: ThemeMeta | undefined = merged ? resolveTheme(merged) : undefined
239
- const user = await pilotiq.resolveUser(req)
240
- const [navigation, userMenu, renderHooks, rightSidebar] = await Promise.all([
241
- buildNavigation(pilotiq, user),
242
- buildUserMenu(pilotiq, user),
243
- resolveChromeHooks(pilotiq, user, route),
244
- buildRightSidebarMeta(cfg, user),
245
- ])
246
- const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
247
- // AI suggestion mode — sparse: omit when 'auto' (the default) so the
248
- // wire shape stays minimal for panels that don't opt into review mode.
249
- // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
250
- // this to decide whether to apply writes immediately or stage them as
251
- // PendingSuggestions for user approval.
252
- const aiSuggestionsMode = pilotiq.getAiSuggestionsMode()
253
- return {
254
- name: cfg.name,
255
- branding: cfg.branding,
256
- navigation,
257
- theme,
258
- themeEditor: cfg.themeEditor ?? false,
259
- ...(userMenu ? { userMenu } : {}),
260
- ...(databaseNotifications ? { databaseNotifications } : {}),
261
- ...(rightSidebar ? { rightSidebar } : {}),
262
- ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
263
- ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
264
- }
265
- }
266
-
267
- /**
268
- * Build the bell-icon meta. Returns `null` when:
269
- * - `Pilotiq.databaseNotifications()` was never called, OR
270
- * - no user resolves (no inbox to surface).
271
- *
272
- * Defaults follow Filament: 30s polling, 25 rows per page, primary
273
- * badge color, topbar position.
274
- */
275
- function buildDatabaseNotificationsMeta(
276
- cfg: Readonly<PilotiqConfig>,
277
- user: unknown,
278
- ): DatabaseNotificationsMeta | null {
279
- if (!cfg.databaseNotifications?.enabled) return null
280
- if (user === null || user === undefined) return null
281
-
282
- const dn = cfg.databaseNotifications
283
- const base = cfg.path
284
- const meta: DatabaseNotificationsMeta = {
285
- position: dn.position ?? 'topbar',
286
- polling: dn.polling === null ? null : (dn.polling ?? 30),
287
- pageSize: dn.pageSize ?? 25,
288
- badgeColor: dn.badgeColor ?? 'primary',
289
- listUrl: `${base}/_notifications`,
290
- readAllUrl: `${base}/_notifications/read-all`,
291
- readUrl: `${base}/_notifications/:id/read`,
292
- unreadUrl: `${base}/_notifications/:id/unread`,
293
- actionUrl: `${base}/_notifications/:id/_action/:actionName`,
294
- }
295
- if (dn.trigger) meta.trigger = { ...dn.trigger }
296
- // Phase 2 broadcast hint — only ship when broadcast is enabled AND the
297
- // resolved user has an `id` to scope the channel to. The client uses
298
- // `wsUrl` for the WebSocket connection and `channel` for the subscribe
299
- // call (the private- prefix is already baked in).
300
- if (dn.broadcast) {
301
- const userId = (user as { id?: unknown } | null | undefined)?.id
302
- if (userId !== undefined && userId !== null) {
303
- const wsUrl = typeof dn.broadcast === 'object' && dn.broadcast.wsUrl
304
- ? dn.broadcast.wsUrl
305
- : '' // empty = client falls back to same-origin /ws
306
- meta.broadcast = {
307
- wsUrl,
308
- channel: notificationChannel(String(userId)),
309
- event: NOTIFICATION_CREATED_EVENT,
310
- }
311
- }
312
- }
313
- return meta
314
- }
315
-
316
- /**
317
- * Build the right-sidebar meta from registered contributions. Returns
318
- * `null` when:
319
- *
320
- * - no contributions were registered, OR
321
- * - every contribution failed `canAccess(user)` (or its predicate
322
- * threw — fail-closed), OR
323
- * - every passing contribution is `hidden: true` (no tab-strip
324
- * surface to mount; programmatic-open consumers should ship at
325
- * least one visible tab).
326
- *
327
- * Visible contributions are sorted by `sort` ascending (default 100),
328
- * with registration order as a stable tiebreaker. Each entry's icon is
329
- * serialized through `serializeIcon` keyed on the contribution `id`
330
- * (Phase B's Vite plugin extends `_components.ts` to round-trip
331
- * component-typed icons under that key). `defaultWidth` rolls up:
332
- * panel-level baseline is `RIGHT_PANEL_DEFAULT_WIDTH`; per-contribution
333
- * overrides ride on `RightPanelMeta.defaultWidth`.
334
- *
335
- * Errors thrown by `canAccess` are swallowed (the contribution is
336
- * dropped + a single console warn is emitted) so a flaky predicate on
337
- * one pane never blanks the whole sidebar.
338
- */
339
- async function buildRightSidebarMeta(
340
- cfg: Readonly<PilotiqConfig>,
341
- user: unknown,
342
- ): Promise<RightSidebarMeta | null> {
343
- const list = cfg.rightPanels ?? []
344
- if (list.length === 0) return null
345
-
346
- const indexed = list.map((c, idx) => ({ c, idx }))
347
- const gated = await Promise.all(
348
- indexed.map(async ({ c, idx }) => {
349
- if (c.canAccess) {
350
- try {
351
- const ok = await c.canAccess(user)
352
- if (!ok) return null
353
- } catch (err) {
354
- // eslint-disable-next-line no-console
355
- console.warn(`[Pilotiq] rightPanel "${c.id}" canAccess threw — dropping`, err)
356
- return null
357
- }
358
- }
359
- return { c, idx }
360
- }),
361
- )
362
-
363
- const visible = gated
364
- .filter((x): x is { c: typeof list[number]; idx: number } => x !== null)
365
- .filter((x) => !x.c.hidden)
366
- .sort((a, b) => {
367
- const sa = a.c.sort ?? 100
368
- const sb = b.c.sort ?? 100
369
- if (sa !== sb) return sa - sb
370
- return a.idx - b.idx
371
- })
372
-
373
- if (visible.length === 0) return null
374
-
375
- const panels: RightPanelMeta[] = visible.map(({ c }) => {
376
- const meta: RightPanelMeta = {
377
- id: c.id,
378
- label: c.label ?? c.id,
379
- defaultWidth: c.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
380
- }
381
- if (c.icon !== undefined) {
382
- meta.icon = serializeIcon(c.icon, c.id)
383
- }
384
- return meta
385
- })
386
-
387
- return {
388
- panels,
389
- defaultWidth: panels[0]?.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
390
- minWidth: RIGHT_PANEL_MIN_WIDTH,
391
- maxWidth: RIGHT_PANEL_MAX_WIDTH,
392
- }
393
- }
394
-
395
- /**
396
- * Resolve every chrome render hook (body / topbar / sidebar / user-menu
397
- * / footer / head). Returns a sparse map — slots with no matching
398
- * registered entries are omitted so the wire payload stays minimal on
399
- * panels that don't use render hooks at all.
400
- */
401
- async function resolveChromeHooks(
402
- pilotiq: Pilotiq,
403
- user: unknown,
404
- route: PanelInfoRoute,
405
- ): Promise<RenderHookMap> {
406
- const cfg = pilotiq.getConfig()
407
- const entries = cfg.renderHooks ?? []
408
- if (entries.length === 0) return {}
409
- const ctx: RenderHookContext = {
410
- user,
411
- basePath: cfg.path,
412
- url: route.url ?? cfg.path,
413
- }
414
- if (route.resource !== undefined) ctx.resource = route.resource
415
- if (route.page !== undefined) ctx.page = route.page
416
- if (route.global !== undefined) ctx.global = route.global
417
- if (route.recordId !== undefined) ctx.recordId = route.recordId
418
- return resolveRenderHooks(entries, CHROME_HOOK_NAMES, ctx)
419
- }
420
-
421
- /**
422
- * Resolve a subset of page-role render hooks (e.g. `panels::page.start`
423
- * + the list-records / create-record / view-record / edit-record /
424
- * global-search slot families). Per-page-role data builders call this
425
- * after schema resolution and stamp the result on `viewProps.renderHooks`.
426
- *
427
- * `names` lets each builder declare exactly which slots it serves so a
428
- * list-page builder doesn't ship slots that only fire on the edit page.
429
- */
430
- /**
431
- * Per-builder one-shot — resolve the role's slot set + splice the
432
- * results into the resolved schema. Wraps the two steps a per-builder
433
- * data fn always does in lockstep:
434
- *
435
- * 1. `resolvePageHooks(pilotiq, user, pageHooksFor(role), route)`
436
- * 2. `applyPageHooks(schemaData, hooks, role)`
437
- *
438
- * Returns the wrapped `ElementMeta[]`. No-op when the panel has no
439
- * registered hooks. Pass through what you'd pass to `panelInfo()`'s
440
- * route arg — same shape.
441
- */
442
- export async function applyRoleHooks(
443
- pilotiq: Pilotiq,
444
- user: unknown,
445
- role: PageRole,
446
- schemaData: ElementMeta[],
447
- route: PanelInfoRoute = {},
448
- ): Promise<ElementMeta[]> {
449
- const cfg = pilotiq.getConfig()
450
- if (!cfg.renderHooks || cfg.renderHooks.length === 0) return schemaData
451
- const hooks = await resolvePageHooks(pilotiq, user, pageHooksFor(role), route)
452
- return applyPageHooks(schemaData, hooks, role)
453
- }
454
-
455
- export async function resolvePageHooks(
456
- pilotiq: Pilotiq,
457
- user: unknown,
458
- names: readonly RenderHookName[],
459
- route: PanelInfoRoute,
460
- ): Promise<RenderHookMap> {
461
- const cfg = pilotiq.getConfig()
462
- const entries = cfg.renderHooks ?? []
463
- if (entries.length === 0 || names.length === 0) return {}
464
- const ctx: RenderHookContext = {
465
- user,
466
- basePath: cfg.path,
467
- url: route.url ?? cfg.path,
468
- }
469
- if (route.resource !== undefined) ctx.resource = route.resource
470
- if (route.page !== undefined) ctx.page = route.page
471
- if (route.global !== undefined) ctx.global = route.global
472
- if (route.recordId !== undefined) ctx.recordId = route.recordId
473
- return resolveRenderHooks(entries, names, ctx)
474
- }
475
-
476
-
477
- /**
478
- * Build the top-right user-menu meta. Returns `null` when:
479
- * - `Pilotiq.user()` isn't configured, or
480
- * - the resolver returned `null` (anonymous request), or
481
- * - the user object has no extractable identity AND the panel
482
- * configured no items / no sign-out (nothing to render).
483
- *
484
- * Items resolve in parallel with their visibility predicates
485
- * (`UserMenuItem.visible`). Throwing predicates fail closed (item
486
- * dropped). Sort by `.sort(n)` ascending → registration order.
487
- */
488
- async function buildUserMenu(pilotiq: Pilotiq, user: unknown): Promise<UserMenuMeta | null> {
489
- if (user === null || user === undefined) return null
490
-
491
- const cfg = pilotiq.getConfig()
492
- const items = cfg.userMenuItems ?? []
493
- const ctx = { user }
494
-
495
- // Resolve every item in parallel. `null` returns mean "filtered by
496
- // visibility predicate" — drop them. Indexed pre-sort so stable ties
497
- // resolve to registration order.
498
- const resolved = await Promise.all(
499
- items.map(async (item, idx) => {
500
- try {
501
- const meta = await item.resolve(ctx)
502
- return meta ? { meta, idx, sort: item.getSort() } : null
503
- } catch {
504
- return null
505
- }
506
- }),
507
- )
508
- const visibleItems = resolved
509
- .filter((x): x is { meta: UserMenuItemMeta; idx: number; sort: number | undefined } => x !== null)
510
- .sort((a, b) => {
511
- const aHas = a.sort !== undefined, bHas = b.sort !== undefined
512
- if (aHas && bHas) return a.sort! - b.sort! || a.idx - b.idx
513
- if (aHas) return -1
514
- if (bHas) return 1
515
- return a.idx - b.idx
516
- })
517
- .map(x => x.meta)
518
-
519
- // Auto-inject the profile entry from `cfg.profilePage` when set.
520
- // Prepended (Filament-style) so it always sits at the top of the
521
- // dropdown regardless of user-authored item ordering. Falls through
522
- // its own `canAccess(user)` so per-user gating works without the
523
- // user repeating the predicate at the menu level.
524
- const profileItem = await buildProfileMenuItem(cfg, user)
525
- const finalItems = profileItem ? [profileItem, ...visibleItems] : visibleItems
526
-
527
- const meta: UserMenuMeta = {
528
- user: extractUserIdentity(user),
529
- items: finalItems,
530
- }
531
- if (cfg.signOut) {
532
- meta.signOut = {
533
- url: cfg.signOut.url,
534
- label: cfg.signOut.label ?? 'Sign out',
535
- method: cfg.signOut.method ?? 'POST',
536
- }
537
- }
538
- return meta
539
- }
540
-
541
- /** Build the auto-injected profile entry from `cfg.profilePage`. The
542
- * Page's `static label` / `static icon` win; defaults `'Edit profile'`
543
- * + `'user-circle'` (registry-resolved). Returns `null` when no
544
- * profile page is configured or `Page.canAccess(user)` denies. */
545
- async function buildProfileMenuItem(
546
- cfg: Readonly<PilotiqConfig>,
547
- user: unknown,
548
- ): Promise<UserMenuItemMeta | null> {
549
- const P = cfg.profilePage
550
- if (!P) return null
551
- if (!(await safeAccess(() => P.canAccess(user)))) return null
552
- const url = pageBasePath(cfg.path, P)
553
- const icon = serializeIcon(P.icon ?? 'user-circle', P.name)
554
- const meta: UserMenuItemMeta = {
555
- name: '__profile',
556
- label: P.label ?? 'Edit profile',
557
- url,
558
- }
559
- if (icon !== undefined) meta.icon = icon
560
- return meta
561
- }
562
-
563
- /** Duck-type the user object for display fields. We never throw — a
564
- * user resolver might return literally anything (a primitive, a class
565
- * instance with getters, a plain object) and the dropdown should
566
- * degrade gracefully (initials fallback to '?' when no name found). */
567
- function extractUserIdentity(user: unknown): { name?: string; email?: string; avatar?: string } {
568
- if (user === null || user === undefined) return {}
569
- if (typeof user !== 'object') return { name: String(user) }
570
- const obj = user as Record<string, unknown>
571
- const out: { name?: string; email?: string; avatar?: string } = {}
572
- const name = obj.name ?? obj.fullName ?? obj.displayName ?? obj.username
573
- if (typeof name === 'string' && name) out.name = name
574
- if (typeof obj.email === 'string' && obj.email) out.email = obj.email
575
- const avatar = obj.avatar ?? obj.avatarUrl ?? obj.image
576
- if (typeof avatar === 'string' && avatar) out.avatar = avatar
577
- return out
578
- }
579
-
580
- /** @internal Internal node before nesting; carries the registration index
581
- * so we can stable-sort by it as the tie-breaker. */
582
- interface RawNavItem extends NavItem {
583
- parent?: string
584
- /** Registration index across resources → globals → pages (in that order),
585
- * so resources beat globals on a sort tie within the same group. */
586
- _idx: number
587
- }
588
-
589
- /** Run a `canAccess` check, swallowing throws as `false`. Used by
590
- * `buildNavigation` to fail-closed on flaky auth predicates without
591
- * blanking the page. */
592
- async function safeAccess(fn: () => boolean | Promise<boolean>): Promise<boolean> {
593
- try {
594
- return Boolean(await fn())
595
- } catch {
596
- return false
597
- }
598
- }
599
-
600
- /** Plan #10 — stamp the resolved user onto a SchemaContext so action
601
- * visibility predicates can see it during `resolveSchema`. The `user`
602
- * field is opaque (whatever `Pilotiq.user(req => …)` returns); skipped
603
- * when null/undefined to keep ctx tidy. */
604
- function userCtx<C extends SchemaContext>(ctx: C, user: unknown): C {
605
- if (user === null || user === undefined) return ctx
606
- return { ...ctx, user: user as NonNullable<SchemaContext['user']> }
607
- }
608
-
609
- /** Plan #6 — stamp the panel-wide upload URL so `FileUpload` fields
610
- * emit it on their meta. Single URL for the whole panel; no per-field
611
- * variation. The route is always registered (see `_uploads` in
612
- * `routes.ts`) — meta is stamped regardless of whether an adapter is
613
- * configured so the renderer can show a clear error rather than
614
- * silently breaking. The companion `hasUploadAdapter` flag distinguishes
615
- * "URL exists but adapter missing" so fields with optional upload
616
- * affordances (e.g. `MarkdownField`'s `attachFiles` button) can hide
617
- * themselves rather than render a broken control. */
618
- function uploadCtx<C extends SchemaContext>(ctx: C, cfg: PilotiqConfig): C {
619
- return {
620
- ...ctx,
621
- uploadUrl: `${cfg.path}/_uploads`,
622
- ...(cfg.uploads ? { hasUploadAdapter: true } : {}),
623
- }
624
- }
625
-
626
- async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<NavItem[]> {
627
- const cfg = pilotiq.getConfig()
628
- const base = cfg.path
629
-
630
- // Flatten + resolve badges in parallel. We build the raw list first so
631
- // every entry has its identity (`name`) and parent set; badges resolve
632
- // alongside.
633
- const raw: RawNavItem[] = []
634
- let idx = 0
635
-
636
- const pushBadge: Array<{ item: RawNavItem; handler: () => unknown }> = []
637
-
638
- // Plan #10 — pre-evaluate canAccess for every owner in parallel so we
639
- // can drop forbidden items before flattening. Failed predicates fail
640
- // closed (treated as `false`) so a thrown auth check doesn't accidentally
641
- // expose nav items. Clusters compose: a child gated through its
642
- // cluster's `canAccess` returning false drops the child even when the
643
- // child's own predicate would have passed.
644
- const [resourceAccess, globalAccess, pageAccess, clusterAccess] = await Promise.all([
645
- Promise.all(cfg.resources.map(R => safeAccess(() => R.canAccess(user)))),
646
- Promise.all(cfg.globals.map(G => safeAccess(() => G.canAccess(user)))),
647
- Promise.all(cfg.pages.map(P => safeAccess(() => P.canAccess(user)))),
648
- Promise.all(cfg.clusters.map(C => safeAccess(() => C.canAccess(user)))),
649
- ])
650
-
651
- // Identity-keyed so two clusters that happen to share a `.name`
652
- // (minifier collisions, hot-reload duplicate imports) don't clobber.
653
- const clusterAccessByClass = new Map<ClusterClass, boolean>()
654
- cfg.clusters.forEach((C, i) => clusterAccessByClass.set(C, !!clusterAccess[i]))
655
-
656
- const firstChildUrlByCluster = new Map<ClusterClass, string>()
657
- const recordChildUrl = (cluster: ClusterClass, url: string) => {
658
- if (!firstChildUrlByCluster.has(cluster)) firstChildUrlByCluster.set(cluster, url)
659
- }
660
-
661
- for (let i = 0; i < cfg.resources.length; i++) {
662
- const R = cfg.resources[i]!
663
- if (!resourceAccess[i]) continue
664
- if (R.cluster && !clusterAccessByClass.get(R.cluster)) continue
665
- const url = resourceBasePath(base, R)
666
- if (R.cluster) recordChildUrl(R.cluster, url)
667
- const item: RawNavItem = {
668
- name: R.name,
669
- label: R.getNavigationLabel(),
670
- url,
671
- icon: serializeIcon(R.getNavigationIcon(), R.name),
672
- _idx: idx++,
673
- }
674
- if (R.navigationGroup !== undefined) item.group = R.navigationGroup
675
- if (R.navigationSort !== undefined) item.sort = R.navigationSort
676
- // Cluster nesting wins over `navigationParentItem`. Both being set
677
- // is a misconfiguration; cluster placement is the structural one.
678
- if (R.cluster) item.parent = R.cluster.name
679
- else if (R.navigationParentItem !== undefined) item.parent = R.navigationParentItem
680
- if (R.navigationBadgeColor !== 'default') item.badgeColor = R.navigationBadgeColor
681
- if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge })
682
- raw.push(item)
683
- }
684
-
685
- for (let i = 0; i < cfg.globals.length; i++) {
686
- if (!globalAccess[i]) continue
687
- const G = cfg.globals[i]!
688
- if (G.cluster && !clusterAccessByClass.get(G.cluster)) continue
689
- // Globals default `navigationGroup` to `'Settings'`. Allow `null` as
690
- // an explicit opt-out → render at top level.
691
- const group = G.navigationGroup === null ? undefined : G.navigationGroup
692
- const url = globalBasePath(base, G)
693
- if (G.cluster) recordChildUrl(G.cluster, url)
694
- const item: RawNavItem = {
695
- name: G.name,
696
- label: G.getNavigationLabel(),
697
- url,
698
- icon: serializeIcon(G.getNavigationIcon(), G.name),
699
- _idx: idx++,
700
- }
701
- if (group !== undefined) item.group = group
702
- if (G.navigationSort !== undefined) item.sort = G.navigationSort
703
- if (G.cluster) item.parent = G.cluster.name
704
- else if (G.navigationParentItem !== undefined) item.parent = G.navigationParentItem
705
- if (G.navigationBadgeColor !== 'default') item.badgeColor = G.navigationBadgeColor
706
- if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge })
707
- raw.push(item)
708
- }
709
-
710
- for (let i = 0; i < cfg.pages.length; i++) {
711
- if (!pageAccess[i]) continue
712
- const P = cfg.pages[i]!
713
- if (P.cluster && !clusterAccessByClass.get(P.cluster)) continue
714
- // The dashboard page collapses its nav URL to `${base}` so the
715
- // sidebar entry deep-links to the panel root rather than
716
- // `${base}/${P.getSlug()}` (which would 404 — the slug route skips
717
- // the dashboard page at boot).
718
- const isDashboard = cfg.dashboardPage === P
719
- const url = isDashboard ? base : pageBasePath(base, P)
720
- if (P.cluster && !isDashboard) recordChildUrl(P.cluster, url)
721
- const item: RawNavItem = {
722
- name: P.name,
723
- label: P.getNavigationLabel(),
724
- url,
725
- icon: serializeIcon(P.getNavigationIcon(), P.name),
726
- _idx: idx++,
727
- }
728
- if (P.navigationGroup !== undefined) item.group = P.navigationGroup
729
- if (P.navigationSort !== undefined) item.sort = P.navigationSort
730
- if (P.cluster && !isDashboard) item.parent = P.cluster.name
731
- else if (P.navigationParentItem !== undefined) item.parent = P.navigationParentItem
732
- if (P.navigationBadgeColor !== 'default') item.badgeColor = P.navigationBadgeColor
733
- if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge })
734
- raw.push(item)
735
- }
736
-
737
- // Clusters render as first-class nav items. Each gets a URL pointing
738
- // at its `landingPage` (when set + accessible) or its first accessible
739
- // child. Clusters whose every child was gated out are dropped silently
740
- // — same posture as `navigationParentItem` with no resolvable parent.
741
- for (let i = 0; i < cfg.clusters.length; i++) {
742
- if (!clusterAccess[i]) continue
743
- const C = cfg.clusters[i]!
744
- let url: string | undefined
745
- if (C.landingPage) {
746
- const lpIdx = cfg.pages.indexOf(C.landingPage)
747
- if (lpIdx !== -1 && pageAccess[lpIdx]) {
748
- url = cfg.dashboardPage === C.landingPage ? base : pageBasePath(base, C.landingPage)
749
- }
750
- }
751
- if (url === undefined) url = firstChildUrlByCluster.get(C)
752
- if (url === undefined) continue // empty cluster — drop entirely
753
- const item: RawNavItem = {
754
- name: C.name,
755
- label: C.getNavigationLabel(),
756
- url,
757
- icon: serializeIcon(C.getNavigationIcon(), C.name),
758
- _idx: idx++,
759
- }
760
- if (C.navigationGroup !== undefined) item.group = C.navigationGroup
761
- if (C.navigationSort !== undefined) item.sort = C.navigationSort
762
- if (C.navigationParentItem !== undefined) item.parent = C.navigationParentItem
763
- if (C.navigationBadgeColor !== 'default') item.badgeColor = C.navigationBadgeColor
764
- if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge })
765
- raw.push(item)
766
- }
767
-
768
- await Promise.all(pushBadge.map(async ({ item, handler }) => {
769
- try {
770
- const v = await handler()
771
- if (v === undefined || v === null) return
772
- item.badge = String(v)
773
- } catch {
774
- // Per-badge errors stay silent.
775
- }
776
- }))
777
-
778
- return nestAndSort(raw)
779
- }
780
-
781
- /**
782
- * Resolve `parent` references → nest, drop cycles, sort within each
783
- * grouping, then strip internal scaffolding (`parent`, `_idx`).
784
- */
785
- function nestAndSort(raw: RawNavItem[]): NavItem[] {
786
- const byName = new Map<string, RawNavItem>()
787
- for (const it of raw) byName.set(it.name, it)
788
-
789
- // Detect parent cycles: walk upwards from each item; any name seen
790
- // twice → cycle. Items in a cycle get treated as top-level.
791
- const inCycle = new Set<string>()
792
- for (const it of raw) {
793
- if (it.parent === undefined) continue
794
- const seen = new Set<string>([it.name])
795
- let cur: string | undefined = it.parent
796
- while (cur !== undefined) {
797
- if (seen.has(cur)) {
798
- if (typeof console !== 'undefined' && typeof console.warn === 'function') {
799
- console.warn(`[Pilotiq] navigationParentItem cycle detected at "${it.name}" — rendering at top level.`)
800
- }
801
- inCycle.add(it.name)
802
- break
803
- }
804
- seen.add(cur)
805
- const parent = byName.get(cur)
806
- if (!parent) break
807
- cur = parent.parent
808
- }
809
- }
810
-
811
- const childrenOf = new Map<string, RawNavItem[]>()
812
- const top: RawNavItem[] = []
813
- for (const it of raw) {
814
- const parent = it.parent
815
- if (parent && byName.has(parent) && !inCycle.has(it.name)) {
816
- const list = childrenOf.get(parent) ?? []
817
- list.push(it)
818
- childrenOf.set(parent, list)
819
- } else {
820
- top.push(it)
821
- }
822
- }
823
-
824
- // Sort items in a sibling group by sort (asc), ties → registration order.
825
- const sortItems = (items: RawNavItem[]): RawNavItem[] => {
826
- return [...items].sort((a, b) => {
827
- const aHas = a.sort !== undefined, bHas = b.sort !== undefined
828
- if (aHas && bHas) return a.sort! - b.sort! || a._idx - b._idx
829
- if (aHas) return -1 // sorted items come before unsorted
830
- if (bHas) return 1
831
- return a._idx - b._idx
832
- })
833
- }
834
-
835
- // Strip internals + recurse into children.
836
- const finalize = (items: RawNavItem[]): NavItem[] =>
837
- sortItems(items).map(it => {
838
- const kids = childrenOf.get(it.name)
839
- const { parent, _idx, ...rest } = it
840
- const out: NavItem = { ...rest }
841
- if (kids && kids.length > 0) out.children = finalize(kids)
842
- return out
843
- })
844
-
845
- return finalize(top)
846
- }
847
-
848
- export async function callPageSchema(PageClass: typeof Page, ctx: SchemaContext): Promise<Element[]> {
849
- return Promise.resolve(PageClass.schema(ctx))
850
- }
851
-
852
- /** Mark every Form on the page with its action URL so the rendered <form> posts to itself. */
853
- export function tagFormActions(elements: ReadonlyArray<Element>, action: string): void {
854
- for (const form of findForms(elements)) {
855
- if (!form.getAction()) form.action(action)
856
- }
857
- }
858
-
859
- /**
860
- * Plan #5 — stamp the partial-resolve endpoint URL on every form whose
861
- * descendants include at least one `live()` field. The client uses
862
- * `FormMeta.stateUrl` to flip into controlled-state mode; forms without
863
- * any live fields stay uncontrolled (zero-cost legacy path).
864
- *
865
- * `urlBuilder(formId)` lets the caller compose a per-form URL — the
866
- * endpoint shape is `${base}/${slug}/_form/${formId}/state` so each
867
- * form on a multi-form page gets its own route segment.
868
- */
869
- export function tagFormStateUrls(
870
- elements: ReadonlyArray<Element>,
871
- urlBuilder: (formId: string) => string,
872
- ): void {
873
- for (const form of findForms(elements)) {
874
- if (formHasLiveField(form)) {
875
- form.withStateUrl(urlBuilder(form.getFormId()))
876
- }
877
- }
878
- }
879
-
880
- /**
881
- * Reorderable rows — stamp the POST-reorder URL on every `Table` that
882
- * has `Table.reorderable()` set. The renderer reads `TableMeta.reorderUrl`
883
- * to wire the drop handler; tables that aren't reorderable skip wiring
884
- * entirely. Same shape as `tagFormStateUrls` so the call site stays
885
- * consistent.
886
- */
887
- export function tagTableReorderUrls(
888
- elements: ReadonlyArray<Element>,
889
- url: string,
890
- ): void {
891
- for (const table of findTables(elements)) {
892
- if (table.isReorderable() && !table.getReorderUrl()) {
893
- table.withReorderUrl(url)
894
- }
895
- }
896
- }
897
-
898
- // Marks every Table on the page deferred and stamps the URL the
899
- // renderer will fetch from after mount. Must run BEFORE `loadTableRecords`
900
- // so the records handler short-circuits.
901
- export function tagTableDeferred(
902
- elements: ReadonlyArray<Element>,
903
- url: string,
904
- ): void {
905
- for (const table of findTables(elements)) {
906
- table.withDeferred(true)
907
- table.withTableUrl(url)
908
- }
909
- }
910
-
911
- /**
912
- * Editable cell columns — walk every table on the page and stamp
913
- * `_cellEditUrls[colName]` per row, but only on rows that already
914
- * carry a `_cellEditable[colName]` marker (set by `loadTableRecords`
915
- * after `R.canEdit(user, row)` passed). The dispatcher stays
916
- * URL-shape-agnostic; URL building lives here parallel to
917
- * `tagFormStateUrls / tagTableReorderUrls`.
918
- *
919
- * `idOf` extracts the per-row primary key. Defaults to reading `id` —
920
- * works for the rudder ORM convention. Resources with a different
921
- * primary-key column should pass an override (none in v1).
922
- */
923
- export function tagCellEditUrls(
924
- elements: ReadonlyArray<Element>,
925
- resourceUrl: string,
926
- idOf: (row: Record<string, unknown>) => unknown = row => row['id'],
927
- ): void {
928
- for (const table of findTables(elements)) {
929
- const rows = table.getRows() as ReadonlyArray<Record<string, unknown>> | undefined
930
- if (!rows || rows.length === 0) continue
931
- // Optimisation: skip the table when none of its columns are editable.
932
- const editable = (table.getChildren() ?? []).some(c => c instanceof Column && c.isEditable())
933
- if (!editable) continue
934
- for (const row of rows) {
935
- const editableMap = row['_cellEditable'] as Record<string, true> | undefined
936
- if (!editableMap) continue
937
- const id = idOf(row)
938
- if (id === undefined || id === null || id === '') continue
939
- const urls: Record<string, string> = {}
940
- for (const colName of Object.keys(editableMap)) {
941
- urls[colName] = `${resourceUrl}/${encodeURIComponent(String(id))}/_cell/${encodeURIComponent(colName)}`
942
- }
943
- ;(row as Record<string, unknown>)['_cellEditUrls'] = urls
944
- }
945
- }
946
- }
947
-
948
- /**
949
- * Plan #8 — stamp the wizard step-validate endpoint URL on every form
950
- * whose descendants include a `Wizard` element. `FormMeta.wizardUrl` is
951
- * what the client posts to on Next-button clicks; forms without a wizard
952
- * descendant skip wiring.
953
- */
954
- export function tagFormWizardUrls(
955
- elements: ReadonlyArray<Element>,
956
- urlBuilder: (formId: string) => string,
957
- ): void {
958
- for (const form of findForms(elements)) {
959
- if (formHasWizard(form)) {
960
- form.withWizardUrl(urlBuilder(form.getFormId()))
961
- }
962
- }
963
- }
964
-
965
- /**
966
- * Stamp `_agentRunBase` on every field element in the resolved
967
- * `ElementMeta[]` tree that carries `aiActions`. Operates on the
968
- * post-`resolveSchema` wire shape (plain objects) rather than on
969
- * `Element` instances — `aiActions` is added by the `field-ai.ts`
970
- * wrapper during `toMeta()`, so it isn't visible to pre-resolve walkers.
971
- *
972
- * Only called on edit pages where a `recordId` is known. Create pages
973
- * deliberately skip it — field AI actions target existing content.
974
- */
975
- export function tagFieldAiUrls(
976
- elements: ReadonlyArray<Record<string, unknown>>,
977
- agentBase: string,
978
- ): void {
979
- for (const el of elements) {
980
- if (Array.isArray(el['aiActions']) && (el['aiActions'] as unknown[]).length > 0) {
981
- ;(el as Record<string, unknown>)['_agentRunBase'] = agentBase
982
- }
983
- const children = el['children']
984
- if (Array.isArray(children)) tagFieldAiUrls(children as Record<string, unknown>[], agentBase)
985
- // Repeater rows
986
- const rows = el['rows']
987
- if (Array.isArray(rows)) {
988
- for (const row of rows as Record<string, unknown>[]) {
989
- const rowChildren = row['children']
990
- if (Array.isArray(rowChildren)) tagFieldAiUrls(rowChildren as Record<string, unknown>[], agentBase)
991
- }
992
- }
993
- }
994
- }
995
-
996
- /**
997
- * Audit row 2026-05-07 cont'd⁸ — stamp the inline-create-option endpoint
998
- * URL on every `SelectField` that has called `createOptionForm()`. Walks
999
- * every form on the page so the URL carries the parent form's id; URL
1000
- * shape `${formScopeUrl}/_form/${formId}/create-option/${fieldName}` so
1001
- * the route handler can pick the form by id and the field by name.
1002
- *
1003
- * Mirrors `tagFormStateUrls / tagFormWizardUrls` — operates on the
1004
- * un-resolved Element tree, mutates field-instance state via
1005
- * `field.withCreateOptionUrl(url)`, and the field's `toMeta()` reads it
1006
- * back to emit `createOption.url`.
1007
- *
1008
- * Stops at Repeater / Builder boundaries (parallel to the form-state /
1009
- * wizard walkers): inside-row schemas are dispatched per-row and the
1010
- * createOption shape doesn't compose with row body coercion in v1.
1011
- */
1012
- export function tagSelectCreateOptionUrls(
1013
- elements: ReadonlyArray<Element>,
1014
- urlBuilder: (formId: string, fieldName: string) => string,
1015
- ): void {
1016
- for (const form of findForms(elements)) {
1017
- const formId = form.getFormId()
1018
- walkSelectFields(form.getChildren() as Element[] ?? [], (field) => {
1019
- if (field.hasCreateOption() && !field.getCreateOptionUrl()) {
1020
- field.withCreateOptionUrl(urlBuilder(formId, field.name))
1021
- }
1022
- })
1023
- }
1024
- }
1025
-
1026
- function walkSelectFields(elements: Element[], visit: (f: SelectField) => void): void {
1027
- for (const el of elements) {
1028
- if (el instanceof SelectField) {
1029
- visit(el)
1030
- // SelectField has no children of its own — no recursion needed.
1031
- continue
1032
- }
1033
- // Stop at row-array boundaries — see comment on `tagSelectCreateOptionUrls`.
1034
- if (el instanceof RepeaterField) continue
1035
- if (el instanceof BuilderField) continue
1036
- const children = el.getChildren()
1037
- if (children && children.length > 0) walkSelectFields(children as Element[], visit)
1038
- }
1039
- }
1040
-
1041
- /**
1042
- * Adapter-package async-resolve walker. Stamps the per-form mentions URL
1043
- * on every field that ducks like a "rich text with at least one async
1044
- * mention provider". The duck-typed contract lives here (as opposed to
1045
- * importing from `@pilotiq/tiptap`) so pilotiq core stays adapter-free —
1046
- * any future field type with an async-resolve trigger can satisfy the
1047
- * same shape and pick up URL stamping for free.
1048
- *
1049
- * Contract:
1050
- * - `getType() === 'richtext'` (fast filter)
1051
- * - `hasAsyncMentions(): boolean`
1052
- * - `withMentionsUrl(url: string): unknown`
1053
- *
1054
- * Walks every form on the page so the URL builder can mint a per-form
1055
- * URL (mirrors `tagFormStateUrls / tagFormWizardUrls`). The route handler
1056
- * uses formId in the URL to select the form; the body carries `field`
1057
- * + `trigger` + `query`. One URL per (form, scope), reused across every
1058
- * async-mention field on that form.
1059
- */
1060
- interface AsyncMentionFieldLike {
1061
- hasAsyncMentions(): boolean
1062
- withMentionsUrl(url: string): unknown
1063
- }
1064
-
1065
- function isAsyncMentionField(el: Element): el is Element & AsyncMentionFieldLike {
1066
- if (el.getType() !== 'richtext') return false
1067
- const candidate = el as unknown as Partial<AsyncMentionFieldLike>
1068
- return typeof candidate.hasAsyncMentions === 'function'
1069
- && typeof candidate.withMentionsUrl === 'function'
1070
- }
1071
-
1072
- export function tagRichTextMentionUrls(
1073
- elements: ReadonlyArray<Element>,
1074
- urlBuilder: (formId: string) => string,
1075
- ): void {
1076
- for (const form of findForms(elements)) {
1077
- const url = urlBuilder(form.getFormId())
1078
- let stampedAny = false
1079
- const visit = (els: ReadonlyArray<Element>): void => {
1080
- for (const el of els) {
1081
- // Don't cross into nested forms — each form gets its own URL.
1082
- if (el !== form && el.getType() === 'form') continue
1083
- if (isAsyncMentionField(el) && el.hasAsyncMentions()) {
1084
- el.withMentionsUrl(url)
1085
- stampedAny = true
1086
- }
1087
- // Builder.getChildren() returns undefined to keep the field-level
1088
- // walkers from treating heterogeneous rows as flat children. Manual
1089
- // descent into each block's schema covers the URL-stamping path
1090
- // without changing the no-cross posture for save/coerce.
1091
- if (isBuilderField(el)) {
1092
- for (const block of (el as BuilderField).getBlocks()) visit(block.getSchema())
1093
- continue
1094
- }
1095
- const children = el.getChildren()
1096
- if (children) visit(children)
1097
- }
1098
- }
1099
- const children = form.getChildren()
1100
- if (children) visit(children)
1101
- void stampedAny // silence unused — kept locally for readability
1102
- }
1103
- }
1104
-
1105
- function formHasLiveField(form: Form): boolean {
1106
- let found = false
1107
- const visit = (els: ReadonlyArray<Element>): void => {
1108
- for (const el of els) {
1109
- if (found) return
1110
- // Either a server-side `live()` (drives a roundtrip) OR a
1111
- // client-side `afterStateUpdatedJs(body)` (JS-only) is enough to
1112
- // mount the controlled-form path: the FormStateProvider holds the
1113
- // values map either path needs, and the client gates the actual
1114
- // network POST on `live` separately. Cost of the over-stamp for
1115
- // JS-only forms is one unused endpoint URL per form — endpoint
1116
- // never gets hit because the client only POSTs on `live`.
1117
- if (el instanceof Field && (el.isLive() || el.getAfterStateUpdatedJs() !== undefined)) {
1118
- found = true
1119
- return
1120
- }
1121
- const children = el.getChildren()
1122
- if (children) visit(children)
1123
- }
1124
- }
1125
- const children = form.getChildren()
1126
- if (children) visit(children)
1127
- return found
1128
- }
1129
-
1130
- function formHasWizard(form: Form): boolean {
1131
- let found = false
1132
- const visit = (els: ReadonlyArray<Element>): void => {
1133
- for (const el of els) {
1134
- if (found) return
1135
- if (el.getType() === 'wizard') { found = true; return }
1136
- const children = el.getChildren()
1137
- if (children) visit(children)
1138
- }
1139
- }
1140
- const children = form.getChildren()
1141
- if (children) visit(children)
1142
- return found
1143
- }
1144
-
1145
- /**
1146
- * Run the edit-mode fill pipeline on a loaded record:
1147
- * mutateFormDataBeforeFill → fillFromRecord → mutateFormDataAfterFill
1148
- *
1149
- * `fillFromRecord` defaults to `{ ...record }` when not configured. Both
1150
- * mutators are optional and may be async. `ctx.record` is the loaded
1151
- * record so mutators can read from fields the form doesn't surface.
1152
- */
1153
- export async function applyFillPipeline<R>(
1154
- form: Form<R>,
1155
- record: R,
1156
- ): Promise<Record<string, unknown>> {
1157
- const recordObj = record as unknown as Record<string, unknown>
1158
- let values: Record<string, unknown> = { ...recordObj }
1159
-
1160
- const before = form.getMutateFormDataBeforeFill()
1161
- if (before) values = await before(values, { values, record })
1162
-
1163
- const fill = form.getFillFromRecord()
1164
- if (fill) values = fill(record)
1165
-
1166
- const after = form.getMutateFormDataAfterFill()
1167
- if (after) values = await after(values, { values, record })
1168
-
1169
- return values
1170
- }
1171
-
1172
- /**
1173
- * Walk the form's top-level Repeaters and replace `values[fieldName]`
1174
- * with rows fetched from `parent.related(name)` for any
1175
- * relationship-backed Repeater. Each loaded row stamps `__id` to the
1176
- * child's primary key so the renderer can round-trip identity through
1177
- * a hidden input and the save-side diff can match submitted rows back
1178
- * to existing records.
1179
- *
1180
- * No-op when the parent record is null (create mode), when no
1181
- * relationship-backed Repeaters exist on the form, or when the
1182
- * resource has no `R.model` (relation queries need it).
1183
- *
1184
- * Mutates and returns a fresh values object — never the input.
1185
- */
1186
- export async function applyRelationshipRepeaterFill(
1187
- form: Form,
1188
- values: Record<string, unknown>,
1189
- record: unknown,
1190
- parentModel: ModelLike | undefined,
1191
- ): Promise<Record<string, unknown>> {
1192
- if (record == null) return values
1193
- if (!parentModel) return values
1194
- const repeaters = findRelationshipRepeaters(form.getChildren() ?? [])
1195
- if (repeaters.length === 0) return values
1196
-
1197
- const out: Record<string, unknown> = { ...values }
1198
- for (const repeater of repeaters) {
1199
- const cfg = repeater.getRelationship()!
1200
- const pivotColumns = cfg.pivotColumns
1201
- let rows: unknown[]
1202
- try {
1203
- rows = await loadRelationRows(parentModel, record, cfg.name, pivotColumns)
1204
- } catch {
1205
- // Failed lookup (e.g. missing `relations` map on a test stub)
1206
- // — fall back to whatever value applyFillPipeline produced
1207
- // rather than wiping the field. Better to render stale data
1208
- // than to silently empty the row list.
1209
- continue
1210
- }
1211
-
1212
- // The child model is opaque here — we don't have the full
1213
- // descriptor at this seam, so use the configured override or
1214
- // peek the parent's relations map for the FK column. Strip it
1215
- // (and the PK) from each row's payload so the inner schema
1216
- // doesn't surface them as form values. For morphMany the
1217
- // attachment is two columns instead of one — strip both.
1218
- const pkColumn = pickChildPrimaryKey(parentModel, cfg.name) ?? 'id'
1219
- const fkColumn = cfg.foreignKey ?? pickChildForeignKey(parentModel, cfg.name)
1220
- const morph = getMorphRelationDescriptor(parentModel, cfg.name)
1221
- const morphIdCol = morph ? `${morph.morphName}Id` : undefined
1222
- const morphTyCol = morph ? `${morph.morphName}Type` : undefined
1223
-
1224
- out[repeater.name] = rows.map(row => {
1225
- const r = (row && typeof row === 'object') ? { ...(row as Record<string, unknown>) } : {}
1226
- const pkValue = r[pkColumn]
1227
- delete r[pkColumn]
1228
- if (fkColumn) delete r[fkColumn]
1229
- if (morphIdCol) delete r[morphIdCol]
1230
- if (morphTyCol) delete r[morphTyCol]
1231
- // M2M pivot extras — flatten `row.pivot[col]` onto the row's data
1232
- // so each pivot column round-trips through the inner schema as a
1233
- // regular form field. The pivot envelope itself is dropped from
1234
- // the values shape — the persist side splits pivot vs child
1235
- // columns by name lookup against `cfg.pivotColumns`.
1236
- const pivotEnvelope = r['pivot']
1237
- delete r['pivot']
1238
- const stamped: Record<string, unknown> = { ...r }
1239
- if (pivotColumns && pivotColumns.length > 0
1240
- && pivotEnvelope && typeof pivotEnvelope === 'object'
1241
- ) {
1242
- const pe = pivotEnvelope as Record<string, unknown>
1243
- for (const col of pivotColumns) {
1244
- if (col in pe) stamped[col] = pe[col]
1245
- }
1246
- }
1247
- if (pkValue !== undefined && pkValue !== null) {
1248
- stamped['__id'] = String(pkValue)
1249
- }
1250
- return stamped
1251
- })
1252
- }
1253
- return out
1254
- }
1255
-
1256
- /** Walk the form's children for top-level relationship-backed Repeaters. */
1257
- function findRelationshipRepeaters(elements: ReadonlyArray<Element>): RepeaterField[] {
1258
- const out: RepeaterField[] = []
1259
- const walk = (els: ReadonlyArray<Element>): void => {
1260
- for (const el of els) {
1261
- if (isRepeaterField(el)) {
1262
- const r = el as RepeaterField
1263
- if (r.getRelationship()) out.push(r)
1264
- // Don't dive into Repeater children — relationship-on-relationship
1265
- // isn't supported in v1.
1266
- continue
1267
- }
1268
- // Don't dive into Builder children either — relationship-backed
1269
- // Builders are resolved separately by `findRelationshipBuilders`.
1270
- if (isBuilderField(el)) continue
1271
- const children = el.getChildren()
1272
- if (children && children.length > 0) walk(children)
1273
- }
1274
- }
1275
- walk(elements)
1276
- return out
1277
- }
1278
-
1279
- /**
1280
- * Walk the form's top-level Builders and replace `values[fieldName]` with
1281
- * rows fetched from `parent.related(name)` for any relationship-backed
1282
- * Builder. Each loaded row stamps `__id` (child PK) + `type` (block
1283
- * discriminator) + `data` (per-block JSON payload) so the renderer can
1284
- * round-trip the heterogeneous envelope.
1285
- *
1286
- * Mirrors `applyRelationshipRepeaterFill`. No-op when the parent record
1287
- * is null (create mode), the resource has no `R.model`, or no
1288
- * relationship-backed Builders exist on the form.
1289
- */
1290
- export async function applyRelationshipBuilderFill(
1291
- form: Form,
1292
- values: Record<string, unknown>,
1293
- record: unknown,
1294
- parentModel: ModelLike | undefined,
1295
- ): Promise<Record<string, unknown>> {
1296
- if (record == null) return values
1297
- if (!parentModel) return values
1298
- const builders = findRelationshipBuilders(form.getChildren() ?? [])
1299
- if (builders.length === 0) return values
1300
-
1301
- const out: Record<string, unknown> = { ...values }
1302
- for (const builder of builders) {
1303
- const cfg = builder.getRelationship()!
1304
- let rows: unknown[]
1305
- try {
1306
- rows = await loadRelationRows(parentModel, record, cfg.name)
1307
- } catch {
1308
- // Failed lookup (e.g. missing `relations` map on a test stub) —
1309
- // fall back to whatever value applyFillPipeline produced rather
1310
- // than wiping the field. Better stale than silently empty.
1311
- continue
1312
- }
1313
-
1314
- const pkColumn = pickChildPrimaryKey(parentModel, cfg.name) ?? 'id'
1315
- const fkColumn = cfg.foreignKey ?? pickChildForeignKey(parentModel, cfg.name)
1316
- const typeColumn = cfg.typeColumn ?? 'type'
1317
- const dataColumn = cfg.dataColumn ?? 'data'
1318
-
1319
- out[builder.name] = rows.map(row => {
1320
- const r = (row && typeof row === 'object') ? { ...(row as Record<string, unknown>) } : {}
1321
- const pkValue = r[pkColumn]
1322
- const blockType = typeof r[typeColumn] === 'string' ? (r[typeColumn] as string) : ''
1323
- const dataRaw = r[dataColumn]
1324
- const blockData = parseBuilderDataPayload(dataRaw)
1325
-
1326
- const stamped: Record<string, unknown> = {
1327
- type: blockType,
1328
- data: blockData,
1329
- }
1330
- if (pkValue !== undefined && pkValue !== null) {
1331
- stamped['__id'] = String(pkValue)
1332
- }
1333
- // Non-`type` / `data` / FK / PK columns aren't surfaced — the
1334
- // JSON envelope is the source of truth for per-block fields. If
1335
- // a user denormalizes a column, they handle it via per-block
1336
- // mutate hooks, not by leaking the column into row values.
1337
- void fkColumn
1338
- return stamped
1339
- })
1340
- }
1341
- return out
1342
- }
1343
-
1344
- /**
1345
- * Normalize the JSON payload column into a plain object. Prisma
1346
- * hydrates `Json` columns to objects; some adapters return strings.
1347
- * Anything that isn't a parseable object falls back to `{}` so the
1348
- * inner schema renders fresh defaults.
1349
- */
1350
- function parseBuilderDataPayload(raw: unknown): Record<string, unknown> {
1351
- if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
1352
- return raw as Record<string, unknown>
1353
- }
1354
- if (typeof raw === 'string') {
1355
- try {
1356
- const parsed: unknown = JSON.parse(raw)
1357
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1358
- return parsed as Record<string, unknown>
1359
- }
1360
- } catch {
1361
- // fall through to {}
1362
- }
1363
- }
1364
- return {}
1365
- }
1366
-
1367
- /** Walk the form's children for top-level relationship-backed Builders. */
1368
- function findRelationshipBuilders(elements: ReadonlyArray<Element>): BuilderField[] {
1369
- const out: BuilderField[] = []
1370
- const walk = (els: ReadonlyArray<Element>): void => {
1371
- for (const el of els) {
1372
- if (isBuilderField(el)) {
1373
- const b = el as BuilderField
1374
- if (b.getRelationship()) out.push(b)
1375
- continue
1376
- }
1377
- // Don't dive into Repeater children either — both array-row
1378
- // boundaries are walker stops here.
1379
- if (isRepeaterField(el)) continue
1380
- const children = el.getChildren()
1381
- if (children && children.length > 0) walk(children)
1382
- }
1383
- }
1384
- walk(elements)
1385
- return out
1386
- }
1387
-
1388
- /** Read the child model's PK column from the parent's relations map, when present. */
1389
- function pickChildPrimaryKey(parentModel: ModelLike, name: string): string | undefined {
1390
- const relations = (parentModel as unknown as Record<string, unknown>)['relations']
1391
- if (!relations || typeof relations !== 'object') return undefined
1392
- const entry = (relations as Record<string, unknown>)[name]
1393
- if (!entry || typeof entry !== 'object') return undefined
1394
- const e = entry as Record<string, unknown>
1395
- if (typeof e['model'] !== 'function') return undefined
1396
- try {
1397
- const child = (e['model'] as () => ModelLike)()
1398
- return getPrimaryKey(child)
1399
- } catch {
1400
- return undefined
1401
- }
1402
- }
1403
-
1404
- /** Read the FK column from the parent's relations map, when present. */
1405
- function pickChildForeignKey(parentModel: ModelLike, name: string): string | undefined {
1406
- const relations = (parentModel as unknown as Record<string, unknown>)['relations']
1407
- if (!relations || typeof relations !== 'object') return undefined
1408
- const entry = (relations as Record<string, unknown>)[name]
1409
- if (!entry || typeof entry !== 'object') return undefined
1410
- const e = entry as Record<string, unknown>
1411
- return typeof e['foreignKey'] === 'string' ? (e['foreignKey'] as string) : undefined
1412
- }
1413
-
1414
- // ─── Plan #15 server-data widgets ─────────────────────────────
1415
-
1416
- /** Wire-shape of the per-widget data map shipped to the client.
1417
- * Lazy elements stamp `null` (renderer paints skeleton + fetches);
1418
- * eager elements stamp their resolved payload. Errors stamp
1419
- * `{ error: '<message>' }` so the renderer can surface a per-widget
1420
- * failure without blanking the page. */
1421
- export type ServerDataMap = Record<string, unknown>
1422
-
1423
- /**
1424
- * Plan #15 — collect every `ServerDataElement` in the schema tree and
1425
- * resolve their `getServerData(ctx)` payloads in parallel. Returns a
1426
- * map keyed by element id, ready to ship as `viewProps._widgetData`.
1427
- *
1428
- * Lazy elements (default — `lazy(false)` opts out) skip the hook and
1429
- * stamp `null` so the renderer paints a skeleton and fetches the
1430
- * payload via `POST {base}/_widget/:id` on mount. Eager elements
1431
- * resolve synchronously and ship the data with the page.
1432
- *
1433
- * Per-widget errors are caught and surfaced as `{ error: '...' }` —
1434
- * one flaky `getStats()` shouldn't 500 the entire dashboard.
1435
- *
1436
- * Visibility is **not** re-evaluated here. The schema resolver
1437
- * (`resolveSchema → evaluateVisibility`) drops hidden layout elements
1438
- * before any widget code runs. Widgets inside still-rendered branches
1439
- * always resolve (or stamp lazy null).
1440
- */
1441
- export async function resolveServerDataElements(
1442
- elements: ReadonlyArray<Element>,
1443
- ctx: RenderContext,
1444
- ): Promise<ServerDataMap> {
1445
- const widgets = collectServerDataElements(elements)
1446
- if (widgets.length === 0) return {}
1447
- const out: ServerDataMap = {}
1448
- await Promise.all(widgets.map(async (el) => {
1449
- const id = el.getId()
1450
- if (el.isLazy()) {
1451
- out[id] = null // sentinel — renderer paints skeleton, fetches on mount
1452
- return
1453
- }
1454
- try {
1455
- out[id] = await el.resolveServerData(ctx)
1456
- } catch (err) {
1457
- out[id] = { error: err instanceof Error ? err.message : 'Widget failed to load' }
1458
- }
1459
- }))
1460
- return out
1461
- }
1462
-
1463
- /** Walk the tree collecting every `ServerDataElement`. Walks into
1464
- * containers but stops at Form/Repeater/Builder boundaries — widgets
1465
- * inside an editable form don't make sense in v1. */
1466
- function collectServerDataElements(elements: ReadonlyArray<Element>): ServerDataElement[] {
1467
- const out: ServerDataElement[] = []
1468
- const walk = (els: ReadonlyArray<Element>): void => {
1469
- for (const el of els) {
1470
- if (isServerDataElement(el)) {
1471
- out.push(el)
1472
- // Don't recurse into a widget's children — `View` etc. are leaves
1473
- // for v1 (no nested widgets inside widgets).
1474
- continue
1475
- }
1476
- // Skip walkers that imply per-row resolution — widgets inside
1477
- // Repeater/Builder rows don't have a stable id space.
1478
- const type = el.getType()
1479
- if (type === 'form' || type === 'repeater' || type === 'builder' || type === 'table' || type === 'tableWidget') continue
1480
- const children = el.getChildren()
1481
- if (children) walk(children)
1482
- }
1483
- }
1484
- walk(elements)
1485
- return out
1486
- }
1487
-
1488
- /**
1489
- * Plan #15 — stamp the polling-endpoint URL on every `ServerDataElement`
1490
- * in the tree. Mirrors `tagFormStateUrls / tagTableReorderUrls`. Walks
1491
- * with the same boundaries as `collectServerDataElements` so the wire
1492
- * stays in sync (no orphan widgets without URLs and vice versa).
1493
- *
1494
- * `urlBuilder(id)` typically produces `${base}/_widget/${id}` for
1495
- * dashboard widgets and `${base}/${pageSlug}/_widget/${id}` for
1496
- * custom-page widgets — the route handlers for both shapes are wired up
1497
- * in `routes.ts` (see Phase A.4).
1498
- */
1499
- export function tagWidgetUrls(
1500
- elements: ReadonlyArray<Element>,
1501
- urlBuilder: (id: string) => string,
1502
- ): void {
1503
- for (const widget of collectServerDataElements(elements)) {
1504
- if (widget.getWidgetUrl()) continue // user-set wins
1505
- widget.withWidgetUrl(urlBuilder(widget.getId()))
1506
- }
1507
- }
1508
-
1509
- /** Stamp dispatchUrl on every handler-style Action so the client knows where to POST. */
1510
- export function tagActionDispatch(elements: ReadonlyArray<Element>, baseUrl: string): void {
1511
- for (const action of findActions(elements)) {
1512
- if (!action.getHandler()) continue
1513
- if (action.getHref() || action.getMethod()) continue
1514
- if (action.getDispatchUrl()) continue
1515
- action.dispatchUrl(`${baseUrl}/_action/${action.name}`)
1516
- }
1517
- // Row-scoped extraItemActions (Repeater/Builder). Stamped here too so
1518
- // the client can POST to the same `_action/:name` route — the renderer
1519
- // attaches `_rowPath=<fieldName>.<index>` per click; the server's
1520
- // dispatcher uses that to walk into the right row when building
1521
- // `ctx.row`. See `findRowExtraActions` in `dispatchAction.ts`.
1522
- for (const { action } of findRowExtraActions(elements)) {
1523
- if (!action.getHandler()) continue
1524
- if (action.getDispatchUrl()) continue
1525
- action.dispatchUrl(`${baseUrl}/_action/${action.name}`)
1526
- }
1527
- }
1528
-
1529
- // ─── Per-role data builders ──────────────────────────────────
1530
-
1531
- export async function dashboardData(pilotiq: Pilotiq, req?: unknown): Promise<Record<string, unknown>> {
1532
- const cfg = pilotiq.getConfig()
1533
- const user = await pilotiq.resolveUser(req)
1534
- const ctx: SchemaContext = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
1535
-
1536
- // Plan #15 — when `panel.dashboard(P)` was called, resolve P's
1537
- // schema instead of the builder-level `cfg.schema`. Page-scoped
1538
- // schema means widget elements read like a regular custom page —
1539
- // including action dispatch, form-state, and `_widget/:id` polling.
1540
- let elements: Element[]
1541
- if (cfg.dashboardPage) {
1542
- elements = await callPageSchema(cfg.dashboardPage, ctx)
1543
- tagFormActions(elements, cfg.path)
1544
- tagFormStateUrls(elements, formId => `${cfg.path}/_form/${formId}/state`)
1545
- tagFormWizardUrls(elements, formId => `${cfg.path}/_form/${formId}/wizard`)
1546
- tagRichTextMentionUrls(elements, formId => `${cfg.path}/_form/${formId}/mentions`)
1547
- tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${cfg.path}/_form/${formId}/create-option/${fieldName}`)
1548
- tagActionDispatch(elements, cfg.path)
1549
- } else {
1550
- elements = []
1551
- if (cfg.schema) {
1552
- const def = cfg.schema
1553
- elements = typeof def === 'function' ? await def(ctx) : def
1554
- }
1555
- }
1556
-
1557
- // Stamp polling URLs on every widget — panel-scope (no pageSlug
1558
- // segment) for the dashboard. Done before schema resolve so the URL
1559
- // rides on each widget's stamped meta.
1560
- tagWidgetUrls(elements, id => `${cfg.path}/_widget/${id}`)
1561
-
1562
- const widgetData = await resolveServerDataElements(elements, ctx)
1563
- const dashRoute: PanelInfoRoute = cfg.dashboardPage ? { page: cfg.dashboardPage } : {}
1564
- const schemaData = await applyRoleHooks(
1565
- pilotiq, user, 'dashboard',
1566
- await resolveSchema(elements, ctx),
1567
- dashRoute,
1568
- )
1569
-
1570
- return {
1571
- panel: await panelInfo(pilotiq, req, dashRoute),
1572
- page: cfg.dashboardPage ? cfg.dashboardPage.toMeta() : undefined,
1573
- basePath: cfg.path,
1574
- layout: cfg.layout,
1575
- schemaData,
1576
- _widgetData: widgetData,
1577
- notifications: consumeFlashedNotifications(req),
1578
- }
1579
- }
1580
-
1581
- export async function resourceIndexData(
1582
- pilotiq: Pilotiq,
1583
- slug: string,
1584
- query: Record<string, string> = {},
1585
- req?: unknown,
1586
- ): Promise<Record<string, unknown> | null> {
1587
- const cfg = pilotiq.getConfig()
1588
- const R = cfg.resources.find(r => r.getSlug() === slug)
1589
- if (!R) return null
1590
-
1591
- const pages = R.resolvePages()
1592
- if (!pages.index) return null
1593
- const PageClass = pages.index
1594
-
1595
- const indexUrl = resourceBasePath(cfg.path, R)
1596
- const user = await pilotiq.resolveUser(req)
1597
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'table', basePath: cfg.path }, user), cfg)
1598
- const elements = await callPageSchema(PageClass, ctx)
1599
- tagActionDispatch(elements, indexUrl)
1600
- // Plan #15 — resource-scope widget polling URL. Stamped before the
1601
- // schema resolves so each widget's meta carries its endpoint.
1602
- tagWidgetUrls(elements, id => `${indexUrl}/_widget/${id}`)
1603
- // Mark the active tab + parallel-eval badges + stamp per-tab URLs
1604
- // before the table records run — `loadTableRecords` walks the schema
1605
- // for the active tab and splices its `modifyQuery` predicate into the
1606
- // ORM chain alongside filters.
1607
- await resolveActiveTab(elements, query, indexUrl)
1608
- if (R.deferLoading) tagTableDeferred(elements, `${indexUrl}/_table`)
1609
- await loadTableRecords(elements, query, indexUrl, user, {
1610
- canEdit: (u, record) => R.canEdit(u, record),
1611
- })
1612
- tagTableReorderUrls(elements, `${indexUrl}/_reorder`)
1613
- tagCellEditUrls(elements, indexUrl)
1614
- const widgetData = await resolveServerDataElements(elements, ctx)
1615
-
1616
- const breadcrumbs = resourceListBreadcrumbs(cfg, R)
1617
- if (breadcrumbs) elements.unshift(breadcrumbs)
1618
-
1619
- const listRoute: PanelInfoRoute = { resource: R, page: PageClass }
1620
- const schemaData = await applyRoleHooks(
1621
- pilotiq, user, 'list',
1622
- await resolveSchema(elements, ctx),
1623
- listRoute,
1624
- )
1625
-
1626
- return {
1627
- pageType: 'resource',
1628
- panel: await panelInfo(pilotiq, req, listRoute),
1629
- page: PageClass.toMeta(),
1630
- resource: { name: R.name, label: R.label, labelSingular: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
1631
- basePath: cfg.path,
1632
- layout: cfg.layout,
1633
- schemaData,
1634
- _widgetData: widgetData,
1635
- notifications: consumeFlashedNotifications(req),
1636
- }
1637
- }
1638
-
1639
- // Deferred-load JSON endpoint payload — `GET {base}/{slug}/_table`
1640
- // re-runs the list-page builder without the deferred flag, then returns
1641
- // every resolved `TableMeta` as a flat array. Returns null on missing
1642
- // resource / index page (route 404s).
1643
- export async function resourceTableData(
1644
- pilotiq: Pilotiq,
1645
- slug: string,
1646
- query: Record<string, string> = {},
1647
- req?: unknown,
1648
- ): Promise<{ tables: Record<string, unknown>[] } | null> {
1649
- const cfg = pilotiq.getConfig()
1650
- const R = cfg.resources.find(r => r.getSlug() === slug)
1651
- if (!R) return null
1652
-
1653
- const pages = R.resolvePages()
1654
- if (!pages.index) return null
1655
- const PageClass = pages.index
1656
-
1657
- const indexUrl = resourceBasePath(cfg.path, R)
1658
- const user = await pilotiq.resolveUser(req)
1659
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'table', basePath: cfg.path }, user), cfg)
1660
- const elements = await callPageSchema(PageClass, ctx)
1661
- tagActionDispatch(elements, indexUrl)
1662
- await resolveActiveTab(elements, query, indexUrl)
1663
- await loadTableRecords(elements, query, indexUrl, user, {
1664
- canEdit: (u, record) => R.canEdit(u, record),
1665
- })
1666
- tagTableReorderUrls(elements, `${indexUrl}/_reorder`)
1667
- tagCellEditUrls(elements, indexUrl)
1668
- const schemaData = await resolveSchema(elements, ctx)
1669
-
1670
- const tables = collectTableMetas(schemaData)
1671
- return { tables }
1672
- }
1673
-
1674
- function collectTableMetas(
1675
- metas: ReadonlyArray<Record<string, unknown>>,
1676
- ): Record<string, unknown>[] {
1677
- const out: Record<string, unknown>[] = []
1678
- const walk = (nodes: ReadonlyArray<Record<string, unknown>>): void => {
1679
- for (const node of nodes) {
1680
- if (node['type'] === 'table') out.push(node)
1681
- const children = node['children']
1682
- if (Array.isArray(children)) walk(children as Record<string, unknown>[])
1683
- }
1684
- }
1685
- walk(metas)
1686
- return out
1687
- }
1688
-
1689
- /**
1690
- * Walk the schema for `ListTabs` containers, pick the active tab from
1691
- * `?tab=…` (defaulting to the tab marked `.default()` or the first one),
1692
- * stamp render-time state (`active` flag, per-tab `?tab=` URL, and
1693
- * resolved badge counts) onto each tab. The active tab's query/context
1694
- * modifier is NOT applied here — `loadTableRecords` walks for the active
1695
- * tab and splices in its modifier when it builds the records-handler
1696
- * `TableContext`.
1697
- *
1698
- * No-op when the page has no `ListTabs`.
1699
- */
1700
- export async function resolveActiveTab(
1701
- elements: ReadonlyArray<Element>,
1702
- query: Record<string, string>,
1703
- currentPath: string,
1704
- ): Promise<void> {
1705
- const listTabs = findListTabs(elements)
1706
- if (listTabs.length === 0) return
1707
-
1708
- for (const container of listTabs) {
1709
- const children = (container.getChildren() ?? []).filter((c): c is ListTab => c.getType() === 'listTab')
1710
- if (children.length === 0) continue
1711
-
1712
- // Default tab (used both for `?tab=` fallback and to omit the param
1713
- // from the canonical URL of that tab — see `buildTabUrl`).
1714
- const defaultTab = children.find(t => t.isDefault()) ?? children[0]!
1715
-
1716
- // Active tab: explicit `?tab=name` → default tab.
1717
- const wanted = typeof query['tab'] === 'string' ? query['tab'] : undefined
1718
- const active = (wanted && children.find(t => t.name === wanted)) || defaultTab
1719
-
1720
- // Stamp render-time state on each tab.
1721
- children.forEach(t => {
1722
- t.withActive(t === active)
1723
- t.withUrl(buildTabUrl(currentPath, query, t.name, defaultTab.name))
1724
- })
1725
-
1726
- // Resolve every tab's badge in parallel — failed handlers swallow
1727
- // silently (badge omitted) so a flaky count never blanks the page.
1728
- await Promise.all(children.map(async (tab) => {
1729
- const handler = tab.getBadgeHandler()
1730
- if (!handler) return
1731
- try {
1732
- const v = await handler()
1733
- if (v === undefined || v === null) return
1734
- tab.withResolvedBadge(String(v))
1735
- } catch {
1736
- // Per-tab badge errors stay silent.
1737
- }
1738
- }))
1739
- }
1740
- }
1741
-
1742
- function findListTabs(elements: ReadonlyArray<Element>): ListTabs[] {
1743
- const out: ListTabs[] = []
1744
- const walk = (els: ReadonlyArray<Element>): void => {
1745
- for (const el of els) {
1746
- if (el.getType() === 'listTabs') out.push(el as ListTabs)
1747
- const children = el.getChildren()
1748
- if (children) walk(children)
1749
- }
1750
- }
1751
- walk(elements)
1752
- return out
1753
- }
1754
-
1755
- function buildTabUrl(
1756
- pathname: string,
1757
- query: Record<string, string>,
1758
- tabName: string,
1759
- defaultTabName: string,
1760
- ): string {
1761
- // Carry forward search/sort/perPage + any filter values; reset page to 1
1762
- // (tab change reshapes the result set, page numbers don't translate).
1763
- // The default tab gets the canonical, paramless URL — visiting that URL
1764
- // already lands on the default, so emitting `?tab=default` would just be
1765
- // noise that bookmarks/share-links pick up.
1766
- const params = new URLSearchParams()
1767
- for (const [k, v] of Object.entries(query)) {
1768
- if (v === undefined || v === '' || v === null) continue
1769
- if (k === 'tab' || k === 'page') continue
1770
- params.set(k, String(v))
1771
- }
1772
- if (tabName !== defaultTabName) params.set('tab', tabName)
1773
- const qs = params.toString()
1774
- return qs ? `${pathname}?${qs}` : pathname
1775
- }
1776
-
1777
- export async function resourceCreateData(
1778
- pilotiq: Pilotiq,
1779
- slug: string,
1780
- prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
1781
- req?: unknown,
1782
- ): Promise<Record<string, unknown> | null> {
1783
- const cfg = pilotiq.getConfig()
1784
- const R = cfg.resources.find(r => r.getSlug() === slug)
1785
- if (!R) return null
1786
- const pages = R.resolvePages()
1787
- if (!pages.create) return null
1788
- const PageClass = pages.create
1789
-
1790
- const resourceBase = resourceBasePath(cfg.path, R)
1791
- const createUrl = `${resourceBase}/create`
1792
- const user = await pilotiq.resolveUser(req)
1793
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'create', basePath: cfg.path }, user), cfg)
1794
- const elements = await callPageSchema(PageClass, ctx)
1795
- tagFormActions(elements, createUrl)
1796
- tagActionDispatch(elements, createUrl)
1797
- tagFormStateUrls(elements, formId => `${resourceBase}/_form/${formId}/state`)
1798
- tagFormWizardUrls(elements, formId => `${resourceBase}/_form/${formId}/wizard`)
1799
- tagRichTextMentionUrls(elements, formId => `${resourceBase}/_form/${formId}/mentions`)
1800
- tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${resourceBase}/_form/${formId}/create-option/${fieldName}`)
1801
- if (prefill) {
1802
- const form = findForms(elements)[0]
1803
- if (form) {
1804
- if (prefill.values) form.withValues(prefill.values)
1805
- if (prefill.errors) form.withErrors(prefill.errors)
1806
- }
1807
- }
1808
-
1809
- const breadcrumbs = resourceCreateBreadcrumbs(cfg, R)
1810
- if (breadcrumbs) elements.unshift(breadcrumbs)
1811
-
1812
- const createRoute: PanelInfoRoute = { resource: R, page: PageClass }
1813
- const schemaData = await applyRoleHooks(
1814
- pilotiq, user, 'create',
1815
- await resolveSchema(elements, ctx),
1816
- createRoute,
1817
- )
1818
-
1819
- return {
1820
- panel: await panelInfo(pilotiq, req, createRoute),
1821
- page: PageClass.toMeta(),
1822
- resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
1823
- mode: 'create' as const,
1824
- basePath: cfg.path,
1825
- layout: cfg.layout,
1826
- schemaData,
1827
- notifications: consumeFlashedNotifications(req),
1828
- ...(prefill?.errors ? { hasErrors: true } : {}),
1829
- }
1830
- }
1831
-
1832
- export async function resourceEditData(
1833
- pilotiq: Pilotiq,
1834
- slug: string,
1835
- recordId: string,
1836
- prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
1837
- req?: unknown,
1838
- ): Promise<Record<string, unknown> | null> {
1839
- const cfg = pilotiq.getConfig()
1840
- const R = cfg.resources.find(r => r.getSlug() === slug)
1841
- if (!R) return null
1842
- const pages = R.resolvePages()
1843
- if (!pages.edit) return null
1844
- const PageClass = pages.edit
1845
-
1846
- const resourceBase = resourceBasePath(cfg.path, R)
1847
- const editUrl = `${resourceBase}/${recordId}/edit`
1848
- const user = await pilotiq.resolveUser(req)
1849
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'edit', recordId, basePath: cfg.path }, user), cfg)
1850
- const elements = await callPageSchema(PageClass, ctx)
1851
- tagFormActions(elements, editUrl)
1852
- tagActionDispatch(elements, editUrl)
1853
- tagFormStateUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/state`)
1854
- tagFormWizardUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/wizard`)
1855
- tagRichTextMentionUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/mentions`)
1856
- tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${resourceBase}/${recordId}/_form/${formId}/create-option/${fieldName}`)
1857
-
1858
- // Locate the primary form, load the record, fill values.
1859
- const form = findForms(elements)[0]
1860
- let record: unknown = undefined
1861
- if (form?.getLoadRecord()) {
1862
- try {
1863
- record = await form.getLoadRecord()!(recordId, { values: prefill?.values ?? {} })
1864
- } catch {
1865
- // sentinel/missing record — fall through
1866
- }
1867
- if (!prefill?.values && record != null) {
1868
- const values = await applyFillPipeline(form, record)
1869
- const withRelations = await applyRelationshipRepeaterFill(form, values, record, R.model)
1870
- const withBuilders = await applyRelationshipBuilderFill(form, withRelations, record, R.model)
1871
- form.withValues(withBuilders)
1872
- } else if (prefill?.values) {
1873
- form.withValues(prefill.values)
1874
- }
1875
- if (prefill?.errors) form.withErrors(prefill.errors)
1876
- }
1877
-
1878
- // Plan #11 — when the resource has relation managers, prepend a
1879
- // navigation strip so users can drill into each manager's table
1880
- // without leaving the parent record context. The "Edit" tab is
1881
- // active here.
1882
- const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record)
1883
- if (relationTabsEl) elements.unshift(relationTabsEl)
1884
-
1885
- const recordTitle = record !== undefined && record !== null
1886
- ? deriveParentTitle(R, record)
1887
- : recordId
1888
- const breadcrumbs = resourceEditBreadcrumbs(cfg, R, recordId, recordTitle)
1889
- if (breadcrumbs) elements.unshift(breadcrumbs)
1890
-
1891
- const editRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
1892
- const schemaData = await applyRoleHooks(
1893
- pilotiq, user, 'edit',
1894
- await resolveSchema(
1895
- elements,
1896
- record !== undefined ? { ...ctx, record } : ctx,
1897
- ),
1898
- editRoute,
1899
- )
1900
-
1901
- tagFieldAiUrls(schemaData as Record<string, unknown>[], `${resourceBase}/${recordId}/_agents`)
1902
-
1903
- return {
1904
- panel: await panelInfo(pilotiq, req, editRoute),
1905
- page: PageClass.toMeta(),
1906
- resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
1907
- mode: 'edit' as const,
1908
- recordId,
1909
- basePath: cfg.path,
1910
- layout: cfg.layout,
1911
- schemaData,
1912
- notifications: consumeFlashedNotifications(req),
1913
- ...(prefill?.errors ? { hasErrors: true } : {}),
1914
- }
1915
- }
1916
-
1917
- // ─── Plan #11 relation-manager data builder ─────────────────
1918
-
1919
- /**
1920
- * Plan #11 — three scopes a single relation-manager URL space resolves to:
1921
- *
1922
- * list: GET {base}/{slug}/:id/{rel}
1923
- * create: GET {base}/{slug}/:id/{rel}/create
1924
- * edit: GET {base}/{slug}/:id/{rel}/{childId}/edit
1925
- *
1926
- * Each carries enough state for `relationManagerData` to load the right
1927
- * parent + (for edit) child + form/table context. Submit-side handlers
1928
- * live in `routes.ts` and reuse `dispatchFormSubmit`.
1929
- */
1930
- export type RelationManagerScope =
1931
- | { kind: 'relation-list'; slug: string; recordId: string; relationship: string; query?: Record<string, string> }
1932
- | { kind: 'relation-create'; slug: string; recordId: string; relationship: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
1933
- | { kind: 'relation-view'; slug: string; recordId: string; relationship: string; childId: string }
1934
- | { kind: 'relation-edit'; slug: string; recordId: string; relationship: string; childId: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
1935
- // Phase B nested resources — the leaf is one manager deeper than the
1936
- // depth-1 variants. The two-step `chain` carries the (recordId,
1937
- // relationship) for each layer; the trailing `childId` (when present)
1938
- // is the leaf record's id under chain[1].
1939
- | { kind: 'nested-relation-list'; slug: string; chain: [RelationChainStep, RelationChainStep]; query?: Record<string, string> }
1940
- | { kind: 'nested-relation-create'; slug: string; chain: [RelationChainStep, RelationChainStep]; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
1941
- | { kind: 'nested-relation-view'; slug: string; chain: [RelationChainStep, RelationChainStep]; childId: string }
1942
- | { kind: 'nested-relation-edit'; slug: string; chain: [RelationChainStep, RelationChainStep]; childId: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
1943
-
1944
- /** Phase B — one parent layer in a nested-resources URL chain. The list
1945
- * of these identifies a path through the manager tree:
1946
- * `[ { recordId: '123', relationship: 'comments' } ]` picks comment
1947
- * "456 under post 123" when paired with `childId: '456'`. */
1948
- export interface RelationChainStep {
1949
- recordId: string
1950
- relationship: string
1951
- }
1952
-
1953
- /**
1954
- * Failure outcomes the data builder discriminates back to the route
1955
- * handler, which decides between 403 / 404 / HTML / JSON shapes.
1956
- *
1957
- * `null` — unknown panel / parent / manager / child;
1958
- * route returns 404
1959
- * `{ ok: false, status: 403 }` — policy denied; route returns 403
1960
- *
1961
- * Success returns the schemaData payload directly (a record, not
1962
- * tagged) for parity with `resourceIndexData / resourceCreateData`.
1963
- */
1964
- export type RelationManagerResult =
1965
- | Record<string, unknown>
1966
- | { ok: false; status: 403 }
1967
- | null
1968
-
1969
- /**
1970
- * Discover the related Resource for a manager. Order:
1971
- * 1. `M.relatedResource` explicit override (skip discovery).
1972
- * 2. Rudder ORM convention: walk
1973
- * `R.model.relations[manager.relationship].model()` and find
1974
- * `cfg.resources[i].model === relatedModel`.
1975
- * 3. Otherwise undefined — caller must error or fall back.
1976
- *
1977
- * A returned Resource is the one whose `model` backs the related
1978
- * table. Callers use it for `Related.model.find(childId)`,
1979
- * `Related.canEdit(user, child)`, and the auto-wired form save handler.
1980
- */
1981
- export function findRelatedResource(
1982
- M: typeof RelationManager,
1983
- R: ResourceClass,
1984
- cfg: ReturnType<Pilotiq['getConfig']>,
1985
- ): ResourceClass | undefined {
1986
- if (M.relatedResource) return M.relatedResource
1987
- const ParentModel = R.model as unknown as { relations?: Record<string, { model?: () => unknown }> } | undefined
1988
- if (!ParentModel) return undefined
1989
- const def = ParentModel.relations?.[M.getRelationship()]
1990
- const RelatedModel = typeof def?.model === 'function' ? def.model() : undefined
1991
- if (!RelatedModel) return undefined
1992
- return cfg.resources.find(r => (r.model as unknown) === RelatedModel)
1993
- }
1994
-
1995
- /** Find a registered manager on a Resource by its relationship key.
1996
- * Throws on unknown manager — so the route can 404 cleanly. */
1997
- function findManager(
1998
- R: ResourceClass,
1999
- relationship: string,
2000
- ): typeof RelationManager | undefined {
2001
- return R.relations().find(M => {
2002
- try { return M.getRelationship() === relationship } catch { return false }
2003
- })
2004
- }
2005
-
2006
- /**
2007
- * Verify a child record actually belongs to the given parent under the
2008
- * declared relationship. Anti-IDOR — without this an attacker can swap
2009
- * the `:childId` segment to load any related-model row regardless of
2010
- * whether it's actually owned by the parent.
2011
- *
2012
- * Strategy: re-resolve the parent's relation query and check whether
2013
- * the child's primary key shows up in `where(pk, '=', childId).paginate(1, 1)`.
2014
- * Yes, it's a second round-trip — but it's the single point of trust
2015
- * for IDOR safety, and it fits naturally into the same query path
2016
- * `modelRelationTableRecords` uses.
2017
- */
2018
- async function childBelongsToParent(
2019
- parentModel: ModelLike,
2020
- parent: unknown,
2021
- relationship: string,
2022
- childPk: string,
2023
- childId: string,
2024
- ): Promise<boolean> {
2025
- try {
2026
- const q: ModelQuery = (parentModel.relatedQuery
2027
- ? parentModel.relatedQuery(parent, relationship)
2028
- : (parent as { related: (n: string) => ModelQuery }).related(relationship))
2029
- const result = await q.where(childPk, '=', childId).paginate(1, 1)
2030
- return result.total > 0
2031
- } catch {
2032
- return false
2033
- }
2034
- }
2035
-
2036
- /**
2037
- * Auto-wire the manager's table records loader against the parent's
2038
- * relation query when the user didn't set `Table.records()` themselves.
2039
- * Mirrors `defaultPages`'s wiring of `Table.records()` from `R.model`
2040
- * for the resource list page.
2041
- */
2042
- function autoWireManagerTable(
2043
- table: Table,
2044
- parentModel: ModelLike,
2045
- parent: unknown,
2046
- relationship: string,
2047
- ): void {
2048
- if (table.getRecords()) return // user wired it explicitly
2049
- table.records(modelRelationTableRecords(parentModel, parent, relationship, table))
2050
- }
2051
-
2052
- /**
2053
- * Plan #13 polish — auto-inject `TrashedFilter` on a relation manager's
2054
- * table when the **related** Resource opts into soft deletes. Mirrors the
2055
- * resource-list pattern in `defaultPages.applyTableDefaults`. The check
2056
- * is on the related Resource (not the manager), because soft-delete is a
2057
- * model-level capability — if the child model supports trashing, the
2058
- * manager's table should expose the toggle.
2059
- *
2060
- * No-op when:
2061
- * - the related Resource hasn't set `softDeletes = true`
2062
- * - the user already attached a `TrashedFilter` in `M.table()`
2063
- */
2064
- function injectManagerTrashedFilter(
2065
- table: Table,
2066
- Related: ResourceClass | undefined,
2067
- ): void {
2068
- if (!Related?.softDeletes) return
2069
- const children = table.getChildren() ?? []
2070
- const hasTrashed = children.some(c => c instanceof TrashedFilter)
2071
- if (hasTrashed) return
2072
- const existing = children.filter(c => c instanceof Filter) as Filter[]
2073
- table.filters([...existing, TrashedFilter.make()])
2074
- }
2075
-
2076
- /**
2077
- * Auto-wire the manager's form save + loadRecord handlers against the
2078
- * **related** Resource's `model` when the user didn't set them. The
2079
- * route handler is responsible for stamping the parent context
2080
- * (parent, parentRecord, parentId, relationship) onto the
2081
- * `FormContext` so user-supplied `mutateDataBeforeCreate` etc. can
2082
- * read them.
2083
- */
2084
- function autoWireManagerForm(form: Form, Related: ResourceClass): void {
2085
- const RelatedModel = Related.model
2086
- if (!RelatedModel) return
2087
- if (!form.getSave()) form.save(modelSave(RelatedModel))
2088
- if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
2089
- }
2090
-
2091
- async function safePolicy(fn: () => Promise<boolean> | boolean): Promise<boolean> {
2092
- try { return Boolean(await fn()) } catch { return false }
2093
- }
2094
-
2095
- /** Plan #11 — authorization predicate names a `RelationManager` carries.
2096
- * Re-exported from `RelationManager.ts`. */
2097
- export type ManagerCanMethod = ManagerCanMethodType
2098
-
2099
- /** Plan #11 — authorize a relation-manager action with sensible defaults.
2100
- * Re-exported from `RelationManager.ts` so external callers (route
2101
- * handlers, third-party plugins) keep their existing import path. */
2102
- export const safeManagerPolicy = safeManagerPolicyImpl
2103
-
2104
- /**
2105
- * Plan #11 — render data for the three relation-manager URL scopes.
2106
- * Mirrors the resource* builders' shape so routes and Vike +data hooks
2107
- * consume identical props. Authorization runs inline (parent
2108
- * `canAccess + canEdit(parent)` then manager-scoped predicate); IDOR
2109
- * check on `relation-edit` runs against the parent's relation query.
2110
- *
2111
- * Returns:
2112
- * - `null` when panel / parent / manager / child don't exist.
2113
- * - `{ ok: false, status: 403 }` when authorization denies.
2114
- * - the props record on success (route picks SSR view / SPA prop
2115
- * downstream).
2116
- */
2117
- export async function relationManagerData(
2118
- pilotiq: Pilotiq,
2119
- scope: RelationManagerScope,
2120
- req?: unknown,
2121
- ): Promise<RelationManagerResult> {
2122
- // Phase B nested-relation-* scopes split out into their own pipeline
2123
- // — the chain walking + per-layer auth differs enough from the
2124
- // depth-1 path that interleaving them would mostly hurt readability.
2125
- if (scope.kind === 'nested-relation-list'
2126
- || scope.kind === 'nested-relation-create'
2127
- || scope.kind === 'nested-relation-view'
2128
- || scope.kind === 'nested-relation-edit') {
2129
- return nestedRelationManagerData(pilotiq, scope, req)
2130
- }
2131
-
2132
- const cfg = pilotiq.getConfig()
2133
-
2134
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
2135
- if (!R) return null
2136
-
2137
- const M = findManager(R, scope.relationship)
2138
- if (!M) return null
2139
-
2140
- const user = await pilotiq.resolveUser(req)
2141
-
2142
- // Layer 1: parent access. canAccess gates the resource entirely;
2143
- // canEdit gates managing its relations (managers are read-write
2144
- // surfaces — read-only inline views opt in by overriding the
2145
- // manager's can*). Cluster gate composes with R.canAccess — both
2146
- // must pass when the parent resource is inside a cluster.
2147
- if (R.cluster && !await safePolicy(() => R.cluster!.canAccess(user))) return { ok: false, status: 403 }
2148
- if (!await safePolicy(() => R.canAccess(user))) return { ok: false, status: 403 }
2149
-
2150
- if (!R.model) {
2151
- // Without a model on the parent we can't load the parent record,
2152
- // and without that we can't IDOR-check children. Point users at
2153
- // the missing wiring rather than silent 500s.
2154
- throw new Error(
2155
- `[Pilotiq] Resource "${R.name}" has relations(${M.name}) but no static model. ` +
2156
- `Set Resource.model = … to enable relation managers, or remove the manager.`,
2157
- )
2158
- }
2159
-
2160
- const parentRecord = await findRecord(R, scope.recordId, { user }).catch(() => undefined)
2161
- if (!parentRecord) return null
2162
-
2163
- if (!await safePolicy(() => R.canEdit(user, parentRecord))) return { ok: false, status: 403 }
2164
-
2165
- // Read the relation type off the parent's relations map once,
2166
- // normalize to the six-way `RelationMode` the manager-side logic
2167
- // uses. `belongsToMany` / `morphToMany` (owning polymorphic) /
2168
- // `morphedByMany` (inverse polymorphic) all flip into pivot-mutation
2169
- // mode (attach / detach / sync — same accessor surface), `morphMany|
2170
- // morphOne` collapses to `'morphMany'` (parent-side polymorphic —
2171
- // auto-fills morph columns on create), `morphTo` is the child-side
2172
- // polymorphic (no auto-actions; requires explicit `M.relatedResource`).
2173
- // Everything else collapses to `'hasMany'`.
2174
- const relationType = getRelationType(R.model, scope.relationship)
2175
- const mode: RelationMode = normalizeRelationMode(relationType)
2176
-
2177
- const Related = findRelatedResource(M, R, cfg)
2178
- // Related Resource is required for: edit/create form auto-wire,
2179
- // child loading on edit, related URL generation. Throw when missing
2180
- // *only* if we'd otherwise need it — for `relation-list` it's
2181
- // optional (the table can be hand-wired by the user).
2182
- const needRelated = scope.kind !== 'relation-list'
2183
- if (needRelated && !Related) {
2184
- throw new Error(
2185
- `[Pilotiq] RelationManager ${M.name} on ${R.name} could not resolve its related Resource. ` +
2186
- `Set static relatedResource on the manager, or ensure the parent's model declares relations[${JSON.stringify(M.getRelationship())}].`,
2187
- )
2188
- }
2189
-
2190
- switch (scope.kind) {
2191
- case 'relation-list':
2192
- return buildRelationListData(pilotiq, R, M, Related, parentRecord, scope, req, user, mode)
2193
- case 'relation-create':
2194
- return buildRelationCreateData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
2195
- case 'relation-view':
2196
- return buildRelationViewData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
2197
- case 'relation-edit':
2198
- return buildRelationEditData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
2199
- }
2200
- }
2201
-
2202
- async function buildRelationListData(
2203
- pilotiq: Pilotiq,
2204
- R: ResourceClass,
2205
- M: typeof RelationManager,
2206
- Related: ResourceClass | undefined,
2207
- parentRecord: unknown,
2208
- scope: Extract<RelationManagerScope, { kind: 'relation-list' }>,
2209
- req: unknown,
2210
- user: unknown,
2211
- mode: RelationMode,
2212
- ): Promise<RelationManagerResult> {
2213
- if (!await safeManagerPolicy(M, 'canViewAny', Related, user, parentRecord)) return { ok: false, status: 403 }
2214
-
2215
- const cfg = pilotiq.getConfig()
2216
- const base = cfg.path
2217
- const resourceBase = resourceBasePath(base, R)
2218
- const listUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}`
2219
-
2220
- // Build a single Table by piping a fresh Table through M.table(table, ctx).
2221
- // Context lets the user wire `Action.relationCreate / relationEdit /
2222
- // relationDelete(M, ctx)` factories inside `static table()` to template
2223
- // URLs without threading basePath / parentId by hand.
2224
- const managerCtx: RelationManagerContext = {
2225
- basePath: base,
2226
- parentSlug: scope.slug,
2227
- parentId: scope.recordId,
2228
- relationship: scope.relationship,
2229
- parentRecord,
2230
- related: Related,
2231
- mode,
2232
- }
2233
- const table = M.table(Table.make(), managerCtx)
2234
- autoWireManagerTable(table, R.model as ModelLike, parentRecord, scope.relationship)
2235
- injectManagerTrashedFilter(table, Related)
2236
-
2237
- const ctx: SchemaContext = uploadCtx(userCtx({
2238
- mode: 'table',
2239
- basePath: base,
2240
- record: parentRecord,
2241
- }, user), cfg)
2242
-
2243
- const elements: Element[] = [table]
2244
- tagActionDispatch(elements, listUrl)
2245
- await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
2246
-
2247
- const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2248
- if (tabs) elements.unshift(tabs)
2249
-
2250
- const breadcrumbs = relationListBreadcrumbs(
2251
- cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord),
2252
- )
2253
- if (breadcrumbs) elements.unshift(breadcrumbs)
2254
-
2255
- const relationListRoute: PanelInfoRoute = { resource: R, recordId: scope.recordId }
2256
- const schemaData = await applyRoleHooks(
2257
- pilotiq, user, 'relation-list',
2258
- await resolveSchema(elements, ctx),
2259
- relationListRoute,
2260
- )
2261
-
2262
- return {
2263
- pageType: 'relation-list',
2264
- panel: await panelInfo(pilotiq, req, relationListRoute),
2265
- resource: { name: R.name, label: R.label, labelSingular: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
2266
- relation: {
2267
- name: M.name,
2268
- label: M.getLabel(),
2269
- labelSingular: M.getLabelSingular(),
2270
- relationship: scope.relationship,
2271
- icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
2272
- relatedSlug: Related?.getSlug(),
2273
- },
2274
- parent: {
2275
- id: scope.recordId,
2276
- title: deriveParentTitle(R, parentRecord),
2277
- },
2278
- basePath: base,
2279
- layout: cfg.layout,
2280
- schemaData,
2281
- notifications: consumeFlashedNotifications(req),
2282
- }
2283
- }
2284
-
2285
- async function buildRelationCreateData(
2286
- pilotiq: Pilotiq,
2287
- R: ResourceClass,
2288
- M: typeof RelationManager,
2289
- Related: ResourceClass,
2290
- parentRecord: unknown,
2291
- scope: Extract<RelationManagerScope, { kind: 'relation-create' }>,
2292
- req: unknown,
2293
- user: unknown,
2294
- mode: RelationMode,
2295
- ): Promise<RelationManagerResult> {
2296
- if (!await safeManagerPolicy(M, 'canCreate', Related, user, parentRecord)) return { ok: false, status: 403 }
2297
-
2298
- const cfg = pilotiq.getConfig()
2299
- const base = cfg.path
2300
- const resourceBase = resourceBasePath(base, R)
2301
- const createUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}/create`
2302
-
2303
- const managerCtx: RelationManagerContext = {
2304
- basePath: base,
2305
- parentSlug: scope.slug,
2306
- parentId: scope.recordId,
2307
- relationship: scope.relationship,
2308
- parentRecord,
2309
- related: Related,
2310
- mode,
2311
- }
2312
- const form = M.form(Form.make(), managerCtx)
2313
- if (Related.model) autoWireManagerForm(form, Related)
2314
-
2315
- const elements: Element[] = [form]
2316
- tagFormActions(elements, createUrl)
2317
-
2318
- if (scope.prefill) {
2319
- if (scope.prefill.values) form.withValues(scope.prefill.values)
2320
- if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
2321
- }
2322
-
2323
- const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2324
- if (tabs) elements.unshift(tabs)
2325
-
2326
- const breadcrumbs = relationCreateBreadcrumbs(
2327
- cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord),
2328
- )
2329
- if (breadcrumbs) elements.unshift(breadcrumbs)
2330
-
2331
- const ctx: SchemaContext = uploadCtx(userCtx({
2332
- mode: 'create',
2333
- basePath: base,
2334
- record: parentRecord,
2335
- }, user), cfg)
2336
-
2337
- const relationCreateRoute: PanelInfoRoute = { resource: R, recordId: scope.recordId }
2338
- const schemaData = await applyRoleHooks(
2339
- pilotiq, user, 'relation-create',
2340
- await resolveSchema(elements, ctx),
2341
- relationCreateRoute,
2342
- )
2343
-
2344
- return {
2345
- pageType: 'relation-create',
2346
- panel: await panelInfo(pilotiq, req, relationCreateRoute),
2347
- resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
2348
- relation: {
2349
- name: M.name,
2350
- label: M.getLabel(),
2351
- labelSingular: M.getLabelSingular(),
2352
- relationship: scope.relationship,
2353
- icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
2354
- relatedSlug: Related.getSlug(),
2355
- },
2356
- parent: {
2357
- id: scope.recordId,
2358
- title: deriveParentTitle(R, parentRecord),
2359
- },
2360
- mode: 'create' as const,
2361
- basePath: base,
2362
- layout: cfg.layout,
2363
- schemaData,
2364
- notifications: consumeFlashedNotifications(req),
2365
- ...(scope.prefill?.errors ? { hasErrors: true } : {}),
2366
- }
2367
- }
2368
-
2369
- /**
2370
- * Phase A — read-only view page for a related record at depth-2:
2371
- * `${base}/${slug}/:id/${rel}/:childId`. Mirrors `buildRelationEditData`'s
2372
- * IDOR + auth posture but resolves the manager's `static detail(child,
2373
- * parent)` instead of its form. The default `detail()` returns `[]` —
2374
- * managers opt in by overriding it; the chrome (RelationTabs strip)
2375
- * still renders so users can sideways-nav between sibling managers.
2376
- */
2377
- async function buildRelationViewData(
2378
- pilotiq: Pilotiq,
2379
- R: ResourceClass,
2380
- M: typeof RelationManager,
2381
- Related: ResourceClass,
2382
- parentRecord: unknown,
2383
- scope: Extract<RelationManagerScope, { kind: 'relation-view' }>,
2384
- req: unknown,
2385
- user: unknown,
2386
- _mode: RelationMode,
2387
- ): Promise<RelationManagerResult> {
2388
- if (!Related.model) {
2389
- throw new Error(
2390
- `[Pilotiq] Cannot load child record for ${M.name}: Related Resource ${Related.name} has no static model.`,
2391
- )
2392
- }
2393
- const childPk = getPrimaryKey(Related.model)
2394
-
2395
- const belongs = await childBelongsToParent(
2396
- R.model as ModelLike, parentRecord, scope.relationship, childPk, scope.childId,
2397
- )
2398
- if (!belongs) return null
2399
-
2400
- const child = await findRecord(Related, scope.childId, { user }).catch(() => undefined)
2401
- if (!child) return null
2402
-
2403
- if (!await safeManagerPolicy(M, 'canView', Related, user, parentRecord, child)) return { ok: false, status: 403 }
2404
-
2405
- const cfg = pilotiq.getConfig()
2406
- const base = cfg.path
2407
-
2408
- const elements: Element[] = M.detail(child, parentRecord)
2409
-
2410
- // Phase B polish — when M declares nested managers, surface them on
2411
- // this page too. The strip lists the leaf parent's view tab plus one
2412
- // tab per sibling nested manager so users can jump from the Phase A
2413
- // view straight into a grandchild list / create / view / edit page.
2414
- // Active key `'__view'` because the user is currently viewing the
2415
- // leaf parent record itself, not any nested manager.
2416
- const nestedTabs = await buildNestedRelationTabs(
2417
- R, M, base,
2418
- { recordId: scope.recordId, relationship: scope.relationship },
2419
- scope.childId,
2420
- '__view',
2421
- user, child,
2422
- )
2423
- if (nestedTabs) elements.unshift(nestedTabs)
2424
-
2425
- const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2426
- if (tabs) elements.unshift(tabs)
2427
-
2428
- const breadcrumbs = relationViewBreadcrumbs(
2429
- cfg, R, M, scope.recordId,
2430
- deriveParentTitle(R, parentRecord),
2431
- deriveParentTitle(Related, child, M),
2432
- )
2433
- if (breadcrumbs) elements.unshift(breadcrumbs)
2434
-
2435
- const ctx: SchemaContext = uploadCtx(userCtx({
2436
- mode: 'view',
2437
- basePath: base,
2438
- record: child,
2439
- recordId: scope.childId,
2440
- }, user), cfg)
2441
-
2442
- const relationViewRoute: PanelInfoRoute = { resource: R, recordId: scope.childId }
2443
- const schemaData = await applyRoleHooks(
2444
- pilotiq, user, 'relation-view',
2445
- await resolveSchema(elements, ctx),
2446
- relationViewRoute,
2447
- )
2448
-
2449
- return {
2450
- pageType: 'relation-view',
2451
- panel: await panelInfo(pilotiq, req, relationViewRoute),
2452
- resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
2453
- relation: {
2454
- name: M.name,
2455
- label: M.getLabel(),
2456
- labelSingular: M.getLabelSingular(),
2457
- relationship: scope.relationship,
2458
- icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
2459
- relatedSlug: Related.getSlug(),
2460
- },
2461
- parent: {
2462
- id: scope.recordId,
2463
- title: deriveParentTitle(R, parentRecord),
2464
- },
2465
- mode: 'view' as const,
2466
- childId: scope.childId,
2467
- basePath: base,
2468
- layout: cfg.layout,
2469
- schemaData,
2470
- notifications: consumeFlashedNotifications(req),
2471
- }
2472
- }
2473
-
2474
- async function buildRelationEditData(
2475
- pilotiq: Pilotiq,
2476
- R: ResourceClass,
2477
- M: typeof RelationManager,
2478
- Related: ResourceClass,
2479
- parentRecord: unknown,
2480
- scope: Extract<RelationManagerScope, { kind: 'relation-edit' }>,
2481
- req: unknown,
2482
- user: unknown,
2483
- mode: RelationMode,
2484
- ): Promise<RelationManagerResult> {
2485
- if (!Related.model) {
2486
- throw new Error(
2487
- `[Pilotiq] Cannot load child record for ${M.name}: Related Resource ${Related.name} has no static model.`,
2488
- )
2489
- }
2490
- const childPk = getPrimaryKey(Related.model)
2491
-
2492
- // IDOR check first — confirm the child actually belongs to the
2493
- // parent under this relationship before doing anything else. Guards
2494
- // against URL tampering swapping `:childId`.
2495
- const belongs = await childBelongsToParent(
2496
- R.model as ModelLike, parentRecord, scope.relationship, childPk, scope.childId,
2497
- )
2498
- if (!belongs) return null
2499
-
2500
- const child = await findRecord(Related, scope.childId, { user }).catch(() => undefined)
2501
- if (!child) return null
2502
-
2503
- if (!await safeManagerPolicy(M, 'canEdit', Related, user, parentRecord, child)) return { ok: false, status: 403 }
2504
-
2505
- const cfg = pilotiq.getConfig()
2506
- const base = cfg.path
2507
- const resourceBase = resourceBasePath(base, R)
2508
- const editUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}/${scope.childId}/edit`
2509
-
2510
- const managerCtx: RelationManagerContext = {
2511
- basePath: base,
2512
- parentSlug: scope.slug,
2513
- parentId: scope.recordId,
2514
- relationship: scope.relationship,
2515
- parentRecord,
2516
- related: Related,
2517
- mode,
2518
- }
2519
- const form = M.form(Form.make(), managerCtx)
2520
- autoWireManagerForm(form, Related)
2521
-
2522
- const elements: Element[] = [form]
2523
- tagFormActions(elements, editUrl)
2524
-
2525
- // Prefill values: explicit prefill (re-render after 422) wins,
2526
- // otherwise pipe the loaded child through Form's fill pipeline.
2527
- if (scope.prefill?.values) {
2528
- form.withValues(scope.prefill.values)
2529
- if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
2530
- } else if (child != null) {
2531
- const values = await applyFillPipeline(form, child)
2532
- form.withValues(values)
2533
- }
2534
-
2535
- const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
2536
- if (tabs) elements.unshift(tabs)
2537
-
2538
- const breadcrumbs = relationEditBreadcrumbs(
2539
- cfg, R, M, scope.recordId,
2540
- deriveParentTitle(R, parentRecord),
2541
- scope.childId,
2542
- deriveParentTitle(Related, child, M),
2543
- )
2544
- if (breadcrumbs) elements.unshift(breadcrumbs)
2545
-
2546
- const ctx: SchemaContext = uploadCtx(userCtx({
2547
- mode: 'edit',
2548
- basePath: base,
2549
- record: child,
2550
- recordId: scope.childId,
2551
- }, user), cfg)
2552
-
2553
- const relationEditRoute: PanelInfoRoute = { resource: R, recordId: scope.childId }
2554
- const schemaData = await applyRoleHooks(
2555
- pilotiq, user, 'relation-edit',
2556
- await resolveSchema(elements, ctx),
2557
- relationEditRoute,
2558
- )
2559
-
2560
- return {
2561
- pageType: 'relation-edit',
2562
- panel: await panelInfo(pilotiq, req, relationEditRoute),
2563
- resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
2564
- relation: {
2565
- name: M.name,
2566
- label: M.getLabel(),
2567
- labelSingular: M.getLabelSingular(),
2568
- relationship: scope.relationship,
2569
- icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
2570
- relatedSlug: Related.getSlug(),
2571
- },
2572
- parent: {
2573
- id: scope.recordId,
2574
- title: deriveParentTitle(R, parentRecord),
2575
- },
2576
- mode: 'edit' as const,
2577
- childId: scope.childId,
2578
- basePath: base,
2579
- layout: cfg.layout,
2580
- schemaData,
2581
- notifications: consumeFlashedNotifications(req),
2582
- ...(scope.prefill?.errors ? { hasErrors: true } : {}),
2583
- }
2584
- }
2585
-
2586
- // ─── Phase B nested-relation pipeline ────────────────────────
2587
-
2588
- /**
2589
- * Phase B — narrow `scope` discriminator for nested-relation-*. Lets
2590
- * the helpers below avoid restating the union for every parameter.
2591
- */
2592
- type NestedRelationScope = Extract<RelationManagerScope, { kind: `nested-relation-${string}` }>
2593
-
2594
- /**
2595
- * Phase B — chain walk result. Resolved layer-by-layer in
2596
- * `resolveRelationChain`; nested builders consume it. Failures bubble
2597
- * up as the same `{ ok: false, status: 403 }` / `null` shape the
2598
- * depth-1 path uses.
2599
- */
2600
- export interface ResolvedChain {
2601
- R: ResourceClass
2602
- parentRecord: unknown
2603
- M1: typeof RelationManager
2604
- Related1: ResourceClass
2605
- child1: unknown
2606
- child1Mode: RelationMode
2607
- M2: typeof RelationManager
2608
- Related2: ResourceClass | undefined
2609
- child2Mode: RelationMode
2610
- }
2611
-
2612
- /**
2613
- * Phase B — resolve a depth-2 chain, running every auth + IDOR layer:
2614
- * Layer 0 — top-level Resource: cluster gate, R.canAccess.
2615
- * Layer 1 — parent record: R.canEdit(parent) (Phase A gate to manage relations).
2616
- * Layer 2 — first manager M1: relationship discovered, related resource discovered.
2617
- * IDOR #1 — child1 (the leaf parent) must belong to parentRecord under chain[0].relationship.
2618
- * Layer 3 — M1.canView(child1, parent) (Filament-style: must be allowed
2619
- * to view the child to drill into its sub-relations).
2620
- * Layer 4 — second manager M2 lookup; relation type read off Related1.model.
2621
- *
2622
- * The leaf manager's per-scope predicate (canViewAny / canCreate /
2623
- * canView / canEdit) runs inside the per-scope builders below, since
2624
- * each predicate has different arguments.
2625
- */
2626
- export async function resolveRelationChain(
2627
- pilotiq: Pilotiq,
2628
- scope: NestedRelationScope,
2629
- user: unknown,
2630
- ): Promise<ResolvedChain | { ok: false; status: 403 } | null> {
2631
- const cfg = pilotiq.getConfig()
2632
-
2633
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
2634
- if (!R) return null
2635
-
2636
- // Layer 0 — same gates as the depth-1 pipeline.
2637
- if (R.cluster && !await safePolicy(() => R.cluster!.canAccess(user))) return { ok: false, status: 403 }
2638
- if (!await safePolicy(() => R.canAccess(user))) return { ok: false, status: 403 }
2639
-
2640
- if (!R.model) {
2641
- throw new Error(
2642
- `[Pilotiq] Resource "${R.name}" has nested relations but no static model. ` +
2643
- `Set Resource.model = … or remove the manager.`,
2644
- )
2645
- }
2646
-
2647
- const [step0, step1] = scope.chain
2648
- const parentRecord = await findRecord(R, step0.recordId, { user }).catch(() => undefined)
2649
- if (!parentRecord) return null
2650
-
2651
- // Layer 1 — parent record gate.
2652
- if (!await safePolicy(() => R.canEdit(user, parentRecord))) return { ok: false, status: 403 }
2653
-
2654
- // Layer 2 — first manager M1.
2655
- const M1 = findManager(R, step0.relationship)
2656
- if (!M1) return null
2657
- const Related1 = findRelatedResource(M1, R, cfg)
2658
- if (!Related1) {
2659
- throw new Error(
2660
- `[Pilotiq] RelationManager ${M1.name} on ${R.name} could not resolve its related Resource. ` +
2661
- `Set static relatedResource on the manager, or ensure the parent's model declares relations[${JSON.stringify(M1.getRelationship())}].`,
2662
- )
2663
- }
2664
- if (!Related1.model) {
2665
- throw new Error(
2666
- `[Pilotiq] Related Resource ${Related1.name} has no static model — ` +
2667
- `cannot resolve nested manager chain through it.`,
2668
- )
2669
- }
2670
- const child1Mode: RelationMode = normalizeRelationMode(getRelationType(R.model, step0.relationship))
2671
-
2672
- // IDOR #1 — confirm the leaf parent (`step1.recordId`) actually
2673
- // belongs to the top parent under the first relationship key.
2674
- const child1Pk = getPrimaryKey(Related1.model)
2675
- const belongs1 = await childBelongsToParent(
2676
- R.model as ModelLike, parentRecord, step0.relationship, child1Pk, step1.recordId,
2677
- )
2678
- if (!belongs1) return null
2679
-
2680
- const child1 = await findRecord(Related1, step1.recordId, { user }).catch(() => undefined)
2681
- if (!child1) return null
2682
-
2683
- // Layer 3 — M1.canView(child1, parent) gate. Filament-style: viewing
2684
- // the child is the prerequisite for entering its nested manager strip.
2685
- if (!await safeManagerPolicy(M1, 'canView', Related1, user, parentRecord, child1)) return { ok: false, status: 403 }
2686
-
2687
- // Layer 4 — second manager M2 declared under M1.relations().
2688
- const M2 = M1.relations().find(N => {
2689
- try { return N.getRelationship() === step1.relationship } catch { return false }
2690
- })
2691
- if (!M2) return null
2692
- const Related2 = findRelatedResource(M2, Related1, cfg)
2693
- const child2Mode: RelationMode = normalizeRelationMode(getRelationType(Related1.model, step1.relationship))
2694
-
2695
- return { R, parentRecord, M1, Related1, child1, child1Mode, M2, Related2, child2Mode }
2696
- }
2697
-
2698
- /**
2699
- * Phase B dispatcher — splits the four nested scopes onto their builders
2700
- * after the shared chain walk. Mirrors the depth-1 `relationManagerData`
2701
- * function shape.
2702
- */
2703
- async function nestedRelationManagerData(
2704
- pilotiq: Pilotiq,
2705
- scope: NestedRelationScope,
2706
- req?: unknown,
2707
- ): Promise<RelationManagerResult> {
2708
- const user = await pilotiq.resolveUser(req)
2709
- const resolved = await resolveRelationChain(pilotiq, scope, user)
2710
- if (resolved === null) return null
2711
- if ('ok' in resolved) return resolved
2712
-
2713
- // For create / view / edit we strictly need a registered Related2 so
2714
- // we can load the leaf record + auto-wire the form save.
2715
- const needRelated2 = scope.kind !== 'nested-relation-list'
2716
- if (needRelated2 && !resolved.Related2) {
2717
- throw new Error(
2718
- `[Pilotiq] Nested RelationManager ${resolved.M2.name} under ${resolved.M1.name} ` +
2719
- `on ${resolved.R.name} could not resolve its related Resource. ` +
2720
- `Set static relatedResource on the manager, or ensure the parent's model declares ` +
2721
- `relations[${JSON.stringify(resolved.M2.getRelationship())}].`,
2722
- )
2723
- }
2724
-
2725
- switch (scope.kind) {
2726
- case 'nested-relation-list':
2727
- return buildNestedRelationListData(pilotiq, scope, resolved, req, user)
2728
- case 'nested-relation-create':
2729
- return buildNestedRelationCreateData(pilotiq, scope, resolved, req, user)
2730
- case 'nested-relation-view':
2731
- return buildNestedRelationViewData(pilotiq, scope, resolved, req, user)
2732
- case 'nested-relation-edit':
2733
- return buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
2734
- }
2735
- }
2736
-
2737
- /** Phase B — build the manager context for a nested leaf manager. The
2738
- * parent here is `child1` (the chain's leaf parent record); the URL
2739
- * prefix comes from `scope.chain[0]` via `Action.relation*` factories
2740
- * reading `ctx.chain`. */
2741
- function nestedManagerCtx(
2742
- base: string,
2743
- scope: NestedRelationScope,
2744
- resolved: ResolvedChain,
2745
- ): RelationManagerContext {
2746
- const [step0, step1] = scope.chain
2747
- return {
2748
- basePath: base,
2749
- parentSlug: resolved.R.getSlug(),
2750
- parentId: step1.recordId, // immediate parent = child1's id
2751
- relationship: step1.relationship, // leaf manager's relationship
2752
- parentRecord: resolved.child1, // immediate parent record = child1
2753
- related: resolved.Related2,
2754
- mode: resolved.child2Mode,
2755
- chain: [{
2756
- slug: resolved.R.getSlug(),
2757
- recordId: step0.recordId,
2758
- relationship: step0.relationship,
2759
- }],
2760
- }
2761
- }
2762
-
2763
- /** Phase B — assemble the response shape that mirrors the depth-1
2764
- * builders but adds a `chain` array so renderers can build breadcrumbs
2765
- * and back-links without re-deriving them. */
2766
- function nestedResponseEnvelope(
2767
- pageType: 'nested-relation-list' | 'nested-relation-create' | 'nested-relation-view' | 'nested-relation-edit',
2768
- pilotiq: Pilotiq,
2769
- base: string,
2770
- scope: NestedRelationScope,
2771
- resolved: ResolvedChain,
2772
- req: unknown,
2773
- ): {
2774
- pageType: typeof pageType
2775
- resource: { name: string; label?: string | undefined; slug: string; icon?: SerializedIcon | undefined }
2776
- parentRelation: { name: string; relationship: string; label: string; relatedSlug?: string | undefined }
2777
- parentChild: { id: string; title: string }
2778
- relation: { name: string; relationship: string; label: string; labelSingular: string; icon?: SerializedIcon | undefined; relatedSlug?: string | undefined }
2779
- parent: { id: string; title: string }
2780
- basePath: string
2781
- layout: PilotiqConfig['layout']
2782
- notifications: ReturnType<typeof consumeFlashedNotifications>
2783
- } {
2784
- const { R, M1, Related1, child1, M2, Related2 } = resolved
2785
- const [step0, step1] = scope.chain
2786
- const parentChildTitle = deriveParentTitle(Related1, child1, M1)
2787
-
2788
- return {
2789
- pageType,
2790
- resource: { name: R.name, label: R.labelSingular, slug: R.getSlug(), icon: serializeIcon(R.icon, R.name) },
2791
- parentRelation: {
2792
- name: M1.name,
2793
- relationship: step0.relationship,
2794
- label: M1.getLabel(),
2795
- relatedSlug: Related1.getSlug(),
2796
- },
2797
- parentChild: {
2798
- id: step1.recordId,
2799
- title: parentChildTitle,
2800
- },
2801
- relation: {
2802
- name: M2.name,
2803
- relationship: step1.relationship,
2804
- label: M2.getLabel(),
2805
- labelSingular: M2.getLabelSingular(),
2806
- icon: M2.getIcon() ? serializeIcon(M2.getIcon()!, M2.name) : undefined,
2807
- relatedSlug: Related2?.getSlug(),
2808
- },
2809
- parent: {
2810
- // Top-of-chain record — same shape the depth-1 builders ship as
2811
- // `parent` so renderers can reuse the back-to-resource link.
2812
- id: step0.recordId,
2813
- title: deriveParentTitle(R, resolved.parentRecord),
2814
- },
2815
- basePath: base,
2816
- layout: pilotiq.getConfig().layout,
2817
- notifications: consumeFlashedNotifications(req),
2818
- }
2819
- }
2820
-
2821
- async function buildNestedRelationListData(
2822
- pilotiq: Pilotiq,
2823
- scope: Extract<NestedRelationScope, { kind: 'nested-relation-list' }>,
2824
- resolved: ResolvedChain,
2825
- req: unknown,
2826
- user: unknown,
2827
- ): Promise<RelationManagerResult> {
2828
- const { Related1, child1, M2, Related2 } = resolved
2829
-
2830
- if (!await safeManagerPolicy(M2, 'canViewAny', Related2, user, child1)) return { ok: false, status: 403 }
2831
-
2832
- const cfg = pilotiq.getConfig()
2833
- const base = cfg.path
2834
- const [step0, step1] = scope.chain
2835
- const resourceBase = resourceBasePath(base, resolved.R)
2836
- const listUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}`
2837
-
2838
- const managerCtx = nestedManagerCtx(base, scope, resolved)
2839
- const table = M2.table(Table.make(), managerCtx)
2840
- if (Related1.model) {
2841
- autoWireManagerTable(table, Related1.model as ModelLike, child1, step1.relationship)
2842
- }
2843
- injectManagerTrashedFilter(table, Related2)
2844
-
2845
- const ctx: SchemaContext = uploadCtx(userCtx({
2846
- mode: 'table',
2847
- basePath: base,
2848
- record: child1,
2849
- }, user), cfg)
2850
-
2851
- const elements: Element[] = [table]
2852
- tagActionDispatch(elements, listUrl)
2853
- await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
2854
-
2855
- const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2856
- if (tabs) elements.unshift(tabs)
2857
-
2858
- const breadcrumbs = nestedRelationListBreadcrumbs(
2859
- cfg, resolved.R, resolved.M1, M2, scope.chain[0],
2860
- deriveParentTitle(resolved.R, resolved.parentRecord),
2861
- scope.chain[1].recordId,
2862
- deriveParentTitle(Related1, child1, resolved.M1),
2863
- )
2864
- if (breadcrumbs) elements.unshift(breadcrumbs)
2865
-
2866
- const nestedListRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.chain[1].recordId }
2867
- const schemaData = await applyRoleHooks(
2868
- pilotiq, user, 'relation-list',
2869
- await resolveSchema(elements, ctx),
2870
- nestedListRoute,
2871
- )
2872
-
2873
- return {
2874
- ...nestedResponseEnvelope('nested-relation-list', pilotiq, base, scope, resolved, req),
2875
- panel: await panelInfo(pilotiq, req, nestedListRoute),
2876
- schemaData,
2877
- }
2878
- }
2879
-
2880
- async function buildNestedRelationCreateData(
2881
- pilotiq: Pilotiq,
2882
- scope: Extract<NestedRelationScope, { kind: 'nested-relation-create' }>,
2883
- resolved: ResolvedChain,
2884
- req: unknown,
2885
- user: unknown,
2886
- ): Promise<RelationManagerResult> {
2887
- const { child1, M2, Related2 } = resolved
2888
-
2889
- if (!await safeManagerPolicy(M2, 'canCreate', Related2, user, child1)) return { ok: false, status: 403 }
2890
-
2891
- const cfg = pilotiq.getConfig()
2892
- const base = cfg.path
2893
- const [step0, step1] = scope.chain
2894
- const resourceBase = resourceBasePath(base, resolved.R)
2895
- const createUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}/create`
2896
-
2897
- const managerCtx = nestedManagerCtx(base, scope, resolved)
2898
- const form = M2.form(Form.make(), managerCtx)
2899
- if (Related2?.model) autoWireManagerForm(form, Related2)
2900
-
2901
- const elements: Element[] = [form]
2902
- tagFormActions(elements, createUrl)
2903
-
2904
- if (scope.prefill) {
2905
- if (scope.prefill.values) form.withValues(scope.prefill.values)
2906
- if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
2907
- }
2908
-
2909
- const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2910
- if (tabs) elements.unshift(tabs)
2911
-
2912
- const breadcrumbs = nestedRelationCreateBreadcrumbs(
2913
- cfg, resolved.R, resolved.M1, M2, scope.chain[0],
2914
- deriveParentTitle(resolved.R, resolved.parentRecord),
2915
- scope.chain[1].recordId,
2916
- deriveParentTitle(resolved.Related1, child1, resolved.M1),
2917
- )
2918
- if (breadcrumbs) elements.unshift(breadcrumbs)
2919
-
2920
- const ctx: SchemaContext = uploadCtx(userCtx({
2921
- mode: 'create',
2922
- basePath: base,
2923
- record: child1,
2924
- }, user), cfg)
2925
-
2926
- const nestedCreateRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.chain[1].recordId }
2927
- const schemaData = await applyRoleHooks(
2928
- pilotiq, user, 'relation-create',
2929
- await resolveSchema(elements, ctx),
2930
- nestedCreateRoute,
2931
- )
2932
-
2933
- return {
2934
- ...nestedResponseEnvelope('nested-relation-create', pilotiq, base, scope, resolved, req),
2935
- panel: await panelInfo(pilotiq, req, nestedCreateRoute),
2936
- mode: 'create' as const,
2937
- schemaData,
2938
- ...(scope.prefill?.errors ? { hasErrors: true } : {}),
2939
- }
2940
- }
2941
-
2942
- async function buildNestedRelationViewData(
2943
- pilotiq: Pilotiq,
2944
- scope: Extract<NestedRelationScope, { kind: 'nested-relation-view' }>,
2945
- resolved: ResolvedChain,
2946
- req: unknown,
2947
- user: unknown,
2948
- ): Promise<RelationManagerResult> {
2949
- const { Related1, child1, M2, Related2 } = resolved
2950
- if (!Related2?.model) {
2951
- throw new Error(
2952
- `[Pilotiq] Cannot load child record for nested manager ${M2.name}: ` +
2953
- `Related Resource ${Related2?.name ?? '(none)'} has no static model.`,
2954
- )
2955
- }
2956
- const [, step1] = scope.chain
2957
- const child2Pk = getPrimaryKey(Related2.model)
2958
-
2959
- const belongs2 = await childBelongsToParent(
2960
- Related1.model as ModelLike, child1, step1.relationship, child2Pk, scope.childId,
2961
- )
2962
- if (!belongs2) return null
2963
-
2964
- const child2 = await findRecord(Related2, scope.childId, { user }).catch(() => undefined)
2965
- if (!child2) return null
2966
-
2967
- if (!await safeManagerPolicy(M2, 'canView', Related2, user, child1, child2)) return { ok: false, status: 403 }
2968
-
2969
- const cfg = pilotiq.getConfig()
2970
- const base = cfg.path
2971
-
2972
- const elements: Element[] = M2.detail(child2, child1)
2973
-
2974
- const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
2975
- if (tabs) elements.unshift(tabs)
2976
-
2977
- const breadcrumbs = nestedRelationViewBreadcrumbs(
2978
- cfg, resolved.R, resolved.M1, M2, scope.chain[0],
2979
- deriveParentTitle(resolved.R, resolved.parentRecord),
2980
- scope.chain[1].recordId,
2981
- deriveParentTitle(Related1, child1, resolved.M1),
2982
- deriveParentTitle(Related2, child2, M2),
2983
- )
2984
- if (breadcrumbs) elements.unshift(breadcrumbs)
2985
-
2986
- const ctx: SchemaContext = uploadCtx(userCtx({
2987
- mode: 'view',
2988
- basePath: base,
2989
- record: child2,
2990
- recordId: scope.childId,
2991
- }, user), cfg)
2992
-
2993
- const nestedViewRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.childId }
2994
- const schemaData = await applyRoleHooks(
2995
- pilotiq, user, 'relation-view',
2996
- await resolveSchema(elements, ctx),
2997
- nestedViewRoute,
2998
- )
2999
-
3000
- return {
3001
- ...nestedResponseEnvelope('nested-relation-view', pilotiq, base, scope, resolved, req),
3002
- panel: await panelInfo(pilotiq, req, nestedViewRoute),
3003
- mode: 'view' as const,
3004
- childId: scope.childId,
3005
- schemaData,
3006
- }
3007
- }
3008
-
3009
- async function buildNestedRelationEditData(
3010
- pilotiq: Pilotiq,
3011
- scope: Extract<NestedRelationScope, { kind: 'nested-relation-edit' }>,
3012
- resolved: ResolvedChain,
3013
- req: unknown,
3014
- user: unknown,
3015
- ): Promise<RelationManagerResult> {
3016
- const { Related1, child1, M2, Related2 } = resolved
3017
- if (!Related2?.model) {
3018
- throw new Error(
3019
- `[Pilotiq] Cannot load child record for nested manager ${M2.name}: ` +
3020
- `Related Resource ${Related2?.name ?? '(none)'} has no static model.`,
3021
- )
3022
- }
3023
- const [step0, step1] = scope.chain
3024
- const child2Pk = getPrimaryKey(Related2.model)
3025
-
3026
- const belongs2 = await childBelongsToParent(
3027
- Related1.model as ModelLike, child1, step1.relationship, child2Pk, scope.childId,
3028
- )
3029
- if (!belongs2) return null
3030
-
3031
- const child2 = await findRecord(Related2, scope.childId, { user }).catch(() => undefined)
3032
- if (!child2) return null
3033
-
3034
- if (!await safeManagerPolicy(M2, 'canEdit', Related2, user, child1, child2)) return { ok: false, status: 403 }
3035
-
3036
- const cfg = pilotiq.getConfig()
3037
- const base = cfg.path
3038
- const resourceBase = resourceBasePath(base, resolved.R)
3039
- const editUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}/${scope.childId}/edit`
3040
-
3041
- const managerCtx = nestedManagerCtx(base, scope, resolved)
3042
- const form = M2.form(Form.make(), managerCtx)
3043
- autoWireManagerForm(form, Related2)
3044
-
3045
- const elements: Element[] = [form]
3046
- tagFormActions(elements, editUrl)
3047
-
3048
- if (scope.prefill?.values) {
3049
- form.withValues(scope.prefill.values)
3050
- if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
3051
- } else if (child2 != null) {
3052
- const values = await applyFillPipeline(form, child2)
3053
- form.withValues(values)
3054
- }
3055
-
3056
- const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
3057
- if (tabs) elements.unshift(tabs)
3058
-
3059
- const breadcrumbs = nestedRelationEditBreadcrumbs(
3060
- cfg, resolved.R, resolved.M1, M2, scope.chain[0],
3061
- deriveParentTitle(resolved.R, resolved.parentRecord),
3062
- scope.chain[1].recordId,
3063
- deriveParentTitle(Related1, child1, resolved.M1),
3064
- scope.childId,
3065
- deriveParentTitle(Related2, child2, M2),
3066
- )
3067
- if (breadcrumbs) elements.unshift(breadcrumbs)
3068
-
3069
- const ctx: SchemaContext = uploadCtx(userCtx({
3070
- mode: 'edit',
3071
- basePath: base,
3072
- record: child2,
3073
- recordId: scope.childId,
3074
- }, user), cfg)
3075
-
3076
- const nestedEditRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.childId }
3077
- const schemaData = await applyRoleHooks(
3078
- pilotiq, user, 'relation-edit',
3079
- await resolveSchema(elements, ctx),
3080
- nestedEditRoute,
3081
- )
3082
-
3083
- return {
3084
- ...nestedResponseEnvelope('nested-relation-edit', pilotiq, base, scope, resolved, req),
3085
- panel: await panelInfo(pilotiq, req, nestedEditRoute),
3086
- mode: 'edit' as const,
3087
- childId: scope.childId,
3088
- schemaData,
3089
- ...(scope.prefill?.errors ? { hasErrors: true } : {}),
3090
- }
3091
- }
3092
-
3093
- /**
3094
- * Phase B — build a `RelationTabs` strip scoped to a parent's nested
3095
- * children (e.g. tabs for `replies / reactions` listed under a single
3096
- * comment). The strip's "back" tab points to the depth-2 view page for
3097
- * the leaf parent itself, then one tab per sibling nested manager.
3098
- *
3099
- * Only emitted when the depth-1 manager declares `M.relations()` —
3100
- * absent that, callers skip the prepend so single-manager surfaces stay
3101
- * clean. `activeKey` accepts the literal `'__view'` for the leaf
3102
- * parent's view tab, or any sibling manager's relationship key.
3103
- *
3104
- * Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
3105
- * `N.canViewAny(user, child1Record)` (with fall-through to the related
3106
- * Resource via `safeManagerPolicy`) so the strip hides tabs the user
3107
- * couldn't reach anyway. The back-link `__view` stays unconditional
3108
- * since the user is already on a page scoped under `M.canViewAny` —
3109
- * they reached this strip, they can navigate back to it.
3110
- */
3111
- async function buildNestedRelationTabs(
3112
- R: ResourceClass,
3113
- M: typeof RelationManager,
3114
- basePath: string,
3115
- step0: RelationChainStep,
3116
- child1Id: string,
3117
- activeKey: string,
3118
- user: unknown,
3119
- child1Record: unknown,
3120
- ): Promise<RelationTabs | undefined> {
3121
- const siblings = M.relations()
3122
- if (siblings.length === 0) return undefined
3123
-
3124
- const resourceBase = resourceBasePath(basePath, R)
3125
- const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`
3126
-
3127
- // Sibling gating runs in parallel — each predicate may hit auth /
3128
- // db, so don't serialize them.
3129
- const siblingGates = siblings.map(N => {
3130
- const Related = (N as unknown as { relatedResource?: ResourceClass }).relatedResource
3131
- return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record)
3132
- })
3133
- const siblingVisible = await Promise.all(siblingGates)
3134
-
3135
- const tabs: RelationTabMeta[] = []
3136
-
3137
- // Back-link: depth-2 view page for the leaf parent record. Acts as
3138
- // the "View" tab in the same way `__view` does on depth-1 strips.
3139
- tabs.push(relationTab({
3140
- key: '__view',
3141
- label: M.getLabelSingular(),
3142
- url: `${parentBase}/${child1Id}`,
3143
- active: activeKey === '__view',
3144
- icon: M.getIcon(),
3145
- iconOwner: M.name,
3146
- }))
3147
-
3148
- siblings.forEach((N, i) => {
3149
- if (!siblingVisible[i]) return
3150
- let nestedRel = ''
3151
- try { nestedRel = N.getRelationship() } catch { return }
3152
- const icon = N.getIcon()
3153
- tabs.push(relationTab({
3154
- key: nestedRel,
3155
- label: N.getLabel(),
3156
- url: `${parentBase}/${child1Id}/${nestedRel}`,
3157
- active: activeKey === nestedRel,
3158
- ...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
3159
- }))
3160
- })
3161
-
3162
- // After gating, only the back-link may remain — one tab isn't a
3163
- // useful sub-nav. Drop the strip in that case (consistent with
3164
- // depth-1's empty-tabs branch).
3165
- if (tabs.length <= 1) return undefined
3166
-
3167
- return RelationTabs.make(tabs)
3168
- }
3169
-
3170
- /**
3171
- * Plan #11 — build the `RelationTabs` strip for a parent record. The
3172
- * strip surfaces the per-record sub-navigation: View, Edit, plus one
3173
- * tab per `R.relations()` manager. `activeKey` selects which tab the
3174
- * renderer highlights — `'__view'` / `'__edit'` for the parent tabs,
3175
- * the manager's relationship key for a manager tab.
3176
- *
3177
- * Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
3178
- * `__edit` as sibling tabs (record sub-navigation) instead of one
3179
- * parent tab whose label depends on mode. Tabs are dropped when the
3180
- * corresponding page role isn't registered (a Resource overriding
3181
- * `pages()` to omit `view` or `edit` shouldn't surface a tab that
3182
- * 404s).
3183
- *
3184
- * Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
3185
- * the matching predicate for each tab and drops the entry when the
3186
- * user can't reach it. Routes still enforce; this is presentation
3187
- * polish so the chrome doesn't promise a link that 403s on click.
3188
- *
3189
- * - `__view` → `R.canView(user, parentRecord)` (skip gating when
3190
- * `parentRecord` is undefined — record load failed,
3191
- * so the route's own load+gate will surface a 404/403
3192
- * rather than the strip hiding silently).
3193
- * - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
3194
- * - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
3195
- * parentRecord)` (falls through to Related's
3196
- * `canViewAny` when the manager hasn't overridden).
3197
- *
3198
- * Returns `undefined` when the resource has no relation managers — the
3199
- * caller can then skip the prepend entirely so resources without
3200
- * relations stay shape-compatible with their existing schemaData.
3201
- * (View+Edit sub-nav alone isn't worth a tab strip; users navigate
3202
- * those via headerActions or the back link.)
3203
- */
3204
- async function buildRelationTabs(
3205
- R: ResourceClass,
3206
- recordId: string,
3207
- basePath: string,
3208
- activeKey: string,
3209
- user: unknown,
3210
- parentRecord: unknown,
3211
- ): Promise<RelationTabs | undefined> {
3212
- const managers = R.relations()
3213
- const recordPageMap = R.getRecordPages()
3214
- const recordPageSlugs = Object.keys(recordPageMap)
3215
- // No managers AND no record sub-pages → no strip. View+Edit alone
3216
- // isn't worth a tab strip; users navigate those via headerActions or
3217
- // the back link. (When either is non-empty, the strip is worth
3218
- // mounting even if all the dynamic tabs end up gated away — the
3219
- // post-gate emptiness check below catches that.)
3220
- if (managers.length === 0 && recordPageSlugs.length === 0) return undefined
3221
-
3222
- const resourceBase = resourceBasePath(basePath, R)
3223
- const pages = R.resolvePages()
3224
-
3225
- // Evaluate every per-tab predicate in parallel. The arrays line up
3226
- // 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
3227
- // `managers` below — we resolve all gates first so the tab-build
3228
- // loop stays straight-line.
3229
- // Record-aware predicates short-circuit to `true` when no parent
3230
- // record was loaded (presentation should never hide more aggressively
3231
- // than the route can enforce; a missing record means the route will
3232
- // 404/403 on click and the strip stays consistent with that).
3233
- const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
3234
- ? safeBool(() => R.canView(user, parentRecord))
3235
- : Promise.resolve(true)
3236
- const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
3237
- ? safeBool(() => R.canEdit(user, parentRecord))
3238
- : Promise.resolve(true)
3239
- const recordPageGates = recordPageSlugs.map(subSlug => {
3240
- // Record sub-page gates run against the parent record verbatim —
3241
- // missing record still calls the predicate so a sub-page that
3242
- // gates on global user state (no record needed) still evaluates.
3243
- // safeBool fails closed for throwing predicates.
3244
- return safeBool(() => recordPageMap[subSlug]!.canAccess(user, parentRecord))
3245
- })
3246
- const managerGates = managers.map(M => {
3247
- const Related = (M as unknown as { relatedResource?: ResourceClass }).relatedResource
3248
- return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord)
3249
- })
3250
- const gateResults = await Promise.all([
3251
- canViewPromise, canEditPromise,
3252
- ...recordPageGates,
3253
- ...managerGates,
3254
- ])
3255
- const canView = gateResults[0] as boolean
3256
- const canEdit = gateResults[1] as boolean
3257
- const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length) as boolean[]
3258
- const managerVisible = gateResults.slice(2 + recordPageSlugs.length) as boolean[]
3259
-
3260
- const tabs: RelationTabMeta[] = []
3261
-
3262
- if (pages.view && canView) {
3263
- tabs.push(relationTab({
3264
- key: '__view',
3265
- label: 'View',
3266
- url: `${resourceBase}/${recordId}`,
3267
- active: activeKey === '__view',
3268
- icon: R.icon as IconValue | undefined,
3269
- iconOwner: R.name,
3270
- }))
3271
- }
3272
-
3273
- if (pages.edit && canEdit) {
3274
- tabs.push(relationTab({
3275
- key: '__edit',
3276
- label: 'Edit',
3277
- url: `${resourceBase}/${recordId}/edit`,
3278
- active: activeKey === '__edit',
3279
- // Re-use the resource icon so when ViewPage is pruned, Edit
3280
- // still carries the visual identity. When both are present, the
3281
- // icon repeats — acceptable; the labels disambiguate.
3282
- icon: R.icon as IconValue | undefined,
3283
- iconOwner: R.name,
3284
- }))
3285
- }
3286
-
3287
- // Record sub-page tabs — between Edit and the managers, in declaration
3288
- // order. Tab label inherits from the sub-page's class (`getLabel()`);
3289
- // icon picks up the sub-page's static `icon` when set. Slug doubles as
3290
- // the URL segment AND the `activeKey` discriminator the data builder
3291
- // passes when rendering the sub-page.
3292
- recordPageSlugs.forEach((subSlug, i) => {
3293
- if (!recordPageVisible[i]) return
3294
- const SubPage = recordPageMap[subSlug]!
3295
- tabs.push(relationTab({
3296
- key: subSlug,
3297
- label: SubPage.getLabel(),
3298
- url: `${resourceBase}/${recordId}/${subSlug}`,
3299
- active: activeKey === subSlug,
3300
- ...(SubPage.icon !== undefined
3301
- ? { icon: SubPage.icon, iconOwner: SubPage.name }
3302
- : {}),
3303
- }))
3304
- })
3305
-
3306
- managers.forEach((M, i) => {
3307
- if (!managerVisible[i]) return
3308
- let rel = ''
3309
- try { rel = M.getRelationship() } catch { return }
3310
- const icon = M.getIcon()
3311
- tabs.push(relationTab({
3312
- key: rel,
3313
- label: M.getLabel(),
3314
- url: `${resourceBase}/${recordId}/${rel}`,
3315
- active: activeKey === rel,
3316
- ...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
3317
- }))
3318
- })
3319
-
3320
- // After gating, the strip may collapse to zero entries. Mirror the
3321
- // "no managers + no sub-pages" branch above — no strip is friendlier
3322
- // than a one-tab strip with just the active page.
3323
- if (tabs.length === 0) return undefined
3324
-
3325
- return RelationTabs.make(tabs)
3326
- }
3327
-
3328
- /**
3329
- * Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
3330
- * relation-tabs builder stays straight-line — mirrors `checkPolicy`
3331
- * in `routes.ts` but kept local to avoid cross-module imports.
3332
- */
3333
- async function safeBool(fn: () => boolean | Promise<boolean>): Promise<boolean> {
3334
- try { return Boolean(await fn()) } catch { return false }
3335
- }
3336
-
3337
- /** Pull a human-readable title off a parent record for breadcrumb /
3338
- * page-title use. Falls back through `recordTitleAttribute` →
3339
- * `name` → `title` → primary key value → 'Record'. */
3340
- function deriveParentTitle(
3341
- R: ResourceClass,
3342
- record: unknown,
3343
- manager?: typeof RelationManager,
3344
- ): string {
3345
- const r = record as Record<string, unknown>
3346
- // Manager-scoped child rows prefer the manager's `recordTitleAttribute`
3347
- // when set — the manager owns its presentation surface, and the related
3348
- // Resource may not opt into the same column (e.g. nested-only Resources
3349
- // that exist purely to back a manager).
3350
- const managerAttr = manager?.recordTitleAttribute
3351
- if (managerAttr && r[managerAttr] != null) return String(r[managerAttr])
3352
- const attr = R.recordTitleAttribute
3353
- if (attr && r[attr] != null) return String(r[attr])
3354
- if (r['name'] != null) return String(r['name'])
3355
- if (r['title'] != null) return String(r['title'])
3356
- if (R.model) {
3357
- const pk = getPrimaryKey(R.model)
3358
- if (r[pk] != null) return String(r[pk])
3359
- }
3360
- return 'Record'
3361
- }
3362
-
3363
- // ─── Phase C breadcrumb builders ─────────────────────────────
3364
- //
3365
- // Server-resolved chain rendered above any other top-of-page chrome
3366
- // (e.g. RelationTabs). The trailing item is always the current page,
3367
- // emitted without a `url` so the renderer can paint it as plain text
3368
- // + `aria-current="page"`. All earlier items link to their canonical
3369
- // URL — clusters route through `clusterBasePath`, resources through
3370
- // `resourceBasePath`, etc., so a clustered resource resolves to
3371
- // `Home / Cluster / Resource / …` instead of skipping the cluster
3372
- // rung.
3373
-
3374
- function homeBreadcrumb(cfg: PilotiqConfig): BreadcrumbItem {
3375
- return {
3376
- label: cfg.branding?.title ?? cfg.name ?? 'Home',
3377
- url: cfg.path,
3378
- }
3379
- }
3380
-
3381
- function clusterBreadcrumb(cfg: PilotiqConfig, child: { cluster?: ClusterClass }): BreadcrumbItem | undefined {
3382
- if (!child.cluster) return undefined
3383
- return {
3384
- label: child.cluster.label,
3385
- url: clusterBasePath(cfg.path, child.cluster),
3386
- }
3387
- }
3388
-
3389
- function buildBreadcrumbs(items: BreadcrumbItem[]): Breadcrumbs | undefined {
3390
- // A single "Home" rung carries no information beyond the dashboard
3391
- // link the layout already exposes — drop it. Every other length is
3392
- // worth rendering.
3393
- if (items.length < 2) return undefined
3394
- return Breadcrumbs.make(items)
3395
- }
3396
-
3397
- function resourceListBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass): Breadcrumbs | undefined {
3398
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3399
- const cluster = clusterBreadcrumb(cfg, R)
3400
- if (cluster) items.push(cluster)
3401
- items.push({ label: R.getBreadcrumb() })
3402
- return buildBreadcrumbs(items)
3403
- }
3404
-
3405
- function resourceCreateBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass): Breadcrumbs | undefined {
3406
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3407
- const cluster = clusterBreadcrumb(cfg, R)
3408
- if (cluster) items.push(cluster)
3409
- items.push({ label: R.getBreadcrumb(), url: resourceBasePath(cfg.path, R) })
3410
- items.push({ label: 'Create' })
3411
- return buildBreadcrumbs(items)
3412
- }
3413
-
3414
- function resourceViewBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass, recordTitle: string): Breadcrumbs | undefined {
3415
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3416
- const cluster = clusterBreadcrumb(cfg, R)
3417
- if (cluster) items.push(cluster)
3418
- items.push({ label: R.getBreadcrumb(), url: resourceBasePath(cfg.path, R) })
3419
- items.push({ label: recordTitle })
3420
- return buildBreadcrumbs(items)
3421
- }
3422
-
3423
- function resourceEditBreadcrumbs(
3424
- cfg: PilotiqConfig,
3425
- R: ResourceClass,
3426
- recordId: string,
3427
- recordTitle: string,
3428
- ): Breadcrumbs | undefined {
3429
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3430
- const cluster = clusterBreadcrumb(cfg, R)
3431
- if (cluster) items.push(cluster)
3432
- const resourceBase = resourceBasePath(cfg.path, R)
3433
- items.push({ label: R.getBreadcrumb(), url: resourceBase })
3434
- // Link the record title to the View page when registered — falls
3435
- // back to plain text so users who pruned ViewPage don't hit a 404.
3436
- const hasView = R.resolvePages().view !== undefined
3437
- items.push(hasView
3438
- ? { label: recordTitle, url: `${resourceBase}/${recordId}` }
3439
- : { label: recordTitle })
3440
- items.push({ label: 'Edit' })
3441
- return buildBreadcrumbs(items)
3442
- }
3443
-
3444
- function globalBreadcrumbs(cfg: PilotiqConfig, G: GlobalClass): Breadcrumbs | undefined {
3445
- // Globals don't have a list page — `Home > <Global Label>` is the
3446
- // shortest meaningful chain. Edit and View collapse to the same
3447
- // breadcrumb (both render the singleton).
3448
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3449
- const cluster = clusterBreadcrumb(cfg, G)
3450
- if (cluster) items.push(cluster)
3451
- items.push({ label: G.label })
3452
- return buildBreadcrumbs(items)
3453
- }
3454
-
3455
- function customPageBreadcrumbs(cfg: PilotiqConfig, P: typeof Page): Breadcrumbs | undefined {
3456
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3457
- const cluster = clusterBreadcrumb(cfg, P)
3458
- if (cluster) items.push(cluster)
3459
- items.push({ label: P.getLabel() })
3460
- return buildBreadcrumbs(items)
3461
- }
3462
-
3463
- /** Common "Home / cluster? / Resource / parent record" prefix used by
3464
- * every relation-* / nested-relation-* breadcrumb. The parent record
3465
- * links to its View page when registered; the resource list is the
3466
- * fallback so users still have a back-link out of the relation chain. */
3467
- function relationBreadcrumbPrefix(
3468
- cfg: PilotiqConfig,
3469
- R: ResourceClass,
3470
- parentId: string,
3471
- parentTitle: string,
3472
- ): BreadcrumbItem[] {
3473
- const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
3474
- const cluster = clusterBreadcrumb(cfg, R)
3475
- if (cluster) items.push(cluster)
3476
- const resourceBase = resourceBasePath(cfg.path, R)
3477
- items.push({ label: R.getBreadcrumb(), url: resourceBase })
3478
- const hasView = R.resolvePages().view !== undefined
3479
- items.push(hasView
3480
- ? { label: parentTitle, url: `${resourceBase}/${parentId}` }
3481
- : { label: parentTitle })
3482
- return items
3483
- }
3484
13
 
3485
- function relationListBreadcrumbs(
3486
- cfg: PilotiqConfig,
3487
- R: ResourceClass,
3488
- M: typeof RelationManager,
3489
- parentId: string,
3490
- parentTitle: string,
3491
- ): Breadcrumbs | undefined {
3492
- const items = relationBreadcrumbPrefix(cfg, R, parentId, parentTitle)
3493
- items.push({ label: M.getLabel() })
3494
- return buildBreadcrumbs(items)
3495
- }
3496
-
3497
- function relationCreateBreadcrumbs(
3498
- cfg: PilotiqConfig,
3499
- R: ResourceClass,
3500
- M: typeof RelationManager,
3501
- parentId: string,
3502
- parentTitle: string,
3503
- ): Breadcrumbs | undefined {
3504
- const items = relationBreadcrumbPrefix(cfg, R, parentId, parentTitle)
3505
- const relList = `${resourceBasePath(cfg.path, R)}/${parentId}/${M.getRelationship()}`
3506
- items.push({ label: M.getLabel(), url: relList })
3507
- items.push({ label: 'Create' })
3508
- return buildBreadcrumbs(items)
3509
- }
3510
-
3511
- function relationViewBreadcrumbs(
3512
- cfg: PilotiqConfig,
3513
- R: ResourceClass,
3514
- M: typeof RelationManager,
3515
- parentId: string,
3516
- parentTitle: string,
3517
- childTitle: string,
3518
- ): Breadcrumbs | undefined {
3519
- const items = relationBreadcrumbPrefix(cfg, R, parentId, parentTitle)
3520
- const relList = `${resourceBasePath(cfg.path, R)}/${parentId}/${M.getRelationship()}`
3521
- items.push({ label: M.getLabel(), url: relList })
3522
- items.push({ label: childTitle })
3523
- return buildBreadcrumbs(items)
3524
- }
3525
-
3526
- function relationEditBreadcrumbs(
3527
- cfg: PilotiqConfig,
3528
- R: ResourceClass,
3529
- M: typeof RelationManager,
3530
- parentId: string,
3531
- parentTitle: string,
3532
- childId: string,
3533
- childTitle: string,
3534
- ): Breadcrumbs | undefined {
3535
- const items = relationBreadcrumbPrefix(cfg, R, parentId, parentTitle)
3536
- const relBase = `${resourceBasePath(cfg.path, R)}/${parentId}/${M.getRelationship()}`
3537
- items.push({ label: M.getLabel(), url: relBase })
3538
- // Phase A always mounts the relation-view page per (R, M), so the
3539
- // child title can always link back to it.
3540
- items.push({ label: childTitle, url: `${relBase}/${childId}` })
3541
- items.push({ label: 'Edit' })
3542
- return buildBreadcrumbs(items)
3543
- }
3544
-
3545
- /** Phase B — depth-2 prefix shared by every nested-relation-* role.
3546
- * Returns "Home / cluster? / Resource / parent / M1 / child1". */
3547
- function nestedRelationBreadcrumbPrefix(
3548
- cfg: PilotiqConfig,
3549
- R: ResourceClass,
3550
- M1: typeof RelationManager,
3551
- step0: RelationChainStep,
3552
- parentTitle: string,
3553
- child1Id: string,
3554
- child1Title: string,
3555
- ): BreadcrumbItem[] {
3556
- const items = relationBreadcrumbPrefix(cfg, R, step0.recordId, parentTitle)
3557
- const rel1Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}`
3558
- items.push({ label: M1.getLabel(), url: rel1Base })
3559
- // Phase A relation-view always mounted, so child1 always links.
3560
- items.push({ label: child1Title, url: `${rel1Base}/${child1Id}` })
3561
- return items
3562
- }
3563
-
3564
- function nestedRelationListBreadcrumbs(
3565
- cfg: PilotiqConfig,
3566
- R: ResourceClass,
3567
- M1: typeof RelationManager,
3568
- M2: typeof RelationManager,
3569
- step0: RelationChainStep,
3570
- parentTitle: string,
3571
- child1Id: string,
3572
- child1Title: string,
3573
- ): Breadcrumbs | undefined {
3574
- const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
3575
- items.push({ label: M2.getLabel() })
3576
- return buildBreadcrumbs(items)
3577
- }
3578
-
3579
- function nestedRelationCreateBreadcrumbs(
3580
- cfg: PilotiqConfig,
3581
- R: ResourceClass,
3582
- M1: typeof RelationManager,
3583
- M2: typeof RelationManager,
3584
- step0: RelationChainStep,
3585
- parentTitle: string,
3586
- child1Id: string,
3587
- child1Title: string,
3588
- ): Breadcrumbs | undefined {
3589
- const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
3590
- const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
3591
- items.push({ label: M2.getLabel(), url: rel2Base })
3592
- items.push({ label: 'Create' })
3593
- return buildBreadcrumbs(items)
3594
- }
3595
-
3596
- function nestedRelationViewBreadcrumbs(
3597
- cfg: PilotiqConfig,
3598
- R: ResourceClass,
3599
- M1: typeof RelationManager,
3600
- M2: typeof RelationManager,
3601
- step0: RelationChainStep,
3602
- parentTitle: string,
3603
- child1Id: string,
3604
- child1Title: string,
3605
- child2Title: string,
3606
- ): Breadcrumbs | undefined {
3607
- const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
3608
- const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
3609
- items.push({ label: M2.getLabel(), url: rel2Base })
3610
- items.push({ label: child2Title })
3611
- return buildBreadcrumbs(items)
3612
- }
3613
-
3614
- function nestedRelationEditBreadcrumbs(
3615
- cfg: PilotiqConfig,
3616
- R: ResourceClass,
3617
- M1: typeof RelationManager,
3618
- M2: typeof RelationManager,
3619
- step0: RelationChainStep,
3620
- parentTitle: string,
3621
- child1Id: string,
3622
- child1Title: string,
3623
- child2Id: string,
3624
- child2Title: string,
3625
- ): Breadcrumbs | undefined {
3626
- const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
3627
- const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
3628
- items.push({ label: M2.getLabel(), url: rel2Base })
3629
- items.push({ label: child2Title, url: `${rel2Base}/${child2Id}` })
3630
- items.push({ label: 'Edit' })
3631
- return buildBreadcrumbs(items)
3632
- }
3633
-
3634
- // ─── Plan #5 partial-resolve data builder ────────────────────
3635
-
3636
- export type FormStateScope =
3637
- | { kind: 'resource-create'; slug: string }
3638
- | { kind: 'resource-edit'; slug: string; recordId: string }
3639
- | { kind: 'global-edit'; slug: string }
3640
- | { kind: 'page'; pageSlug: string }
3641
-
3642
- export interface FormStateRequest {
3643
- formId: string
3644
- changed: string
3645
- values: Record<string, unknown>
3646
- }
3647
-
3648
- export interface FormStateResult {
3649
- ok: true
3650
- form: Record<string, unknown> // resolved FormMeta
3651
- dirty: string[]
3652
- }
3653
-
3654
- export interface FormStateError {
3655
- ok: false
3656
- status: 404 | 422
3657
- error: string
3658
- }
3659
-
3660
- /**
3661
- * Plan #5 — handle a partial-resolve roundtrip from a `live()` field.
3662
- *
3663
- * Locates the page's schema, finds the targeted form by `formId`, runs
3664
- * `applyStateUpdate` to apply the changed value + run
3665
- * `afterStateUpdated`, then re-resolves the form's children with the
3666
- * mutated values + bound `$get / $set` so dependent options /
3667
- * conditional visibility re-evaluate. Returns the resolved FormMeta the
3668
- * client uses to replace its rendered form.
3669
- *
3670
- * Returns `null` when the route prefix doesn't resolve to a real
3671
- * resource/global/page — the route handler turns this into a 404. The
3672
- * inner `{ status: 422 }` failure is for "form found but `changed`
3673
- * field doesn't exist on it" — also a client-side bug.
3674
- */
3675
- export async function formStateData(
3676
- pilotiq: Pilotiq,
3677
- scope: FormStateScope,
3678
- body: FormStateRequest,
3679
- req?: unknown,
3680
- ): Promise<FormStateResult | FormStateError | null> {
3681
- const cfg = pilotiq.getConfig()
3682
- const user = await pilotiq.resolveUser(req)
3683
-
3684
- let PageClass: typeof Page | undefined
3685
- let mode: 'create' | 'edit'
3686
- let record: unknown = undefined
3687
- let recordId: string | undefined
3688
- let baseCtxExtras: Record<string, unknown> = {}
3689
-
3690
- if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
3691
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
3692
- if (!R) return null
3693
- const pages = R.resolvePages()
3694
- if (scope.kind === 'resource-create') {
3695
- if (!pages.create) return null
3696
- PageClass = pages.create
3697
- mode = 'create'
3698
- } else {
3699
- if (!pages.edit) return null
3700
- PageClass = pages.edit
3701
- mode = 'edit'
3702
- recordId = scope.recordId
3703
- baseCtxExtras = { recordId }
3704
- if (R.model) {
3705
- try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
3706
- } else if (recordId) {
3707
- record = { id: recordId }
3708
- }
3709
- }
3710
- } else if (scope.kind === 'global-edit') {
3711
- const G = cfg.globals.find(g => g.getSlug() === scope.slug)
3712
- if (!G) return null
3713
- const pages = G.resolvePages()
3714
- if (!pages.edit) return null
3715
- PageClass = pages.edit
3716
- mode = 'edit'
3717
- } else {
3718
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
3719
- if (!P) return null
3720
- PageClass = P
3721
- // Custom pages don't have a record/edit-mode concept — pass mode
3722
- // 'edit' so resolveSchema treats fields as form inputs (not table
3723
- // cells / view-mode read-only).
3724
- mode = 'edit'
3725
- }
3726
-
3727
- if (!PageClass) return null
3728
-
3729
- const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
3730
- const elements = await callPageSchema(PageClass, baseCtx)
3731
- const form = selectFormById(findForms(elements), body.formId)
3732
- if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
3733
-
3734
- const update = await applyStateUpdate(form, body.values, body.changed, {
3735
- ...(record !== undefined ? { record } : {}),
3736
- ...(user !== null ? { user } : {}),
3737
- request: req,
3738
- })
3739
- if (!update) {
3740
- return { ok: false, status: 422, error: `Field "${body.changed}" not found on form "${body.formId}"` }
3741
- }
3742
-
3743
- // Re-resolve the form with the mutated values bound. We bind
3744
- // `$get / $set` against the post-update values map so further
3745
- // resolve-time logic (SelectField.options(fn), reactive
3746
- // visibility) reads current state.
3747
- const $get = (name: string): unknown => update.values[name]
3748
- // $set on the resolve pass is a no-op — only afterStateUpdated
3749
- // mutations survive into the response. Resolve-time `$set` would
3750
- // race against the client's view of the world.
3751
- const $set = (_name: string, _v: unknown): void => { /* intentional no-op */ }
3752
-
3753
- const resolveCtx = {
3754
- ...baseCtx,
3755
- values: update.values,
3756
- $get,
3757
- $set,
3758
- changed: body.changed,
3759
- ...(record !== undefined ? { record } : {}),
3760
- }
3761
- // Snapshot values onto the form so its FormMeta carries them.
3762
- form.withValues(update.values)
3763
- const resolved = await resolveSchema([form], resolveCtx)
3764
- const formMeta = resolved[0]
3765
- if (!formMeta || formMeta.type !== 'form') {
3766
- return { ok: false, status: 422, error: 'Form re-resolved to non-form meta' }
3767
- }
3768
-
3769
- return { ok: true, form: formMeta, dirty: update.dirty }
3770
- }
3771
-
3772
- // ─── Plan #8 wizard step-validate data builder ────────────────
3773
-
3774
- export interface FormWizardRequest {
3775
- formId: string
3776
- step: number
3777
- values: Record<string, unknown>
3778
- }
3779
-
3780
- export interface FormWizardSuccess {
3781
- ok: true
3782
- }
3783
-
3784
- export interface FormWizardFailure {
3785
- ok: false
3786
- status: 404 | 422
3787
- error?: string
3788
- errors?: Record<string, string[]>
3789
- }
3790
-
3791
- /**
3792
- * Plan #8 — handle a Wizard step-validate POST. Locates the form by id,
3793
- * walks to the Wizard descendant, validates only the fields inside step
3794
- * `step` against `values`. Returns `{ ok: true }` on success or
3795
- * `{ ok: false, status: 422, errors }` when fields fail validation.
3796
- *
3797
- * Errors are keyed by field name, same shape as the form-submit 422 path,
3798
- * so the client (`FormStateApi.applyErrors`) can surface them in-place.
3799
- */
3800
- export async function formWizardData(
3801
- pilotiq: Pilotiq,
3802
- scope: FormStateScope,
3803
- body: FormWizardRequest,
3804
- req?: unknown,
3805
- ): Promise<FormWizardSuccess | FormWizardFailure | null> {
3806
- const cfg = pilotiq.getConfig()
3807
- const user = await pilotiq.resolveUser(req)
3808
-
3809
- let PageClass: typeof Page | undefined
3810
- let mode: 'create' | 'edit'
3811
- let record: unknown = undefined
3812
- let baseCtxExtras: Record<string, unknown> = {}
3813
-
3814
- if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
3815
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
3816
- if (!R) return null
3817
- const pages = R.resolvePages()
3818
- if (scope.kind === 'resource-create') {
3819
- if (!pages.create) return null
3820
- PageClass = pages.create
3821
- mode = 'create'
3822
- } else {
3823
- if (!pages.edit) return null
3824
- PageClass = pages.edit
3825
- mode = 'edit'
3826
- baseCtxExtras = { recordId: scope.recordId }
3827
- if (R.model) {
3828
- try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
3829
- } else {
3830
- record = { id: scope.recordId }
3831
- }
3832
- }
3833
- } else if (scope.kind === 'global-edit') {
3834
- const G = cfg.globals.find(g => g.getSlug() === scope.slug)
3835
- if (!G) return null
3836
- const pages = G.resolvePages()
3837
- if (!pages.edit) return null
3838
- PageClass = pages.edit
3839
- mode = 'edit'
3840
- } else {
3841
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
3842
- if (!P) return null
3843
- PageClass = P
3844
- mode = 'edit'
3845
- }
3846
-
3847
- if (!PageClass) return null
3848
-
3849
- const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
3850
- const elements = await callPageSchema(PageClass, baseCtx)
3851
- const form = selectFormById(findForms(elements), body.formId)
3852
- if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
3853
-
3854
- const formChildren = form.getChildren() ?? []
3855
- const step = findWizardStep(formChildren, body.step)
3856
- if (!step) return { ok: false, status: 404, error: `Step ${body.step} not found on form "${body.formId}"` }
3857
-
3858
- // Step.beforeValidation — runs before validators. May mutate `body.values`
3859
- // in place (the validator reads from the same object), or throw to halt
3860
- // with a 422 stamped under the reserved `_step` key.
3861
- type StepHook = (values: Record<string, unknown>, ctx: { record?: unknown; user?: unknown }) => void | Promise<void>
3862
- const stepHooks = step as {
3863
- getBeforeValidation?: () => StepHook | undefined
3864
- getAfterValidation?: () => StepHook | undefined
3865
- }
3866
- const beforeHook = stepHooks.getBeforeValidation?.call(step)
3867
- if (beforeHook) {
3868
- try { await beforeHook(body.values, { record, user }) }
3869
- catch (err) {
3870
- return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
3871
- }
3872
- }
3873
-
3874
- const errors = await validateSchema(step.getChildren() ?? [], body.values, record)
3875
- if (Object.keys(errors).length > 0) {
3876
- return { ok: false, status: 422, errors }
3877
- }
3878
-
3879
- // Step.afterValidation — fires only when validators pass. Same throw →
3880
- // 422 contract as beforeValidation.
3881
- const afterHook = stepHooks.getAfterValidation?.call(step)
3882
- if (afterHook) {
3883
- try { await afterHook(body.values, { record, user }) }
3884
- catch (err) {
3885
- return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
3886
- }
3887
- }
3888
-
3889
- return { ok: true }
3890
- }
3891
-
3892
- function stepHookErrorMessage(err: unknown): string {
3893
- if (err instanceof Error && err.message) return err.message
3894
- if (typeof err === 'string' && err.length > 0) return err
3895
- return 'Step validation failed'
3896
- }
3897
-
3898
- // ─── SelectField inline-create-option data builder ───────────
3899
-
3900
- export interface FormCreateOptionRequest {
3901
- formId: string
3902
- fieldName: string
3903
- values: Record<string, unknown>
3904
- }
3905
-
3906
- export interface FormCreateOptionSuccess {
3907
- ok: true
3908
- option: { value: string; label: string }
3909
- }
3910
-
3911
- export interface FormCreateOptionFailure {
3912
- ok: false
3913
- status: 403 | 404 | 422 | 500
3914
- error?: string
3915
- errors?: Record<string, string[]>
3916
- }
3917
-
3918
- /** Find a `SelectField` by name inside a form's children, walking through
3919
- * layout containers but stopping at Repeater / Builder boundaries
3920
- * (parallel to `tagSelectCreateOptionUrls`'s walker). Returns the first
3921
- * match or `undefined`. */
3922
- function findSelectFieldByName(elements: Element[], name: string): SelectField | undefined {
3923
- for (const el of elements) {
3924
- if (el instanceof SelectField) {
3925
- if (el.name === name) return el
3926
- continue
3927
- }
3928
- if (el instanceof RepeaterField) continue
3929
- if (el instanceof BuilderField) continue
3930
- const children = el.getChildren()
3931
- if (children && children.length > 0) {
3932
- const found = findSelectFieldByName(children as Element[], name)
3933
- if (found) return found
3934
- }
3935
- }
3936
- return undefined
3937
- }
3938
-
3939
- /**
3940
- * Audit row 2026-05-07 cont'd⁸ — handle a `SelectField.createOptionForm()`
3941
- * modal submit. Locates the parent form by `formId`, finds the SelectField
3942
- * by `fieldName`, re-evaluates the `createOptionAuthorize` rule (so a
3943
- * tampered URL can't bypass), coerces + validates the body against the
3944
- * sub-form's fields, then calls `createOptionUsing(handler)` and returns
3945
- * `{ option }` for the client to append + select.
3946
- *
3947
- * Returns `null` when the route prefix doesn't resolve to a real
3948
- * resource/global/page (route handler turns into 404).
3949
- */
3950
- export async function formCreateOptionData(
3951
- pilotiq: Pilotiq,
3952
- scope: FormStateScope,
3953
- body: FormCreateOptionRequest,
3954
- req?: unknown,
3955
- ): Promise<FormCreateOptionSuccess | FormCreateOptionFailure | null> {
3956
- const cfg = pilotiq.getConfig()
3957
- const user = await pilotiq.resolveUser(req)
3958
-
3959
- let PageClass: typeof Page | undefined
3960
- let mode: 'create' | 'edit'
3961
- let record: unknown = undefined
3962
- let baseCtxExtras: Record<string, unknown> = {}
3963
-
3964
- if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
3965
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
3966
- if (!R) return null
3967
- const pages = R.resolvePages()
3968
- if (scope.kind === 'resource-create') {
3969
- if (!pages.create) return null
3970
- PageClass = pages.create
3971
- mode = 'create'
3972
- } else {
3973
- if (!pages.edit) return null
3974
- PageClass = pages.edit
3975
- mode = 'edit'
3976
- baseCtxExtras = { recordId: scope.recordId }
3977
- if (R.model) {
3978
- try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
3979
- } else {
3980
- record = { id: scope.recordId }
3981
- }
3982
- }
3983
- } else if (scope.kind === 'global-edit') {
3984
- const G = cfg.globals.find(g => g.getSlug() === scope.slug)
3985
- if (!G) return null
3986
- const pages = G.resolvePages()
3987
- if (!pages.edit) return null
3988
- PageClass = pages.edit
3989
- mode = 'edit'
3990
- } else {
3991
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
3992
- if (!P) return null
3993
- PageClass = P
3994
- mode = 'edit'
3995
- }
3996
-
3997
- if (!PageClass) return null
3998
-
3999
- const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
4000
- const elements = await callPageSchema(PageClass, baseCtx)
4001
- const form = selectFormById(findForms(elements), body.formId)
4002
- if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
4003
-
4004
- const field = findSelectFieldByName(form.getChildren() as Element[] ?? [], body.fieldName)
4005
- if (!field) return { ok: false, status: 404, error: `SelectField "${body.fieldName}" not found on form "${body.formId}"` }
4006
- if (!field.hasCreateOption()) return { ok: false, status: 404, error: `SelectField "${body.fieldName}" does not configure createOptionForm()` }
4007
-
4008
- const createForm = field.getCreateOptionForm()!
4009
- const handler = field.getCreateOptionHandler()
4010
- if (!handler) {
4011
- return { ok: false, status: 500, error: `SelectField "${body.fieldName}" has createOptionForm() but no createOptionUsing() handler` }
4012
- }
4013
-
4014
- // Re-evaluate authorize. Build the same ActionVisibilityContext shape
4015
- // the field's `toMeta` did — keeps server / meta-build paths consistent.
4016
- const authorize = field.getCreateOptionAuthorize()
4017
- if (authorize !== undefined) {
4018
- const authVisible = await (async () => {
4019
- if (typeof authorize !== 'function') return authorize
4020
- const visCtx: import('./actions/Action.js').ActionVisibilityContext = {}
4021
- if (record !== undefined) visCtx.record = record
4022
- if (user !== null ) visCtx.user = user
4023
- try { return await authorize(visCtx) } catch { return false }
4024
- })()
4025
- if (!authVisible) return { ok: false, status: 403, error: 'createOptionAuthorize denied' }
4026
- }
4027
-
4028
- // Coerce + validate body against the sub-form's fields. The createOption
4029
- // sub-schema is detached from the parent form so we run it against its
4030
- // own children only — coerceFormValues mutates `out` to normalize toggle
4031
- // / number / date / etc. shapes (same shape parent forms use).
4032
- const coerced = coerceFormValues(createForm, { ...body.values })
4033
- const errors = await validateSchema(createForm, coerced, undefined)
4034
- if (Object.keys(errors).length > 0) {
4035
- return { ok: false, status: 422, errors }
4036
- }
4037
-
4038
- const ctx: RenderContext = {
4039
- ...baseCtx,
4040
- values: coerced,
4041
- ...(record !== undefined ? { record } : {}),
4042
- }
4043
- let option: { value: string; label: string }
4044
- try {
4045
- option = await handler(coerced, ctx)
4046
- } catch (e) {
4047
- return { ok: false, status: 500, error: e instanceof Error ? e.message : String(e) }
4048
- }
4049
-
4050
- if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') {
4051
- return { ok: false, status: 500, error: `createOptionUsing must return { value: string, label: string }` }
4052
- }
4053
-
4054
- return { ok: true, option }
4055
- }
4056
-
4057
- // ─── Async-mention resolve data builder ──────────────────────
4058
-
4059
- export interface MentionResolveRequest {
4060
- formId: string
4061
- field: string
4062
- trigger: string
4063
- query: string
4064
- }
4065
-
4066
- /** Wire-side shape for a single resolved item — mirrors `MentionItem` from
4067
- * `@pilotiq/tiptap`. Pilotiq core doesn't import that package, so the
4068
- * duck-typed shape lives here. */
4069
- export interface MentionResolveItem {
4070
- id: string
4071
- label: string
4072
- group?: string
4073
- }
4074
-
4075
- export interface MentionResolveSuccess {
4076
- ok: true
4077
- items: MentionResolveItem[]
4078
- }
4079
-
4080
- export interface MentionResolveError {
4081
- ok: false
4082
- status: 404 | 422
4083
- error: string
4084
- }
4085
-
4086
- interface AsyncMentionResolverField {
4087
- resolveMention(
4088
- trigger: string,
4089
- query: string,
4090
- ctx: { user?: unknown; record?: unknown; request?: unknown },
4091
- ): Promise<MentionResolveItem[] | null>
4092
- }
4093
-
4094
- function isMentionResolverField(el: Element): el is Element & AsyncMentionResolverField {
4095
- if (el.getType() !== 'richtext') return false
4096
- const candidate = el as unknown as Partial<AsyncMentionResolverField>
4097
- return typeof candidate.resolveMention === 'function'
4098
- }
4099
-
4100
- /**
4101
- * Walk a form's tree looking for the named field. Descends into Repeater /
4102
- * Builder rows when the requested name carries the row-prefix shape:
4103
- *
4104
- * - Repeater rows: `<repeaterName>.<index>.<innerPath>` — looks up
4105
- * `<innerPath>` against the Repeater's template schema. Field config
4106
- * (providers, async resolver) is shared across rows, so any row index
4107
- * resolves to the same template field.
4108
- * - Builder rows: `<builderName>.<index>.data.<innerPath>` — looks up
4109
- * `<innerPath>` against every block's schema; first match wins. Block
4110
- * schemas often share leaf names — if two blocks define a RichTextField
4111
- * with the same name and different async-mention providers, only the
4112
- * first block in declaration order is reachable here. Authors needing
4113
- * per-block resolution should give the leaves distinct names.
4114
- *
4115
- * Mirrors the boundary-stopping posture of `findFieldByName` inside
4116
- * `dispatchForm.ts` for top-level matches — only the dotted-prefix branch
4117
- * crosses into row schemas.
4118
- */
4119
- function findRichTextFieldByName(
4120
- elements: ReadonlyArray<Element>,
4121
- name: string,
4122
- ): (Element & AsyncMentionResolverField) | undefined {
4123
- for (const el of elements) {
4124
- if (isMentionResolverField(el) && (el as unknown as { name: string }).name === name) {
4125
- return el
4126
- }
4127
- if (isRepeaterField(el)) {
4128
- const inner = stripRepeaterRowPrefix(name, (el as RepeaterField).name)
4129
- if (inner !== undefined) {
4130
- const hit = findRichTextFieldByName((el as RepeaterField).getInnerSchema(), inner)
4131
- if (hit) return hit
4132
- }
4133
- continue
4134
- }
4135
- if (isBuilderField(el)) {
4136
- const inner = stripBuilderRowPrefix(name, (el as BuilderField).name)
4137
- if (inner !== undefined) {
4138
- for (const block of (el as BuilderField).getBlocks()) {
4139
- const hit = findRichTextFieldByName(block.getSchema(), inner)
4140
- if (hit) return hit
4141
- }
4142
- }
4143
- continue
4144
- }
4145
- const children = el.getChildren()
4146
- if (children && children.length > 0) {
4147
- const hit = findRichTextFieldByName(children, name)
4148
- if (hit) return hit
4149
- }
4150
- }
4151
- return undefined
4152
- }
4153
-
4154
- /**
4155
- * `items.0.body` → `body`. Returns `undefined` when the path doesn't match
4156
- * the `<repeaterName>.<digits>.<rest>` shape so the walker keeps searching
4157
- * other branches instead of misinterpreting an unrelated dotted name.
4158
- */
4159
- function stripRepeaterRowPrefix(path: string, repeaterName: string): string | undefined {
4160
- const parts = path.split('.')
4161
- if (parts.length < 3) return undefined
4162
- if (parts[0] !== repeaterName) return undefined
4163
- if (!/^\d+$/.test(parts[1] ?? '')) return undefined
4164
- return parts.slice(2).join('.')
4165
- }
4166
-
4167
- /**
4168
- * `blocks.0.data.heading` → `heading`. The literal `data` segment matches
4169
- * Builder's wire shape (`{ __id, type, data: {…} }`) and distinguishes a
4170
- * Builder leaf from a Repeater leaf at the same depth.
4171
- */
4172
- function stripBuilderRowPrefix(path: string, builderName: string): string | undefined {
4173
- const parts = path.split('.')
4174
- if (parts.length < 4) return undefined
4175
- if (parts[0] !== builderName) return undefined
4176
- if (!/^\d+$/.test(parts[1] ?? '')) return undefined
4177
- if (parts[2] !== 'data') return undefined
4178
- return parts.slice(3).join('.')
4179
- }
4180
-
4181
- /**
4182
- * Resolve one async-mention round-trip. Locates the page's schema, finds
4183
- * the form by `formId` and the RichTextField by `field`, calls its
4184
- * `resolveMention(trigger, query, ctx)`. Returns `{ ok, items }`, a 404
4185
- * when the form / field / trigger isn't present, or `null` for a missing
4186
- * page (the route handler turns `null` into a 404 too).
4187
- *
4188
- * The dispatcher is duck-typed against the contract in `@pilotiq/tiptap`'s
4189
- * `RichTextField` — pilotiq core never imports the adapter. Any future
4190
- * field-type that ships an async-resolve trigger can implement the same
4191
- * shape and pick up routing for free.
4192
- */
4193
- export async function mentionResolveData(
4194
- pilotiq: Pilotiq,
4195
- scope: FormStateScope,
4196
- body: MentionResolveRequest,
4197
- req?: unknown,
4198
- ): Promise<MentionResolveSuccess | MentionResolveError | null> {
4199
- const cfg = pilotiq.getConfig()
4200
- const user = await pilotiq.resolveUser(req)
4201
-
4202
- let PageClass: typeof Page | undefined
4203
- let mode: 'create' | 'edit'
4204
- let record: unknown = undefined
4205
- let baseCtxExtras: Record<string, unknown> = {}
4206
-
4207
- if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
4208
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
4209
- if (!R) return null
4210
- const pages = R.resolvePages()
4211
- if (scope.kind === 'resource-create') {
4212
- if (!pages.create) return null
4213
- PageClass = pages.create
4214
- mode = 'create'
4215
- } else {
4216
- if (!pages.edit) return null
4217
- PageClass = pages.edit
4218
- mode = 'edit'
4219
- baseCtxExtras = { recordId: scope.recordId }
4220
- if (R.model) {
4221
- try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
4222
- } else {
4223
- record = { id: scope.recordId }
4224
- }
4225
- }
4226
- } else if (scope.kind === 'global-edit') {
4227
- const G = cfg.globals.find(g => g.getSlug() === scope.slug)
4228
- if (!G) return null
4229
- const pages = G.resolvePages()
4230
- if (!pages.edit) return null
4231
- PageClass = pages.edit
4232
- mode = 'edit'
4233
- } else {
4234
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
4235
- if (!P) return null
4236
- PageClass = P
4237
- mode = 'edit'
4238
- }
4239
-
4240
- if (!PageClass) return null
4241
-
4242
- const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
4243
- const elements = await callPageSchema(PageClass, baseCtx)
4244
- const form = selectFormById(findForms(elements), body.formId)
4245
- if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
4246
-
4247
- const field = findRichTextFieldByName(form.getChildren() ?? [], body.field)
4248
- if (!field) {
4249
- return { ok: false, status: 404, error: `Rich-text field "${body.field}" not found on form "${body.formId}"` }
4250
- }
4251
-
4252
- let items: MentionResolveItem[] | null
4253
- try {
4254
- items = await field.resolveMention(body.trigger, body.query, {
4255
- ...(record !== undefined ? { record } : {}),
4256
- ...(user !== null ? { user } : {}),
4257
- request: req,
4258
- })
4259
- } catch (err) {
4260
- return {
4261
- ok: false,
4262
- status: 422,
4263
- error: err instanceof Error ? err.message : 'Mention resolver threw',
4264
- }
4265
- }
4266
-
4267
- if (items === null) {
4268
- return { ok: false, status: 404, error: `No mention provider for trigger "${body.trigger}" on field "${body.field}"` }
4269
- }
4270
-
4271
- return { ok: true, items }
4272
- }
4273
-
4274
- export async function resourceViewData(
4275
- pilotiq: Pilotiq,
4276
- slug: string,
4277
- recordId: string,
4278
- req?: unknown,
4279
- ): Promise<Record<string, unknown> | null> {
4280
- const cfg = pilotiq.getConfig()
4281
- const R = cfg.resources.find(r => r.getSlug() === slug)
4282
- if (!R) return null
4283
- const pages = R.resolvePages()
4284
- if (!pages.view) return null
4285
- const PageClass = pages.view
4286
-
4287
- const user = await pilotiq.resolveUser(req)
4288
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
4289
- const elements = await callPageSchema(PageClass, ctx)
4290
- // For the view page we want the record threaded into resolveSchema so
4291
- // factory-attached visibility predicates see it. Resource.detail()
4292
- // already runs against the loaded record in user code; here we mirror
4293
- // that into ctx.record for the action eval pass.
4294
- let record: unknown = undefined
4295
- if (R.model) {
4296
- try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
4297
- }
4298
-
4299
- // Plan #11 — prepend the relation tabs strip with the "Details" tab
4300
- // active when the resource has relation managers configured.
4301
- const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record)
4302
- if (relationTabsEl) elements.unshift(relationTabsEl)
4303
-
4304
- const recordTitle = record !== undefined && record !== null
4305
- ? deriveParentTitle(R, record)
4306
- : recordId
4307
- const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
4308
- if (breadcrumbs) elements.unshift(breadcrumbs)
4309
-
4310
- const viewRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
4311
- const schemaData = await applyRoleHooks(
4312
- pilotiq, user, 'view',
4313
- await resolveSchema(
4314
- elements,
4315
- record !== undefined ? { ...ctx, record } : ctx,
4316
- ),
4317
- viewRoute,
4318
- )
4319
-
4320
- return {
4321
- panel: await panelInfo(pilotiq, req, viewRoute),
4322
- page: PageClass.toMeta(),
4323
- resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
4324
- mode: 'view' as const,
4325
- recordId,
4326
- basePath: cfg.path,
4327
- layout: cfg.layout,
4328
- schemaData,
4329
- notifications: consumeFlashedNotifications(req),
4330
- }
4331
- }
4332
-
4333
- /**
4334
- * Custom record sub-page data builder. Mounted at
4335
- * `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
4336
- * `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
4337
- * the record, run R.canAccess + R.canView (parent-resource gates),
4338
- * then SubPage.canAccess(user, record) (sub-page-specific gate),
4339
- * then render the sub-page's schema with `ctx.record` set. Tab strip
4340
- * carries the sub-page slug as the active key so the matching record
4341
- * sub-page tab highlights.
4342
- *
4343
- * Returns:
4344
- * - `null` — resource / sub-page slug not found (404 upstream).
4345
- * - `{ ok: false, status: 403 }` — any gate fails or throws.
4346
- * - resolved page data — on success.
4347
- */
4348
- export async function resourceRecordPageData(
4349
- pilotiq: Pilotiq,
4350
- slug: string,
4351
- recordId: string,
4352
- subPageSlug: string,
4353
- req?: unknown,
4354
- ): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
4355
- const cfg = pilotiq.getConfig()
4356
- const R = cfg.resources.find(r => r.getSlug() === slug)
4357
- if (!R) return null
4358
- const recordPages = R.getRecordPages()
4359
- const PageClass = recordPages[subPageSlug]
4360
- if (!PageClass) return null
4361
-
4362
- const user = await pilotiq.resolveUser(req)
4363
-
4364
- // Load the parent record before gating so canView / SubPage.canAccess
4365
- // can branch on record state. Sub-pages without a Resource.model
4366
- // still get gated against an `undefined` record — the same posture as
4367
- // resourceViewData when no model is bound.
4368
- let record: unknown = undefined
4369
- if (R.model) {
4370
- try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
4371
- }
4372
- if (record === undefined || record === null) {
4373
- // Distinguish "model bound but record missing" (route should 404)
4374
- // from "no model bound" (treat record as `{ id: recordId }` so the
4375
- // page can still render — same convention as the edit page).
4376
- if (R.model) return null
4377
- record = { id: recordId }
4378
- }
4379
-
4380
- // Three gates: parent resource access + view, then the sub-page's own
4381
- // canAccess. The route would have run R.canAccess upstream, but
4382
- // re-running here makes resourceRecordPageData safe to call from
4383
- // dispatchPageData (where the SPA path skips the route prelude).
4384
- if (!await safeBool(() => R.canAccess(user))) return { ok: false, status: 403 }
4385
- if (!await safeBool(() => R.canView(user, record))) return { ok: false, status: 403 }
4386
- if (!await safeBool(() => PageClass.canAccess(user, record))) return { ok: false, status: 403 }
4387
-
4388
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
4389
- const elements = await callPageSchema(PageClass, ctx)
4390
-
4391
- // Insert the relation-tabs strip with the sub-page slug active so the
4392
- // matching tab highlights. `buildRelationTabs` evaluates per-tab
4393
- // gating against `user + record` — record sub-page tabs are gated
4394
- // alongside __view/__edit/managers.
4395
- const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record)
4396
- if (relationTabsEl) elements.unshift(relationTabsEl)
4397
-
4398
- const recordTitle = record !== undefined && record !== null
4399
- ? deriveParentTitle(R, record)
4400
- : recordId
4401
- const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
4402
- if (breadcrumbs) elements.unshift(breadcrumbs)
4403
-
4404
- const recordPageRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
4405
- const schemaData = await applyRoleHooks(
4406
- pilotiq, user, 'view',
4407
- await resolveSchema(
4408
- elements,
4409
- record !== undefined ? { ...ctx, record } : ctx,
4410
- ),
4411
- recordPageRoute,
4412
- )
4413
-
4414
- return {
4415
- pageType: 'record-page' as const,
4416
- panel: await panelInfo(pilotiq, req, recordPageRoute),
4417
- page: PageClass.toMeta(),
4418
- resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
4419
- mode: 'record' as const,
4420
- recordId,
4421
- subPage: { slug: subPageSlug, label: PageClass.getLabel() },
4422
- basePath: cfg.path,
4423
- layout: cfg.layout,
4424
- schemaData,
4425
- notifications: consumeFlashedNotifications(req),
4426
- }
4427
- }
4428
-
4429
- export async function globalEditData(
4430
- pilotiq: Pilotiq,
4431
- slug: string,
4432
- prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
4433
- req?: unknown,
4434
- ): Promise<Record<string, unknown> | null> {
4435
- const cfg = pilotiq.getConfig()
4436
- const G = cfg.globals.find(g => g.getSlug() === slug)
4437
- if (!G) return null
4438
- const pages = G.resolvePages()
4439
- if (!pages.edit) return null
4440
- const PageClass = pages.edit
4441
-
4442
- const editUrl = globalBasePath(cfg.path, G)
4443
- const user = await pilotiq.resolveUser(req)
4444
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'edit', basePath: cfg.path }, user), cfg)
4445
- const elements = await callPageSchema(PageClass, ctx)
4446
- tagFormActions(elements, editUrl)
4447
- tagFormStateUrls(elements, formId => `${editUrl}/_form/${formId}/state`)
4448
- tagFormWizardUrls(elements, formId => `${editUrl}/_form/${formId}/wizard`)
4449
- tagRichTextMentionUrls(elements, formId => `${editUrl}/_form/${formId}/mentions`)
4450
- tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${editUrl}/_form/${formId}/create-option/${fieldName}`)
4451
-
4452
- const form = findForms(elements)[0]
4453
- let record: unknown = undefined
4454
- if (form?.getLoadRecord()) {
4455
- try { record = await form.getLoadRecord()!('', { values: prefill?.values ?? {} }) } catch { /* ignore */ }
4456
- if (!prefill?.values && record != null) {
4457
- const values = await applyFillPipeline(form, record)
4458
- form.withValues(values)
4459
- } else if (prefill?.values) {
4460
- form.withValues(prefill.values)
4461
- }
4462
- if (prefill?.errors) form.withErrors(prefill.errors)
4463
- }
4464
-
4465
- const breadcrumbs = globalBreadcrumbs(cfg, G)
4466
- if (breadcrumbs) elements.unshift(breadcrumbs)
4467
-
4468
- const globalEditRoute: PanelInfoRoute = { global: G, page: PageClass }
4469
- const schemaData = await applyRoleHooks(
4470
- pilotiq, user, 'global-edit',
4471
- await resolveSchema(
4472
- elements,
4473
- record !== undefined ? { ...ctx, record } : ctx,
4474
- ),
4475
- globalEditRoute,
4476
- )
4477
-
4478
- return {
4479
- pageType: 'global',
4480
- panel: await panelInfo(pilotiq, req, globalEditRoute),
4481
- page: PageClass.toMeta(),
4482
- global: { name: G.name, label: G.label, labelSingular: G.labelSingular, slug, icon: serializeIcon(G.icon, G.name) },
4483
- basePath: cfg.path,
4484
- layout: cfg.layout,
4485
- schemaData,
4486
- notifications: consumeFlashedNotifications(req),
4487
- ...(prefill?.errors ? { hasErrors: true } : {}),
4488
- }
4489
- }
4490
-
4491
- export async function globalViewData(
4492
- pilotiq: Pilotiq,
4493
- slug: string,
4494
- req?: unknown,
4495
- ): Promise<Record<string, unknown> | null> {
4496
- const cfg = pilotiq.getConfig()
4497
- const G = cfg.globals.find(g => g.getSlug() === slug)
4498
- if (!G) return null
4499
- const pages = G.resolvePages()
4500
- if (!pages.view) return null
4501
- const PageClass = pages.view
4502
-
4503
- const user = await pilotiq.resolveUser(req)
4504
- const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', basePath: cfg.path }, user), cfg)
4505
- const elements = await callPageSchema(PageClass, ctx)
4506
-
4507
- const breadcrumbs = globalBreadcrumbs(cfg, G)
4508
- if (breadcrumbs) elements.unshift(breadcrumbs)
4509
-
4510
- const globalViewRoute: PanelInfoRoute = { global: G, page: PageClass }
4511
- const schemaData = await applyRoleHooks(
4512
- pilotiq, user, 'global-view',
4513
- await resolveSchema(elements, ctx),
4514
- globalViewRoute,
4515
- )
4516
-
4517
- return {
4518
- panel: await panelInfo(pilotiq, req, globalViewRoute),
4519
- page: PageClass.toMeta(),
4520
- global: { name: G.name, label: G.label, labelSingular: G.labelSingular, slug, icon: serializeIcon(G.icon, G.name) },
4521
- basePath: cfg.path,
4522
- layout: cfg.layout,
4523
- schemaData,
4524
- notifications: consumeFlashedNotifications(req),
4525
- }
4526
- }
4527
-
4528
- export async function customPageData(
4529
- pilotiq: Pilotiq,
4530
- pageSlug: string,
4531
- req?: unknown,
4532
- ): Promise<Record<string, unknown> | null> {
4533
- const cfg = pilotiq.getConfig()
4534
- const PageClass = cfg.pages.find(P => P.getSlug() === pageSlug)
4535
- if (!PageClass) return null
4536
-
4537
- const pageUrl = pageBasePath(cfg.path, PageClass)
4538
- const user = await pilotiq.resolveUser(req)
4539
- const ctx: SchemaContext = uploadCtx(userCtx({}, user), cfg)
4540
- const elements = await callPageSchema(PageClass, ctx)
4541
- tagFormActions(elements, pageUrl)
4542
- tagFormStateUrls(elements, formId => `${pageUrl}/_form/${formId}/state`)
4543
- tagFormWizardUrls(elements, formId => `${pageUrl}/_form/${formId}/wizard`)
4544
- tagRichTextMentionUrls(elements, formId => `${pageUrl}/_form/${formId}/mentions`)
4545
- tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${pageUrl}/_form/${formId}/create-option/${fieldName}`)
4546
- tagActionDispatch(elements, pageUrl)
4547
- // Page-scope polling URL (mirrors `${base}/${pageSlug}/_widget/:id`
4548
- // route registered in routes.ts).
4549
- tagWidgetUrls(elements, id => `${pageUrl}/_widget/${id}`)
4550
- const widgetData = await resolveServerDataElements(elements, ctx)
4551
-
4552
- const breadcrumbs = customPageBreadcrumbs(cfg, PageClass)
4553
- if (breadcrumbs) elements.unshift(breadcrumbs)
4554
-
4555
- const customRoute: PanelInfoRoute = { page: PageClass }
4556
- const schemaData = await applyRoleHooks(
4557
- pilotiq, user, 'page',
4558
- await resolveSchema(elements, ctx),
4559
- customRoute,
4560
- )
4561
-
4562
- return {
4563
- pageType: 'page',
4564
- panel: await panelInfo(pilotiq, req, customRoute),
4565
- page: PageClass.toMeta(),
4566
- schemaData,
4567
- _widgetData: widgetData,
4568
- basePath: cfg.path,
4569
- layout: cfg.layout,
4570
- notifications: consumeFlashedNotifications(req),
4571
- }
4572
- }
4573
-
4574
- // ─── Plan #15 widget polling data builder ────────────────────
4575
-
4576
- /**
4577
- * Scopes the polling endpoint resolves against. Mirrors the
4578
- * form-state / wizard scope discriminator.
4579
- *
4580
- * panel: dashboard page (`POST {base}/_widget/:id`)
4581
- * page: custom page (`POST {base}/{pageSlug}/_widget/:id`)
4582
- * resource: list page (`POST {base}/{slug}/_widget/:id`) —
4583
- * resolves the resource's index `Page.schema()` so widgets
4584
- * from `Resource.headerSchema()` / `footerSchema()` are
4585
- * reachable. Auth runs `R.canAccess + R.canViewAny` in
4586
- * front of the per-widget visibility check.
4587
- */
4588
- export type WidgetScope =
4589
- | { kind: 'panel' }
4590
- | { kind: 'page'; pageSlug: string }
4591
- | { kind: 'resource'; slug: string }
4592
-
4593
- export interface WidgetRequest {
4594
- id: string
4595
- filter?: string
4596
- }
14
+ // Re-export `RelationChainStep` so external callsites importing it via
15
+ // `./pageData.js` keep working.
16
+ export type { RelationChainStep } from './pageData/breadcrumbs.js'
17
+
18
+ // Re-export `ServerDataMap` so external imports via `./pageData.js` keep
19
+ // working — the type is also surfaced from `packages/pilotiq/src/index.ts`.
20
+ export type { ServerDataMap } from './pageData/helpers.js'
21
+
22
+ // Re-export the URL-tag helpers + fill pipeline + server-data resolver
23
+ // for consumers that import them through `./pageData.js`.
24
+ export {
25
+ applyFillPipeline,
26
+ applyRelationshipBuilderFill,
27
+ applyRelationshipRepeaterFill,
28
+ callPageSchema,
29
+ resolveServerDataElements,
30
+ tagActionDispatch,
31
+ tagCellEditUrls,
32
+ tagFieldAiUrls,
33
+ tagFormActions,
34
+ tagFormStateUrls,
35
+ tagFormWizardUrls,
36
+ tagRichTextMentionUrls,
37
+ tagSelectCreateOptionUrls,
38
+ tagTableDeferred,
39
+ tagTableReorderUrls,
40
+ tagWidgetUrls,
41
+ } from './pageData/helpers.js'
42
+
43
+ // Re-export navigation chrome surface so external callsites importing
44
+ // it via `./pageData.js` keep working (e.g. routes/test harnesses).
45
+ export type {
46
+ DatabaseNotificationsMeta,
47
+ NavItem,
48
+ PanelInfoRoute,
49
+ RightPanelMeta,
50
+ RightSidebarMeta,
51
+ UserMenuMeta,
52
+ } from './pageData/navigation.js'
53
+ export {
54
+ applyRoleHooks,
55
+ panelInfo,
56
+ resolvePageHooks,
57
+ } from './pageData/navigation.js'
4597
58
 
4598
- export interface WidgetSuccess {
4599
- ok: true
4600
- data: unknown
4601
- timestamp: number
4602
- }
4603
-
4604
- export interface WidgetFailure {
4605
- ok: false
4606
- status: 403 | 404 | 500
4607
- error: string
4608
- }
4609
-
4610
- /**
4611
- * Plan #15 — re-resolve the active page's schema, find the widget by
4612
- * id, fail-closed via `evaluateVisibility`, then run
4613
- * `resolveServerData(ctx)` and return the payload.
4614
- *
4615
- * - 404 when the page or widget id doesn't exist.
4616
- * - 403 when the layout-level `visible(rule)` says the widget is
4617
- * hidden (server doesn't show data for hidden surfaces).
4618
- * - 500 when the hook itself throws.
4619
- *
4620
- * `body.filter` rides along on `RenderContext.filter` so per-chart
4621
- * filter dropdowns can re-fetch with the new filter value. Treated as
4622
- * an opaque string widget hooks decode it however they want.
4623
- */
4624
- export async function widgetData(
4625
- pilotiq: Pilotiq,
4626
- scope: WidgetScope,
4627
- body: WidgetRequest,
4628
- req?: unknown,
4629
- ): Promise<WidgetSuccess | WidgetFailure> {
4630
- const cfg = pilotiq.getConfig()
4631
- const user = await pilotiq.resolveUser(req)
4632
-
4633
- let elements: Element[]
4634
- let ctx: RenderContext
4635
-
4636
- if (scope.kind === 'panel') {
4637
- if (!cfg.dashboardPage) return { ok: false, status: 404, error: 'No dashboard page registered' }
4638
- ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
4639
- elements = await callPageSchema(cfg.dashboardPage, ctx)
4640
- } else if (scope.kind === 'page') {
4641
- const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
4642
- if (!P) return { ok: false, status: 404, error: 'Page not found' }
4643
- ctx = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
4644
- elements = await callPageSchema(P, ctx)
4645
- } else {
4646
- // Resource-scope: re-resolve the list page's schema so widgets from
4647
- // `Resource.headerSchema()` / `footerSchema()` are reachable.
4648
- const R = cfg.resources.find(r => r.getSlug() === scope.slug)
4649
- if (!R) return { ok: false, status: 404, error: 'Resource not found' }
4650
- const pages = R.resolvePages()
4651
- if (!pages.index) return { ok: false, status: 404, error: 'Resource has no list page' }
4652
- ctx = uploadCtx(userCtx({ mode: 'table', basePath: cfg.path }, user), cfg)
4653
- elements = await callPageSchema(pages.index, ctx)
4654
- }
4655
-
4656
- // Stamp the request's filter onto the render context so widget hooks
4657
- // can branch on it. Opaque string — widgets decode their own format.
4658
- if (body.filter !== undefined) ctx = { ...ctx, filter: body.filter } as RenderContext
4659
-
4660
- const widget = findWidgetById(elements, body.id)
4661
- if (!widget) return { ok: false, status: 404, error: `Widget "${body.id}" not found` }
4662
-
4663
- // Layout-level visibility re-check — if the widget is hidden by a
4664
- // visible(rule), refuse to ship data. Same fail-closed posture as
4665
- // the schema resolver. (Parent-container `visible(false)` would
4666
- // already drop the widget from the schema tree at SSR time, so a
4667
- // direct hidden-widget probe here covers the visible-rule-only case.)
4668
- const layoutCtx: import('./schema/Element.js').LayoutContext = {}
4669
- if (user !== null && user !== undefined) layoutCtx.user = user
4670
- if (!await widget.evaluateVisibility(layoutCtx)) {
4671
- return { ok: false, status: 403, error: 'Widget hidden' }
4672
- }
4673
-
4674
- try {
4675
- const data = await widget.resolveServerData(ctx)
4676
- return { ok: true, data, timestamp: Date.now() }
4677
- } catch (err) {
4678
- return {
4679
- ok: false,
4680
- status: 500,
4681
- error: err instanceof Error ? err.message : 'Widget failed',
4682
- }
4683
- }
4684
- }
4685
-
4686
- /** Walk the element tree looking for a server-data element with the
4687
- * given id. Same walker as `collectServerDataElements` but stops on
4688
- * first match. */
4689
- function findWidgetById(elements: ReadonlyArray<Element>, id: string): ServerDataElement | undefined {
4690
- let found: ServerDataElement | undefined
4691
- const walk = (els: ReadonlyArray<Element>): void => {
4692
- for (const el of els) {
4693
- if (found) return
4694
- if (isServerDataElement(el)) {
4695
- if (el.getId() === id) { found = el; return }
4696
- continue
4697
- }
4698
- const type = el.getType()
4699
- if (type === 'form' || type === 'repeater' || type === 'builder' || type === 'table' || type === 'tableWidget') continue
4700
- const children = el.getChildren()
4701
- if (children) walk(children)
4702
- }
4703
- }
4704
- walk(elements)
4705
- return found
4706
- }
59
+ import {
60
+ dashboardData,
61
+ resourceCreateData,
62
+ resourceEditData,
63
+ resourceIndexData,
64
+ resourceRecordPageData,
65
+ resourceViewData,
66
+ } from './pageData/resourcePages.js'
67
+
68
+ // Re-export resource page builders so external callsites importing
69
+ // through `./pageData.js` keep working (e.g. routes.ts handlers, tests).
70
+ export {
71
+ dashboardData,
72
+ resolveActiveTab,
73
+ resourceCreateData,
74
+ resourceEditData,
75
+ resourceIndexData,
76
+ resourceRecordPageData,
77
+ resourceTableData,
78
+ resourceViewData,
79
+ } from './pageData/resourcePages.js'
80
+
81
+ import { relationManagerData } from './pageData/relationPages.js'
82
+
83
+ // Re-export relation manager builder surface for external consumers
84
+ // (routes.ts dispatches every relation-* role through these).
85
+ export type {
86
+ RelationManagerResult,
87
+ RelationManagerScope,
88
+ ResolvedChain,
89
+ } from './pageData/relationPages.js'
90
+ export {
91
+ findRelatedResource,
92
+ relationManagerData,
93
+ resolveRelationChain,
94
+ safeManagerPolicy,
95
+ } from './pageData/relationPages.js'
96
+
97
+ // Re-export form-related builders + types for external callsites.
98
+ export type {
99
+ FormCreateOptionFailure,
100
+ FormCreateOptionRequest,
101
+ FormCreateOptionSuccess,
102
+ FormStateError,
103
+ FormStateRequest,
104
+ FormStateResult,
105
+ FormStateScope,
106
+ FormWizardFailure,
107
+ FormWizardRequest,
108
+ FormWizardSuccess,
109
+ } from './pageData/forms.js'
110
+ export {
111
+ formCreateOptionData,
112
+ formStateData,
113
+ formWizardData,
114
+ mentionResolveData,
115
+ } from './pageData/forms.js'
4707
116
 
4708
- // ─── Plan #12 global search data builder ─────────────────────
117
+ import {
118
+ customPageData,
119
+ globalEditData,
120
+ globalViewData,
121
+ } from './pageData/misc.js'
122
+
123
+ // Re-export the misc page builders for external callsites (routes.ts
124
+ // dispatches into globals / custom-page / widget / search routes).
125
+ export {
126
+ customPageData,
127
+ globalEditData,
128
+ globalViewData,
129
+ searchData,
130
+ widgetData,
131
+ } from './pageData/misc.js'
132
+ export type {
133
+ WidgetFailure,
134
+ WidgetRequest,
135
+ WidgetScope,
136
+ WidgetSuccess,
137
+ } from './pageData/misc.js'
4709
138
 
4710
- /**
4711
- * Resolve the user via `pilotiq.resolveUser(req)` and run the
4712
- * panel-wide search. Mirrors the formStateData/formWizardData
4713
- * shape so the `/_search` route handler stays a thin wrapper.
4714
- *
4715
- * Also resolves the `panels::global-search.results.before/.after`
4716
- * render hooks when the panel registered any — sparse, absent when
4717
- * neither slot has registered fns. Sent as a `RenderHookMap` so the
4718
- * client `<CommandPalette>` can mount `<RenderHookSlot>` above and
4719
- * below the result list (same pattern chrome slots use).
4720
- */
4721
- export async function searchData(
4722
- pilotiq: Pilotiq,
4723
- query: string,
4724
- req?: unknown,
4725
- ): Promise<{
4726
- ok: true
4727
- results: GlobalSearchResult[]
4728
- renderHooks?: RenderHookMap
4729
- }> {
4730
- const user = await pilotiq.resolveUser(req)
4731
- const results = await searchAllResources(pilotiq, query, user)
4732
- const cfg = pilotiq.getConfig()
4733
- const out: { ok: true; results: GlobalSearchResult[]; renderHooks?: RenderHookMap } = {
4734
- ok: true,
4735
- results,
4736
- }
4737
- if (cfg.renderHooks && cfg.renderHooks.length > 0) {
4738
- const hooks = await resolvePageHooks(
4739
- pilotiq,
4740
- user,
4741
- pageHooksFor('search'),
4742
- { url: `${cfg.path}/_search` },
4743
- )
4744
- if (Object.keys(hooks).length > 0) out.renderHooks = hooks
4745
- }
4746
- return out
4747
- }
4748
139
 
4749
140
  // ─── Vike +data dispatcher ───────────────────────────────────
4750
141