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