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