@pilotiq/pilotiq 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +142 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +26 -5
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +26 -4
@@ -0,0 +1,779 @@
1
+ import type { Pilotiq, PilotiqConfig } from '../Pilotiq.js'
2
+ import type { Page } from '../Page.js'
3
+ import type { ResourceClass, NavigationBadgeColor } from '../Resource.js'
4
+ import type { GlobalClass } from '../Global.js'
5
+ import type { ClusterClass } from '../Cluster.js'
6
+ import { resourceBasePath, globalBasePath, pageBasePath } from '../clusterPaths.js'
7
+ import type { ElementMeta } from '../schema/Element.js'
8
+ import { resolveSchema, type SchemaContext } from '../schema/resolveSchema.js'
9
+ import { resolveTheme } from '../theme/resolve.js'
10
+ import type { ThemeMeta } from '../theme/types.js'
11
+ import { serializeIcon, type SerializedIcon } from '../icons/types.js'
12
+ import {
13
+ RIGHT_PANEL_DEFAULT_WIDTH,
14
+ RIGHT_PANEL_MIN_WIDTH,
15
+ RIGHT_PANEL_MAX_WIDTH,
16
+ } from '../RightPanel.js'
17
+ import type { UserMenuItemMeta } from '../UserMenuItem.js'
18
+ import {
19
+ resolveRenderHooks,
20
+ CHROME_HOOK_NAMES,
21
+ type RenderHookContext,
22
+ type RenderHookMap,
23
+ type RenderHookName,
24
+ } from '../RenderHook.js'
25
+ import { applyPageHooks, pageHooksFor, type PageRole } from '../applyPageHooks.js'
26
+ import {
27
+ notificationChannel,
28
+ NOTIFICATION_CREATED_EVENT,
29
+ } from '../notifications/broadcast.js'
30
+ import { safeBool } from './helpers.js'
31
+
32
+ // ─── Navigation chrome ──────────────────────────────────────
33
+ //
34
+ // `panelInfo` is the entry point every SSR + SPA-nav data hook calls
35
+ // to build the static chrome envelope (branding / theme / navigation
36
+ // tree / user menu / database-notifications meta / right sidebar meta
37
+ // / page-role render hooks). Returns a snapshot that's safe to ship
38
+ // over the wire; references like component classes get rendered down
39
+ // to serializable shapes here.
40
+
41
+
42
+ // ─── Shared helpers ──────────────────────────────────────────
43
+
44
+ /**
45
+ * Top-right user dropdown shipped to the renderer in `viewProps.panel`.
46
+ * `null` when no `Pilotiq.user(req => …)` resolver is configured or the
47
+ * resolver returns `null` (no logged-in user) — the renderer suppresses
48
+ * the dropdown entirely in that case.
49
+ *
50
+ * `user.name / user.email / user.avatar` are duck-typed off the
51
+ * resolver's return value; whichever fields are present round-trip into
52
+ * the dropdown trigger (initials fall back to the first two letters of
53
+ * `name` when no avatar URL is set).
54
+ */
55
+ export interface UserMenuMeta {
56
+ user: { name?: string; email?: string; avatar?: string }
57
+ items: UserMenuItemMeta[]
58
+ signOut?: { url: string; label: string; method: 'POST' | 'GET' }
59
+ }
60
+
61
+ /**
62
+ * Bell-icon dropdown configuration shipped under `viewProps.panel`. Sparse —
63
+ * absent when `Pilotiq.databaseNotifications()` wasn't called OR when no
64
+ * user resolves (anonymous request → no inbox to surface). Renderer mounts
65
+ * the bell only when this is set.
66
+ *
67
+ * Routes are absolute URLs (panel `basePath` already applied). Client
68
+ * substitutes `:id` per row when calling read / unread; `_widget`-style
69
+ * params aren't used here because the bell only ever issues these four
70
+ * fetch shapes.
71
+ *
72
+ * `polling` mirrors `DatabaseNotificationsConfig.polling` — `null` ships
73
+ * over the wire to disable client-side polling. The bell still fetches on
74
+ * mount + after every mark-read mutation.
75
+ */
76
+ export interface DatabaseNotificationsMeta {
77
+ position: 'topbar' | 'sidebar'
78
+ polling: number | null
79
+ pageSize: number
80
+ badgeColor: NavigationBadgeColor
81
+ trigger?: { icon?: string; label?: string }
82
+ listUrl: string
83
+ readAllUrl: string
84
+ /** Template URL with literal `:id` placeholder. Client replaces. */
85
+ readUrl: string
86
+ /** Template URL with literal `:id` placeholder. Client replaces. */
87
+ unreadUrl: string
88
+ /**
89
+ * Template URL for the notification-action dispatch endpoint with
90
+ * literal `:id` and `:actionName` placeholders. Bell client builds
91
+ * per-action URLs by substituting both at render time. Used only by
92
+ * `handler`-mode actions; `url` / `post` actions ride their own URL
93
+ * verbatim.
94
+ */
95
+ actionUrl: string
96
+ /**
97
+ * Phase 2 — broadcast hint. Sparse — absent when
98
+ * `databaseNotifications({ broadcast: true })` wasn't set OR when no
99
+ * resolved user has an `id` to scope the channel to.
100
+ *
101
+ * Client connects to `wsUrl` via `@rudderjs/broadcast`'s
102
+ * `RudderSocket`, subscribes to the `channel` (already includes the
103
+ * `private-` prefix), and listens for `event` to trigger refetches.
104
+ */
105
+ broadcast?: {
106
+ wsUrl: string
107
+ channel: string
108
+ event: string
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Right-sidebar shipped under `viewProps.panel.rightSidebar`. Sparse —
114
+ * absent from `panelInfo()` when no contributions are registered, every
115
+ * registered contribution failed `canAccess(user)`, or every visible
116
+ * contribution is `hidden: true`. Renderer mounts the chrome only when
117
+ * this is set.
118
+ *
119
+ * The React component reference for each contribution does NOT travel
120
+ * here — only its tab-strip metadata. The actual body component is
121
+ * resolved client-side from the Vite plugin's `_components.ts` manifest
122
+ * keyed by contribution `id`, mirroring the icon-class round-trip.
123
+ *
124
+ * `defaultWidth` rolls up: contribution-level value when one
125
+ * contribution was registered with one, otherwise the panel-level
126
+ * baseline (`RIGHT_PANEL_DEFAULT_WIDTH`). Client also clamps
127
+ * localStorage values to `[minWidth, maxWidth]`.
128
+ */
129
+ export interface RightPanelMeta {
130
+ id: string
131
+ label: string
132
+ icon?: SerializedIcon
133
+ defaultWidth: number
134
+ }
135
+
136
+ export interface RightSidebarMeta {
137
+ panels: RightPanelMeta[]
138
+ defaultWidth: number
139
+ minWidth: number
140
+ maxWidth: number
141
+ }
142
+
143
+ /**
144
+ * Single nav-tree entry. `name` is the JS class name (`R.name` /
145
+ * `G.name` / `P.name`) — also the lookup key into the build-time
146
+ * `_components.ts` manifest the Vite plugin emits, so component-typed
147
+ * icons resolve from the same identifier.
148
+ */
149
+ export interface NavItem {
150
+ name: string
151
+ label: string
152
+ url: string
153
+ icon?: SerializedIcon
154
+ group?: string
155
+ sort?: number
156
+ badge?: string
157
+ badgeColor?: NavigationBadgeColor
158
+ children?: NavItem[]
159
+ }
160
+
161
+ /**
162
+ * Build the panel header summary + the unified navigation tree.
163
+ *
164
+ * Pipeline:
165
+ * 1. flatten resources + globals + pages into raw NavItem records
166
+ * 2. drop items whose `canAccess(user)` (Plan #10) returns false
167
+ * 3. resolve `navigationParentItem` references → nest under parents
168
+ * (cycles broken with a console warn; dangling parents render at top level)
169
+ * 4. sort within each grouping (top-level *and* every parent's children)
170
+ * by `navigationSort` ascending → registration order
171
+ * 5. resolve every `navigationBadge()` in parallel via `Promise.all`;
172
+ * handler errors are swallowed (badge omitted) so a flaky count
173
+ * never blanks the page
174
+ *
175
+ * `req` is the active request; pilotiq calls `pilotiq.resolveUser(req)`
176
+ * once and threads the user into every Resource/Global/Page `canAccess`
177
+ * check. When `Pilotiq.user(fn)` isn't configured, user is `null` and the
178
+ * default `canAccess` returns true → no items dropped.
179
+ */
180
+ /**
181
+ * Optional route-context for `panelInfo()`. When set, render-hook
182
+ * `scope: { resource | page | global }` filters fire correctly for the
183
+ * active route. Missing keys mean the slot has no scope-able identifier
184
+ * (chrome-only routes); scope-less hooks still fire either way.
185
+ *
186
+ * `url` defaults to `cfg.path` when unset. `recordId` rides through to
187
+ * `RenderHookContext.recordId` for hooks that need it.
188
+ */
189
+ export interface PanelInfoRoute {
190
+ resource?: ResourceClass
191
+ page?: typeof Page
192
+ global?: GlobalClass
193
+ recordId?: string
194
+ url?: string
195
+ }
196
+
197
+ export async function panelInfo(
198
+ pilotiq: Pilotiq,
199
+ req?: unknown,
200
+ route: PanelInfoRoute = {},
201
+ ) {
202
+ const cfg = pilotiq.getConfig()
203
+ const merged = pilotiq.getMergedTheme()
204
+ const theme: ThemeMeta | undefined = merged ? resolveTheme(merged) : undefined
205
+ const user = await pilotiq.resolveUser(req)
206
+ const [navigation, userMenu, renderHooks, rightSidebar] = await Promise.all([
207
+ buildNavigation(pilotiq, user),
208
+ buildUserMenu(pilotiq, user),
209
+ resolveChromeHooks(pilotiq, user, route),
210
+ buildRightSidebarMeta(cfg, user),
211
+ ])
212
+ const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
213
+ // AI suggestion mode — sparse: omit when 'auto' (the default) so the
214
+ // wire shape stays minimal for panels that don't opt into review mode.
215
+ // Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
216
+ // this to decide whether to apply writes immediately or stage them as
217
+ // PendingSuggestions for user approval.
218
+ const aiSuggestionsMode = pilotiq.getAiSuggestionsMode()
219
+ return {
220
+ name: cfg.name,
221
+ branding: cfg.branding,
222
+ navigation,
223
+ theme,
224
+ themeEditor: cfg.themeEditor ?? false,
225
+ ...(userMenu ? { userMenu } : {}),
226
+ ...(databaseNotifications ? { databaseNotifications } : {}),
227
+ ...(rightSidebar ? { rightSidebar } : {}),
228
+ ...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
229
+ ...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Build the bell-icon meta. Returns `null` when:
235
+ * - `Pilotiq.databaseNotifications()` was never called, OR
236
+ * - no user resolves (no inbox to surface).
237
+ *
238
+ * Defaults follow Filament: 30s polling, 25 rows per page, primary
239
+ * badge color, topbar position.
240
+ */
241
+ export function buildDatabaseNotificationsMeta(
242
+ cfg: Readonly<PilotiqConfig>,
243
+ user: unknown,
244
+ ): DatabaseNotificationsMeta | null {
245
+ if (!cfg.databaseNotifications?.enabled) return null
246
+ if (user === null || user === undefined) return null
247
+
248
+ const dn = cfg.databaseNotifications
249
+ const base = cfg.path
250
+ const meta: DatabaseNotificationsMeta = {
251
+ position: dn.position ?? 'topbar',
252
+ polling: dn.polling === null ? null : (dn.polling ?? 30),
253
+ pageSize: dn.pageSize ?? 25,
254
+ badgeColor: dn.badgeColor ?? 'primary',
255
+ listUrl: `${base}/_notifications`,
256
+ readAllUrl: `${base}/_notifications/read-all`,
257
+ readUrl: `${base}/_notifications/:id/read`,
258
+ unreadUrl: `${base}/_notifications/:id/unread`,
259
+ actionUrl: `${base}/_notifications/:id/_action/:actionName`,
260
+ }
261
+ if (dn.trigger) meta.trigger = { ...dn.trigger }
262
+ // Phase 2 broadcast hint — only ship when broadcast is enabled AND the
263
+ // resolved user has an `id` to scope the channel to. The client uses
264
+ // `wsUrl` for the WebSocket connection and `channel` for the subscribe
265
+ // call (the private- prefix is already baked in).
266
+ if (dn.broadcast) {
267
+ const userId = (user as { id?: unknown } | null | undefined)?.id
268
+ if (userId !== undefined && userId !== null) {
269
+ const wsUrl = typeof dn.broadcast === 'object' && dn.broadcast.wsUrl
270
+ ? dn.broadcast.wsUrl
271
+ : '' // empty = client falls back to same-origin /ws
272
+ meta.broadcast = {
273
+ wsUrl,
274
+ channel: notificationChannel(String(userId)),
275
+ event: NOTIFICATION_CREATED_EVENT,
276
+ }
277
+ }
278
+ }
279
+ return meta
280
+ }
281
+
282
+ /**
283
+ * Build the right-sidebar meta from registered contributions. Returns
284
+ * `null` when:
285
+ *
286
+ * - no contributions were registered, OR
287
+ * - every contribution failed `canAccess(user)` (or its predicate
288
+ * threw — fail-closed), OR
289
+ * - every passing contribution is `hidden: true` (no tab-strip
290
+ * surface to mount; programmatic-open consumers should ship at
291
+ * least one visible tab).
292
+ *
293
+ * Visible contributions are sorted by `sort` ascending (default 100),
294
+ * with registration order as a stable tiebreaker. Each entry's icon is
295
+ * serialized through `serializeIcon` keyed on the contribution `id`
296
+ * (Phase B's Vite plugin extends `_components.ts` to round-trip
297
+ * component-typed icons under that key). `defaultWidth` rolls up:
298
+ * panel-level baseline is `RIGHT_PANEL_DEFAULT_WIDTH`; per-contribution
299
+ * overrides ride on `RightPanelMeta.defaultWidth`.
300
+ *
301
+ * Errors thrown by `canAccess` are swallowed (the contribution is
302
+ * dropped + a single console warn is emitted) so a flaky predicate on
303
+ * one pane never blanks the whole sidebar.
304
+ */
305
+ export async function buildRightSidebarMeta(
306
+ cfg: Readonly<PilotiqConfig>,
307
+ user: unknown,
308
+ ): Promise<RightSidebarMeta | null> {
309
+ const list = cfg.rightPanels ?? []
310
+ if (list.length === 0) return null
311
+
312
+ const indexed = list.map((c, idx) => ({ c, idx }))
313
+ const gated = await Promise.all(
314
+ indexed.map(async ({ c, idx }) => {
315
+ if (c.canAccess) {
316
+ try {
317
+ const ok = await c.canAccess(user)
318
+ if (!ok) return null
319
+ } catch (err) {
320
+ // eslint-disable-next-line no-console
321
+ console.warn(`[Pilotiq] rightPanel "${c.id}" canAccess threw — dropping`, err)
322
+ return null
323
+ }
324
+ }
325
+ return { c, idx }
326
+ }),
327
+ )
328
+
329
+ const visible = gated
330
+ .filter((x): x is { c: typeof list[number]; idx: number } => x !== null)
331
+ .filter((x) => !x.c.hidden)
332
+ .sort((a, b) => {
333
+ const sa = a.c.sort ?? 100
334
+ const sb = b.c.sort ?? 100
335
+ if (sa !== sb) return sa - sb
336
+ return a.idx - b.idx
337
+ })
338
+
339
+ if (visible.length === 0) return null
340
+
341
+ const panels: RightPanelMeta[] = visible.map(({ c }) => {
342
+ const meta: RightPanelMeta = {
343
+ id: c.id,
344
+ label: c.label ?? c.id,
345
+ defaultWidth: c.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
346
+ }
347
+ if (c.icon !== undefined) {
348
+ meta.icon = serializeIcon(c.icon, c.id)
349
+ }
350
+ return meta
351
+ })
352
+
353
+ return {
354
+ panels,
355
+ defaultWidth: panels[0]?.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
356
+ minWidth: RIGHT_PANEL_MIN_WIDTH,
357
+ maxWidth: RIGHT_PANEL_MAX_WIDTH,
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Resolve every chrome render hook (body / topbar / sidebar / user-menu
363
+ * / footer / head). Returns a sparse map — slots with no matching
364
+ * registered entries are omitted so the wire payload stays minimal on
365
+ * panels that don't use render hooks at all.
366
+ */
367
+ export async function resolveChromeHooks(
368
+ pilotiq: Pilotiq,
369
+ user: unknown,
370
+ route: PanelInfoRoute,
371
+ ): Promise<RenderHookMap> {
372
+ const cfg = pilotiq.getConfig()
373
+ const entries = cfg.renderHooks ?? []
374
+ if (entries.length === 0) return {}
375
+ const ctx: RenderHookContext = {
376
+ user,
377
+ basePath: cfg.path,
378
+ url: route.url ?? cfg.path,
379
+ }
380
+ if (route.resource !== undefined) ctx.resource = route.resource
381
+ if (route.page !== undefined) ctx.page = route.page
382
+ if (route.global !== undefined) ctx.global = route.global
383
+ if (route.recordId !== undefined) ctx.recordId = route.recordId
384
+ return resolveRenderHooks(entries, CHROME_HOOK_NAMES, ctx)
385
+ }
386
+
387
+ /**
388
+ * Resolve a subset of page-role render hooks (e.g. `panels::page.start`
389
+ * + the list-records / create-record / view-record / edit-record /
390
+ * global-search slot families). Per-page-role data builders call this
391
+ * after schema resolution and stamp the result on `viewProps.renderHooks`.
392
+ *
393
+ * `names` lets each builder declare exactly which slots it serves so a
394
+ * list-page builder doesn't ship slots that only fire on the edit page.
395
+ */
396
+ /**
397
+ * Per-builder one-shot — resolve the role's slot set + splice the
398
+ * results into the resolved schema. Wraps the two steps a per-builder
399
+ * data fn always does in lockstep:
400
+ *
401
+ * 1. `resolvePageHooks(pilotiq, user, pageHooksFor(role), route)`
402
+ * 2. `applyPageHooks(schemaData, hooks, role)`
403
+ *
404
+ * Returns the wrapped `ElementMeta[]`. No-op when the panel has no
405
+ * registered hooks. Pass through what you'd pass to `panelInfo()`'s
406
+ * route arg — same shape.
407
+ */
408
+ export async function applyRoleHooks(
409
+ pilotiq: Pilotiq,
410
+ user: unknown,
411
+ role: PageRole,
412
+ schemaData: ElementMeta[],
413
+ route: PanelInfoRoute = {},
414
+ ): Promise<ElementMeta[]> {
415
+ const cfg = pilotiq.getConfig()
416
+ if (!cfg.renderHooks || cfg.renderHooks.length === 0) return schemaData
417
+ const hooks = await resolvePageHooks(pilotiq, user, pageHooksFor(role), route)
418
+ return applyPageHooks(schemaData, hooks, role)
419
+ }
420
+
421
+ export async function resolvePageHooks(
422
+ pilotiq: Pilotiq,
423
+ user: unknown,
424
+ names: readonly RenderHookName[],
425
+ route: PanelInfoRoute,
426
+ ): Promise<RenderHookMap> {
427
+ const cfg = pilotiq.getConfig()
428
+ const entries = cfg.renderHooks ?? []
429
+ if (entries.length === 0 || names.length === 0) return {}
430
+ const ctx: RenderHookContext = {
431
+ user,
432
+ basePath: cfg.path,
433
+ url: route.url ?? cfg.path,
434
+ }
435
+ if (route.resource !== undefined) ctx.resource = route.resource
436
+ if (route.page !== undefined) ctx.page = route.page
437
+ if (route.global !== undefined) ctx.global = route.global
438
+ if (route.recordId !== undefined) ctx.recordId = route.recordId
439
+ return resolveRenderHooks(entries, names, ctx)
440
+ }
441
+
442
+
443
+ /**
444
+ * Build the top-right user-menu meta. Returns `null` when:
445
+ * - `Pilotiq.user()` isn't configured, or
446
+ * - the resolver returned `null` (anonymous request), or
447
+ * - the user object has no extractable identity AND the panel
448
+ * configured no items / no sign-out (nothing to render).
449
+ *
450
+ * Items resolve in parallel with their visibility predicates
451
+ * (`UserMenuItem.visible`). Throwing predicates fail closed (item
452
+ * dropped). Sort by `.sort(n)` ascending → registration order.
453
+ */
454
+ export async function buildUserMenu(pilotiq: Pilotiq, user: unknown): Promise<UserMenuMeta | null> {
455
+ if (user === null || user === undefined) return null
456
+
457
+ const cfg = pilotiq.getConfig()
458
+ const items = cfg.userMenuItems ?? []
459
+ const ctx = { user }
460
+
461
+ // Resolve every item in parallel. `null` returns mean "filtered by
462
+ // visibility predicate" — drop them. Indexed pre-sort so stable ties
463
+ // resolve to registration order.
464
+ const resolved = await Promise.all(
465
+ items.map(async (item, idx) => {
466
+ try {
467
+ const meta = await item.resolve(ctx)
468
+ return meta ? { meta, idx, sort: item.getSort() } : null
469
+ } catch {
470
+ return null
471
+ }
472
+ }),
473
+ )
474
+ const visibleItems = resolved
475
+ .filter((x): x is { meta: UserMenuItemMeta; idx: number; sort: number | undefined } => x !== null)
476
+ .sort((a, b) => {
477
+ const aHas = a.sort !== undefined, bHas = b.sort !== undefined
478
+ if (aHas && bHas) return a.sort! - b.sort! || a.idx - b.idx
479
+ if (aHas) return -1
480
+ if (bHas) return 1
481
+ return a.idx - b.idx
482
+ })
483
+ .map(x => x.meta)
484
+
485
+ // Auto-inject the profile entry from `cfg.profilePage` when set.
486
+ // Prepended (Filament-style) so it always sits at the top of the
487
+ // dropdown regardless of user-authored item ordering. Falls through
488
+ // its own `canAccess(user)` so per-user gating works without the
489
+ // user repeating the predicate at the menu level.
490
+ const profileItem = await buildProfileMenuItem(cfg, user)
491
+ const finalItems = profileItem ? [profileItem, ...visibleItems] : visibleItems
492
+
493
+ const meta: UserMenuMeta = {
494
+ user: extractUserIdentity(user),
495
+ items: finalItems,
496
+ }
497
+ if (cfg.signOut) {
498
+ meta.signOut = {
499
+ url: cfg.signOut.url,
500
+ label: cfg.signOut.label ?? 'Sign out',
501
+ method: cfg.signOut.method ?? 'POST',
502
+ }
503
+ }
504
+ return meta
505
+ }
506
+
507
+ /** Build the auto-injected profile entry from `cfg.profilePage`. The
508
+ * Page's `static label` / `static icon` win; defaults `'Edit profile'`
509
+ * + `'user-circle'` (registry-resolved). Returns `null` when no
510
+ * profile page is configured or `Page.canAccess(user)` denies. */
511
+ export async function buildProfileMenuItem(
512
+ cfg: Readonly<PilotiqConfig>,
513
+ user: unknown,
514
+ ): Promise<UserMenuItemMeta | null> {
515
+ const P = cfg.profilePage
516
+ if (!P) return null
517
+ if (!(await safeBool(() => P.canAccess(user)))) return null
518
+ const url = pageBasePath(cfg.path, P)
519
+ const icon = serializeIcon(P.icon ?? 'user-circle', P.name)
520
+ const meta: UserMenuItemMeta = {
521
+ name: '__profile',
522
+ label: P.label ?? 'Edit profile',
523
+ url,
524
+ }
525
+ if (icon !== undefined) meta.icon = icon
526
+ return meta
527
+ }
528
+
529
+ /** Duck-type the user object for display fields. We never throw — a
530
+ * user resolver might return literally anything (a primitive, a class
531
+ * instance with getters, a plain object) and the dropdown should
532
+ * degrade gracefully (initials fallback to '?' when no name found). */
533
+ export function extractUserIdentity(user: unknown): { name?: string; email?: string; avatar?: string } {
534
+ if (user === null || user === undefined) return {}
535
+ if (typeof user !== 'object') return { name: String(user) }
536
+ const obj = user as Record<string, unknown>
537
+ const out: { name?: string; email?: string; avatar?: string } = {}
538
+ const name = obj.name ?? obj.fullName ?? obj.displayName ?? obj.username
539
+ if (typeof name === 'string' && name) out.name = name
540
+ if (typeof obj.email === 'string' && obj.email) out.email = obj.email
541
+ const avatar = obj.avatar ?? obj.avatarUrl ?? obj.image
542
+ if (typeof avatar === 'string' && avatar) out.avatar = avatar
543
+ return out
544
+ }
545
+
546
+ /** @internal Internal node before nesting; carries the registration index
547
+ * so we can stable-sort by it as the tie-breaker. */
548
+ interface RawNavItem extends NavItem {
549
+ parent?: string
550
+ /** Registration index across resources → globals → pages (in that order),
551
+ * so resources beat globals on a sort tie within the same group. */
552
+ _idx: number
553
+ }
554
+
555
+ /** Plan #10 — stamp the resolved user onto a SchemaContext so action
556
+ * visibility predicates can see it during `resolveSchema`. The `user`
557
+ * field is opaque (whatever `Pilotiq.user(req => …)` returns); skipped
558
+ * when null/undefined to keep ctx tidy. */
559
+ export async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<NavItem[]> {
560
+ const cfg = pilotiq.getConfig()
561
+ const base = cfg.path
562
+
563
+ // Flatten + resolve badges in parallel. We build the raw list first so
564
+ // every entry has its identity (`name`) and parent set; badges resolve
565
+ // alongside.
566
+ const raw: RawNavItem[] = []
567
+ let idx = 0
568
+
569
+ const pushBadge: Array<{ item: RawNavItem; handler: () => unknown }> = []
570
+
571
+ // Plan #10 — pre-evaluate canAccess for every owner in parallel so we
572
+ // can drop forbidden items before flattening. Failed predicates fail
573
+ // closed (treated as `false`) so a thrown auth check doesn't accidentally
574
+ // expose nav items. Clusters compose: a child gated through its
575
+ // cluster's `canAccess` returning false drops the child even when the
576
+ // child's own predicate would have passed.
577
+ const [resourceAccess, globalAccess, pageAccess, clusterAccess] = await Promise.all([
578
+ Promise.all(cfg.resources.map(R => safeBool(() => R.canAccess(user)))),
579
+ Promise.all(cfg.globals.map(G => safeBool(() => G.canAccess(user)))),
580
+ Promise.all(cfg.pages.map(P => safeBool(() => P.canAccess(user)))),
581
+ Promise.all(cfg.clusters.map(C => safeBool(() => C.canAccess(user)))),
582
+ ])
583
+
584
+ // Identity-keyed so two clusters that happen to share a `.name`
585
+ // (minifier collisions, hot-reload duplicate imports) don't clobber.
586
+ const clusterAccessByClass = new Map<ClusterClass, boolean>()
587
+ cfg.clusters.forEach((C, i) => clusterAccessByClass.set(C, !!clusterAccess[i]))
588
+
589
+ const firstChildUrlByCluster = new Map<ClusterClass, string>()
590
+ const recordChildUrl = (cluster: ClusterClass, url: string) => {
591
+ if (!firstChildUrlByCluster.has(cluster)) firstChildUrlByCluster.set(cluster, url)
592
+ }
593
+
594
+ for (let i = 0; i < cfg.resources.length; i++) {
595
+ const R = cfg.resources[i]!
596
+ if (!resourceAccess[i]) continue
597
+ if (R.cluster && !clusterAccessByClass.get(R.cluster)) continue
598
+ const url = resourceBasePath(base, R)
599
+ if (R.cluster) recordChildUrl(R.cluster, url)
600
+ const item: RawNavItem = {
601
+ name: R.name,
602
+ label: R.getNavigationLabel(),
603
+ url,
604
+ icon: serializeIcon(R.getNavigationIcon(), R.name),
605
+ _idx: idx++,
606
+ }
607
+ if (R.navigationGroup !== undefined) item.group = R.navigationGroup
608
+ if (R.navigationSort !== undefined) item.sort = R.navigationSort
609
+ // Cluster nesting wins over `navigationParentItem`. Both being set
610
+ // is a misconfiguration; cluster placement is the structural one.
611
+ if (R.cluster) item.parent = R.cluster.name
612
+ else if (R.navigationParentItem !== undefined) item.parent = R.navigationParentItem
613
+ if (R.navigationBadgeColor !== 'default') item.badgeColor = R.navigationBadgeColor
614
+ if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge })
615
+ raw.push(item)
616
+ }
617
+
618
+ for (let i = 0; i < cfg.globals.length; i++) {
619
+ if (!globalAccess[i]) continue
620
+ const G = cfg.globals[i]!
621
+ if (G.cluster && !clusterAccessByClass.get(G.cluster)) continue
622
+ // Globals default `navigationGroup` to `'Settings'`. Allow `null` as
623
+ // an explicit opt-out → render at top level.
624
+ const group = G.navigationGroup === null ? undefined : G.navigationGroup
625
+ const url = globalBasePath(base, G)
626
+ if (G.cluster) recordChildUrl(G.cluster, url)
627
+ const item: RawNavItem = {
628
+ name: G.name,
629
+ label: G.getNavigationLabel(),
630
+ url,
631
+ icon: serializeIcon(G.getNavigationIcon(), G.name),
632
+ _idx: idx++,
633
+ }
634
+ if (group !== undefined) item.group = group
635
+ if (G.navigationSort !== undefined) item.sort = G.navigationSort
636
+ if (G.cluster) item.parent = G.cluster.name
637
+ else if (G.navigationParentItem !== undefined) item.parent = G.navigationParentItem
638
+ if (G.navigationBadgeColor !== 'default') item.badgeColor = G.navigationBadgeColor
639
+ if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge })
640
+ raw.push(item)
641
+ }
642
+
643
+ for (let i = 0; i < cfg.pages.length; i++) {
644
+ if (!pageAccess[i]) continue
645
+ const P = cfg.pages[i]!
646
+ if (P.cluster && !clusterAccessByClass.get(P.cluster)) continue
647
+ // The dashboard page collapses its nav URL to `${base}` so the
648
+ // sidebar entry deep-links to the panel root rather than
649
+ // `${base}/${P.getSlug()}` (which would 404 — the slug route skips
650
+ // the dashboard page at boot).
651
+ const isDashboard = cfg.dashboardPage === P
652
+ const url = isDashboard ? base : pageBasePath(base, P)
653
+ if (P.cluster && !isDashboard) recordChildUrl(P.cluster, url)
654
+ const item: RawNavItem = {
655
+ name: P.name,
656
+ label: P.getNavigationLabel(),
657
+ url,
658
+ icon: serializeIcon(P.getNavigationIcon(), P.name),
659
+ _idx: idx++,
660
+ }
661
+ if (P.navigationGroup !== undefined) item.group = P.navigationGroup
662
+ if (P.navigationSort !== undefined) item.sort = P.navigationSort
663
+ if (P.cluster && !isDashboard) item.parent = P.cluster.name
664
+ else if (P.navigationParentItem !== undefined) item.parent = P.navigationParentItem
665
+ if (P.navigationBadgeColor !== 'default') item.badgeColor = P.navigationBadgeColor
666
+ if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge })
667
+ raw.push(item)
668
+ }
669
+
670
+ // Clusters render as first-class nav items. Each gets a URL pointing
671
+ // at its `landingPage` (when set + accessible) or its first accessible
672
+ // child. Clusters whose every child was gated out are dropped silently
673
+ // — same posture as `navigationParentItem` with no resolvable parent.
674
+ for (let i = 0; i < cfg.clusters.length; i++) {
675
+ if (!clusterAccess[i]) continue
676
+ const C = cfg.clusters[i]!
677
+ let url: string | undefined
678
+ if (C.landingPage) {
679
+ const lpIdx = cfg.pages.indexOf(C.landingPage)
680
+ if (lpIdx !== -1 && pageAccess[lpIdx]) {
681
+ url = cfg.dashboardPage === C.landingPage ? base : pageBasePath(base, C.landingPage)
682
+ }
683
+ }
684
+ if (url === undefined) url = firstChildUrlByCluster.get(C)
685
+ if (url === undefined) continue // empty cluster — drop entirely
686
+ const item: RawNavItem = {
687
+ name: C.name,
688
+ label: C.getNavigationLabel(),
689
+ url,
690
+ icon: serializeIcon(C.getNavigationIcon(), C.name),
691
+ _idx: idx++,
692
+ }
693
+ if (C.navigationGroup !== undefined) item.group = C.navigationGroup
694
+ if (C.navigationSort !== undefined) item.sort = C.navigationSort
695
+ if (C.navigationParentItem !== undefined) item.parent = C.navigationParentItem
696
+ if (C.navigationBadgeColor !== 'default') item.badgeColor = C.navigationBadgeColor
697
+ if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge })
698
+ raw.push(item)
699
+ }
700
+
701
+ await Promise.all(pushBadge.map(async ({ item, handler }) => {
702
+ try {
703
+ const v = await handler()
704
+ if (v === undefined || v === null) return
705
+ item.badge = String(v)
706
+ } catch {
707
+ // Per-badge errors stay silent.
708
+ }
709
+ }))
710
+
711
+ return nestAndSort(raw)
712
+ }
713
+
714
+ /**
715
+ * Resolve `parent` references → nest, drop cycles, sort within each
716
+ * grouping, then strip internal scaffolding (`parent`, `_idx`).
717
+ */
718
+ export function nestAndSort(raw: RawNavItem[]): NavItem[] {
719
+ const byName = new Map<string, RawNavItem>()
720
+ for (const it of raw) byName.set(it.name, it)
721
+
722
+ // Detect parent cycles: walk upwards from each item; any name seen
723
+ // twice → cycle. Items in a cycle get treated as top-level.
724
+ const inCycle = new Set<string>()
725
+ for (const it of raw) {
726
+ if (it.parent === undefined) continue
727
+ const seen = new Set<string>([it.name])
728
+ let cur: string | undefined = it.parent
729
+ while (cur !== undefined) {
730
+ if (seen.has(cur)) {
731
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
732
+ console.warn(`[Pilotiq] navigationParentItem cycle detected at "${it.name}" — rendering at top level.`)
733
+ }
734
+ inCycle.add(it.name)
735
+ break
736
+ }
737
+ seen.add(cur)
738
+ const parent = byName.get(cur)
739
+ if (!parent) break
740
+ cur = parent.parent
741
+ }
742
+ }
743
+
744
+ const childrenOf = new Map<string, RawNavItem[]>()
745
+ const top: RawNavItem[] = []
746
+ for (const it of raw) {
747
+ const parent = it.parent
748
+ if (parent && byName.has(parent) && !inCycle.has(it.name)) {
749
+ const list = childrenOf.get(parent) ?? []
750
+ list.push(it)
751
+ childrenOf.set(parent, list)
752
+ } else {
753
+ top.push(it)
754
+ }
755
+ }
756
+
757
+ // Sort items in a sibling group by sort (asc), ties → registration order.
758
+ const sortItems = (items: RawNavItem[]): RawNavItem[] => {
759
+ return [...items].sort((a, b) => {
760
+ const aHas = a.sort !== undefined, bHas = b.sort !== undefined
761
+ if (aHas && bHas) return a.sort! - b.sort! || a._idx - b._idx
762
+ if (aHas) return -1 // sorted items come before unsorted
763
+ if (bHas) return 1
764
+ return a._idx - b._idx
765
+ })
766
+ }
767
+
768
+ // Strip internals + recurse into children.
769
+ const finalize = (items: RawNavItem[]): NavItem[] =>
770
+ sortItems(items).map(it => {
771
+ const kids = childrenOf.get(it.name)
772
+ const { parent, _idx, ...rest } = it
773
+ const out: NavItem = { ...rest }
774
+ if (kids && kids.length > 0) out.children = finalize(kids)
775
+ return out
776
+ })
777
+
778
+ return finalize(top)
779
+ }