@pilotiq/pilotiq 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +154 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +31 -10
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +31 -9
@@ -1,65 +1,33 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import React, { useEffect, useRef, useState } from 'react';
3
- import { getFieldRenderer } from './registry.js';
4
- import { getFieldLabelSlot } from './FieldLabelSlotRegistry.js';
5
- import { FormStateProvider, useFormState, FormIdContext } from './FormStateContext.js';
6
- import { Checkbox } from './ui/checkbox.js';
7
- import { Input } from './ui/input.js';
8
- import { Popover, PopoverTrigger, PopoverContent } from './ui/popover.js';
9
- import { FieldShell } from './fields/FieldShell.js';
10
- import { TextLikeInput } from './fields/TextLikeInput.js';
11
- import { useTextInputControls } from './fields/textInputControls.js';
12
- import { SelectFieldInput } from './fields/SelectFieldInput.js';
13
- import { ToggleFieldInput } from './fields/ToggleFieldInput.js';
14
- import { DateFieldInput } from './fields/DateFieldInput.js';
15
- import { HiddenInput } from './fields/HiddenInput.js';
16
- import { CheckboxInput } from './fields/CheckboxInput.js';
17
- import { RadioInput } from './fields/RadioInput.js';
18
- import { ToggleButtonsInput } from './fields/ToggleButtonsInput.js';
19
- import { CheckboxListInput } from './fields/CheckboxListInput.js';
20
- import { SliderInput } from './fields/SliderInput.js';
21
- import { ColorInput } from './fields/ColorInput.js';
22
- import { DateTimeInput } from './fields/DateTimeInput.js';
23
- import { KeyValueInput } from './fields/KeyValueInput.js';
24
- import { TagsInput } from './fields/TagsInput.js';
25
- import { FileUploadInput } from './fields/FileUploadInput.js';
26
- import { MarkdownInput } from './fields/MarkdownInput.js';
27
- import { RepeaterInput } from './fields/RepeaterInput.js';
28
- import { BuilderInput } from './fields/BuilderInput.js';
29
- import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from './ui/dialog.js';
30
- import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs.js';
31
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './ui/select.js';
32
- import { Table as DataTable, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from './ui/table.js';
33
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
34
- import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from './ui/tooltip.js';
35
- import { FilterIcon, CircleIcon, InboxIcon, GripVerticalIcon, ChevronDownIcon, CopyIcon, CheckIcon, XIcon, InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon, Columns3Icon, } from 'lucide-react';
2
+ import { CircleIcon } from 'lucide-react';
36
3
  import { useNavigate } from './navigate.js';
37
- import { parseDateRangeValue, encodeDateRangeValue, } from '../filters/DateRangeFilter.js';
38
- import { parseMultiSelectValue, encodeMultiSelectValue, } from '../filters/MultiSelectFilter.js';
39
- import { encodeFormFilterValue } from '../filters/FormFilter.js';
40
- import { parseQueryBuilderValue, encodeQueryBuilderValue, isQueryBuilderTree, } from '../filters/QueryBuilderFilter.js';
41
4
  import { useIconFor } from './icon-context.js';
42
- import { useToast } from './Toaster.js';
43
- import { getIcon } from '../icons/registry.js';
44
- import { pickEditableCell } from './cells/EditableCell.js';
45
5
  import { WidgetDataProvider } from './WidgetDataContext.js';
46
6
  import { StatsOverviewRenderer } from './widgets/StatsOverviewRenderer.js';
47
7
  import { TableWidgetRenderer } from './widgets/TableWidgetRenderer.js';
48
8
  import { ViewRenderer } from './widgets/ViewRenderer.js';
49
- import { getEntryComponent } from '../entries/registry.js';
50
9
  import { getSlotComponent } from '../slot-components/registry.js';
51
10
  import { getWidgetRenderer } from './widgetRegistry.js';
52
- function resolveIcon(name) {
53
- if (!name)
54
- return undefined;
55
- return getIcon(name);
56
- }
57
- const alertStyles = {
58
- info: 'border-blue-200 bg-blue-50 text-blue-800 dark:border-blue-800 dark:bg-blue-950 dark:text-blue-200',
59
- warning: 'border-amber-200 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-200',
60
- success: 'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-200',
61
- danger: 'border-red-200 bg-red-50 text-red-800 dark:border-red-800 dark:bg-red-950 dark:text-red-200',
62
- };
11
+ import { BADGE_COLOR_CLASSES, } from './schemaRenderer/constants.js';
12
+ import { resolveIcon, } from './schemaRenderer/helpers.js';
13
+ import { renderSimpleElement } from './schemaRenderer/SimpleElements.js';
14
+ import { AlertRenderer } from './schemaRenderer/AlertRenderer.js';
15
+ import { SectionRenderer } from './schemaRenderer/SectionRenderer.js';
16
+ import { TabsRenderer } from './schemaRenderer/TabsRenderer.js';
17
+ import { WizardRenderer } from './schemaRenderer/WizardRenderer.js';
18
+ import { renderEntry } from './schemaRenderer/EntryRenderer.js';
19
+ import { dispatchHandlerAction as actionDispatchHandlerAction, } from './schemaRenderer/action/helpers.js';
20
+ import { renderAction, renderActionLike as renderActionLikeImpl } from './schemaRenderer/action/renderAction.js';
21
+ import { ActionGroupTrigger } from './schemaRenderer/action/ActionGroupTrigger.js';
22
+ import { renderField as renderFieldImpl, } from './schemaRenderer/form/renderField.js';
23
+ import { FormRenderer as FormRendererImpl, renderFormChild as renderFormChildImpl, } from './schemaRenderer/form/FormRenderer.js';
24
+ import { TableRenderer as TableRendererImpl } from './schemaRenderer/table/TableRenderer.js';
25
+ /**
26
+ * Re-export `dispatchHandlerAction` from the action helpers so existing
27
+ * consumers (e.g. `RepeaterInput.tsx`) keep working through this barrel.
28
+ * Phase 4 may shift these imports onto the action subpath directly.
29
+ */
30
+ export const dispatchHandlerAction = actionDispatchHandlerAction;
63
31
  export function FormFields({ elements, values }) {
64
32
  return (_jsx(_Fragment, { children: elements.map((el, i) => {
65
33
  if (el['type'] !== 'field')
@@ -71,1693 +39,65 @@ export function FormFields({ elements, values }) {
71
39
  return renderField(merged, i);
72
40
  }) }));
73
41
  }
74
- // ─── Field rendering ────────────────────────────────────────
75
- //
76
- // Each input lives in its own file under `react/fields/`. This file
77
- // stays a thin dispatcher: parse meta pick component → wrap in
78
- // `<FieldShell>`.
79
- function renderField(el, index) {
80
- const fieldType = String(el['fieldType'] ?? 'text');
81
- const name = String(el['name'] ?? '');
82
- const label = String(el['label'] ?? name);
83
- const required = Boolean(el['required']);
84
- const disabled = Boolean(el['disabled']);
85
- const placeholder = el['placeholder'] ? String(el['placeholder']) : undefined;
86
- const defaultValue = el['defaultValue'];
87
- const defaultStr = defaultValue !== undefined && defaultValue !== null ? String(defaultValue) : undefined;
88
- // Hidden fields render bare — no label, no shell, no chrome. Bail
89
- // before the renderField switch + FieldShell wrap.
90
- if (fieldType === 'hidden') {
91
- return _jsx(HiddenInput, { name: name, defaultValue: defaultValue }, index);
92
- }
93
- // Field label slot — rendered next to the label when a plugin registered
94
- // a component via registerFieldLabelSlot() and the field has aiActions +
95
- // _agentRunBase stamped on its meta (set by tagFieldAiUrls in pageData).
96
- const LabelSlot = getFieldLabelSlot();
97
- const aiActions = Array.isArray(el['aiActions']) ? el['aiActions'] : undefined;
98
- const agentRunBase = typeof el['_agentRunBase'] === 'string' ? el['_agentRunBase'] : undefined;
99
- const labelSlot = (LabelSlot && aiActions?.length && agentRunBase)
100
- ? _jsx(LabelSlot, { fieldName: name, actions: aiActions, agentRunBase: agentRunBase })
101
- : undefined;
102
- const autofocus = el['autofocus'] === true;
103
- const extraInput = el['extraInputAttributes'];
104
- const common = {
105
- id: name,
106
- name,
107
- disabled,
108
- placeholder,
109
- required,
110
- ...(defaultStr !== undefined ? { defaultValue: defaultStr } : {}),
111
- ...(autofocus ? { autoFocus: true } : {}),
112
- ...(extraInput ?? {}),
113
- };
114
- // External packages (e.g. @pilotiq/tiptap) register custom renderers
115
- // for non-built-in fieldTypes. The registry wins over the built-in
116
- // switch so consumers can override built-ins too if they want.
117
- const Custom = getFieldRenderer(fieldType);
118
- if (Custom) {
119
- return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, labelSlot: labelSlot, children: _jsx(Custom, { el: el, name: name, defaultValue: defaultValue, required: required, disabled: disabled, placeholder: placeholder }) }, index));
120
- }
121
- // TextField (and slug) rich affordances live in a dedicated shell so
122
- // `useTextInputControls` can hold reveal-toggle / mask state via React
123
- // hooks (renderField itself is a plain function, hooks would violate
124
- // rules-of-hooks here).
125
- if (fieldType === 'text' || fieldType === 'slug') {
126
- return (_jsx(TextFieldShell, { el: el, name: name, label: label, required: required, common: common, labelSlot: labelSlot }, index));
127
- }
128
- const input = renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder);
129
- return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, labelSlot: labelSlot, children: input }, index));
130
- }
131
- /**
132
- * Component-shape TextField renderer — wraps the input shell so we can
133
- * use `useTextInputControls()` (which holds the eye-toggle / mask state).
134
- * Keeps `renderField` itself hook-free.
135
- */
136
- function TextFieldShell({ el, name, label, required, common, labelSlot, }) {
137
- const controls = useTextInputControls(el, name, (m) => renderElement(m, 0));
138
- // Build the input with all the new HTML attrs (inputMode /
139
- // autocapitalize / list / maxLength + the password/text type from
140
- // the controls hook).
141
- const textExtra = {};
142
- if (el['maxLength'] !== undefined)
143
- textExtra['maxLength'] = Number(el['maxLength']);
144
- if (el['inputMode'] !== undefined)
145
- textExtra['inputMode'] = String(el['inputMode']);
146
- if (el['autocapitalize'] !== undefined)
147
- textExtra['autoCapitalize'] = String(el['autocapitalize']);
148
- if (Array.isArray(el['datalist']))
149
- textExtra['list'] = `${name}__datalist`;
150
- const datalist = Array.isArray(el['datalist']) ? el['datalist'] : undefined;
151
- const input = (_jsxs(_Fragment, { children: [_jsx(TextLikeInput, { el: el, name: name, common: common, type: controls.type, extraProps: textExtra, multiline: false, applyMask: controls.applyMask }), datalist && (_jsx("datalist", { id: `${name}__datalist`, children: datalist.map((v, i) => _jsx("option", { value: v }, i)) }))] }));
152
- return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, before: controls.before, after: controls.after, labelSlot: labelSlot, children: input }));
153
- }
154
- function renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder) {
155
- switch (fieldType) {
156
- case 'textarea': {
157
- const autosize = el['autosize'] === true;
158
- const cols = typeof el['cols'] === 'number' ? Number(el['cols']) : undefined;
159
- const extra = {};
160
- // `field-sizing-content` on the Textarea component already grows
161
- // the box with content; `autosize()` just unsets the explicit
162
- // `rows` so the browser doesn't reserve a fixed minimum height.
163
- if (!autosize)
164
- extra['rows'] = Number(el['rows']) || 4;
165
- if (cols !== undefined)
166
- extra['cols'] = cols;
167
- if (el['disableGrammarly'] === true) {
168
- extra['data-gramm'] = 'false';
169
- extra['data-gramm_editor'] = 'false';
170
- extra['data-enable-grammarly'] = 'false';
171
- }
172
- return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "text", extraProps: extra, multiline: true }));
173
- }
174
- case 'select': {
175
- const options = el['options'] ?? [];
176
- const createOption = el['createOption'];
177
- const fieldLabel = String(el['label'] ?? name);
178
- return (_jsx(SelectFieldInput, { name: name, defaultValue: defaultStr, disabled: disabled, required: required, placeholder: placeholder, options: options, fieldLabel: fieldLabel, ...(createOption ? { createOption } : {}) }));
179
- }
180
- case 'toggle': {
181
- const initialChecked = defaultValue === true || defaultValue === 'true' || defaultValue === 1 || defaultValue === '1';
182
- return _jsx(ToggleFieldInput, { name: name, defaultChecked: initialChecked, disabled: disabled });
183
- }
184
- case 'checkbox': {
185
- const initialChecked = defaultValue === true || defaultValue === 'true' || defaultValue === 1 || defaultValue === '1';
186
- return _jsx(CheckboxInput, { name: name, defaultChecked: initialChecked, disabled: disabled });
187
- }
188
- case 'radio': {
189
- const options = el['options'] ?? [];
190
- const inline = Boolean(el['inline']);
191
- return (_jsx(RadioInput, { name: name, defaultValue: defaultStr, disabled: disabled, options: options, inline: inline }));
192
- }
193
- case 'toggleButtons': {
194
- const options = el['options'] ?? [];
195
- return (_jsx(ToggleButtonsInput, { name: name, defaultValue: defaultStr, disabled: disabled, options: options }));
196
- }
197
- case 'checkboxList': {
198
- const options = el['options'] ?? [];
199
- const columns = Number(el['columns']) || 1;
200
- return (_jsx(CheckboxListInput, { name: name, defaultValue: defaultValue, disabled: disabled, options: options, columns: columns }));
201
- }
202
- case 'slider': {
203
- return (_jsx(SliderInput, { name: name, defaultValue: defaultValue, disabled: disabled, min: Number(el['min']) || 0, max: Number(el['max']) || 100, step: Number(el['step']) || 1, showValue: Boolean(el['showValue']) }));
204
- }
205
- case 'color': {
206
- return (_jsx(ColorInput, { name: name, defaultValue: defaultValue, disabled: disabled }));
207
- }
208
- case 'keyValue': {
209
- return (_jsx(KeyValueInput, { name: name, defaultValue: defaultValue, disabled: disabled, keyLabel: String(el['keyLabel'] ?? 'Key'), valueLabel: String(el['valueLabel'] ?? 'Value'), addLabel: String(el['addLabel'] ?? 'Add row'), reorderable: Boolean(el['reorderable']) }));
210
- }
211
- case 'tagsInput': {
212
- const suggestions = el['suggestions'] ?? [];
213
- // separator: omitted → ',' (default); explicit null → null (disabled).
214
- const separator = 'separator' in el
215
- ? el['separator']
216
- : ',';
217
- const splitKeys = el['splitKeys'] ?? ['Enter'];
218
- const maxTags = typeof el['maxTags'] === 'number' ? el['maxTags'] : null;
219
- const reorderable = Boolean(el['reorderable']);
220
- return (_jsx(TagsInput, { name: name, defaultValue: defaultValue, disabled: disabled, placeholder: placeholder, suggestions: suggestions, separator: separator, splitKeys: splitKeys, maxTags: maxTags, reorderable: reorderable }));
221
- }
222
- case 'fileUpload': {
223
- return (_jsx(FileUploadInput, { name: name, defaultValue: defaultValue, disabled: disabled, accept: el['accept'], maxSize: typeof el['maxSize'] === 'number' ? el['maxSize'] : undefined, multiple: Boolean(el['multiple']), preview: el['preview'] !== false, directory: typeof el['directory'] === 'string' ? el['directory'] : undefined, uploadUrl: typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined, downloadable: Boolean(el['downloadable']), openable: Boolean(el['openable']), reorderable: Boolean(el['reorderable']), appendFiles: Boolean(el['appendFiles']), panelLayout: el['panelLayout'] === 'grid' ? 'grid'
224
- : el['panelLayout'] === 'integrated' ? 'integrated'
225
- : 'list', ...(el['automaticallyResize'] && typeof el['automaticallyResize'] === 'object'
226
- ? { automaticallyResize: el['automaticallyResize'] }
227
- : {}), imageEditor: Boolean(el['imageEditor']), circleCropper: Boolean(el['circleCropper']), automaticallyCropImagesToAspectRatio: Boolean(el['automaticallyCropImagesToAspectRatio']), ...(Array.isArray(el['imageEditorAspectRatioOptions'])
228
- ? { imageEditorAspectRatioOptions: el['imageEditorAspectRatioOptions'] }
229
- : {}) }));
230
- }
231
- case 'markdown': {
232
- const toolbarButtons = el['toolbarButtons'] ?? [];
233
- return (_jsx(MarkdownInput, { name: name, defaultValue: defaultValue, disabled: disabled, placeholder: placeholder, toolbarButtons: toolbarButtons, minHeight: typeof el['minHeight'] === 'string' ? el['minHeight'] : undefined, maxHeight: typeof el['maxHeight'] === 'string' ? el['maxHeight'] : undefined, fileAttachmentsDirectory: typeof el['fileAttachmentsDirectory'] === 'string' ? el['fileAttachmentsDirectory'] : undefined, fileAttachmentsVisibility: typeof el['fileAttachmentsVisibility'] === 'string' ? el['fileAttachmentsVisibility'] : undefined, uploadUrl: typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined }));
234
- }
235
- case 'repeater':
236
- return _jsx(RepeaterInput, { el: el, name: name, disabled: disabled });
237
- case 'builder':
238
- return _jsx(BuilderInput, { el: el, name: name, disabled: disabled });
239
- case 'dateTime': {
240
- // Normalize various input shapes to YYYY-MM-DDTHH:mm.
241
- let local;
242
- if (defaultValue instanceof Date) {
243
- local = isNaN(defaultValue.getTime())
244
- ? undefined
245
- : defaultValue.toISOString().slice(0, 16);
246
- }
247
- else if (typeof defaultValue === 'string' && defaultValue) {
248
- const parsed = new Date(defaultValue);
249
- local = isNaN(parsed.getTime()) ? undefined : parsed.toISOString().slice(0, 16);
250
- }
251
- return (_jsx(DateTimeInput, { name: name, defaultValue: local, disabled: disabled, placeholder: placeholder }));
252
- }
253
- case 'number': {
254
- const numProps = {};
255
- if (el['min'] !== undefined)
256
- numProps['min'] = Number(el['min']);
257
- if (el['max'] !== undefined)
258
- numProps['max'] = Number(el['max']);
259
- if (el['step'] !== undefined)
260
- numProps['step'] = Number(el['step']);
261
- return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "number", extraProps: numProps, multiline: false }));
262
- }
263
- case 'email':
264
- return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "email", extraProps: {}, multiline: false }));
265
- case 'date': {
266
- // SSR may hand us a JS Date object directly; SPA JSON nav arrives as
267
- // an ISO string. Normalize both into a `YYYY-MM-DD` slice — naive
268
- // string slicing on `Date.toString()` ("Mon Apr 27 2026 ...") gives
269
- // garbage when re-parsed, so handle the Date branch explicitly.
270
- let iso;
271
- if (defaultValue instanceof Date) {
272
- iso = isNaN(defaultValue.getTime())
273
- ? undefined
274
- : defaultValue.toISOString().slice(0, 10);
275
- }
276
- else if (typeof defaultValue === 'string' && defaultValue) {
277
- const parsed = new Date(defaultValue);
278
- iso = isNaN(parsed.getTime())
279
- ? undefined
280
- : parsed.toISOString().slice(0, 10);
281
- }
282
- return (_jsx(DateFieldInput, { name: name, defaultValue: iso, disabled: disabled, placeholder: placeholder }));
283
- }
284
- case 'slug':
285
- case 'text':
286
- default: {
287
- const textExtra = {};
288
- if (el['maxLength'] !== undefined)
289
- textExtra['maxLength'] = Number(el['maxLength']);
290
- return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "text", extraProps: textExtra, multiline: false }));
291
- }
292
- }
293
- }
294
- /** Drain `notifications[]` from a JSON response into `useToast().notify`. */
295
- function dispatchNotifications(data, notify) {
296
- const notifs = data.notifications;
297
- if (!notifs || notifs.length === 0)
298
- return;
299
- for (const n of notifs)
300
- notify(n);
301
- }
302
- /**
303
- * Fetch + JSON dispatch for form-method actions (Delete-style — no
304
- * server-rendered <form>, no 303 redirect, no full page reload). Sends
305
- * `_method` as a body field so Hono's POST handler dispatches the
306
- * intended verb. On success: drain notifications, SPA-navigate to the
307
- * server-supplied redirect (or stay on current path if none).
308
- *
309
- * Failure modes:
310
- * - 4xx/5xx with `{ error }`: surfaced as an error toast.
311
- * - Network errors: error toast with the exception message.
312
- */
313
- async function dispatchMethodAction(url, method, navigate, notify) {
314
- try {
315
- const fd = new FormData();
316
- if (method !== 'post')
317
- fd.append('_method', method);
318
- const res = await fetch(url, {
319
- method: 'POST',
320
- headers: { 'Accept': 'application/json' },
321
- body: fd,
322
- });
323
- const data = await res.json().catch(() => ({}));
324
- if (!res.ok) {
325
- const message = String(data.error ?? `Request failed (${res.status})`);
326
- notify({ type: 'error', title: 'Action failed', body: message });
327
- return;
328
- }
329
- dispatchNotifications(data, notify);
330
- const redirect = String(data.redirect ?? '');
331
- if (redirect)
332
- navigate(redirect);
333
- else if (typeof window !== 'undefined')
334
- navigate(window.location.pathname + window.location.search);
335
- }
336
- catch (err) {
337
- notify({ type: 'error', title: 'Action failed', body: err instanceof Error ? err.message : String(err) });
338
- }
339
- }
340
- /**
341
- * Fetch + JSON dispatch for handler-style actions (no schema, no modal,
342
- * just a button). Sends `ids[]` plus arbitrary `values` fields. Server
343
- * returns `{ ok, redirect, notifications }` (or `{ ok: false, error }` on
344
- * failure). On success: drain notifications, SPA-navigate; on failure:
345
- * surface the error as a toast. No full page reload in any case.
346
- */
347
- export async function dispatchHandlerAction(url, ids, navigate, notify, values = {}, formSnapshot) {
348
- try {
349
- // When `formSnapshot` is set (Repeater / Builder `extraItemActions`
350
- // dispatch), the snapshot already carries the form's full state — we
351
- // just append `ids` / `values` on top so the server sees both the
352
- // form body (for coerceFormValues + row hydration) and the action's
353
- // own meta keys.
354
- const fd = formSnapshot ?? new FormData();
355
- for (const id of ids)
356
- fd.append('ids', id);
357
- for (const [k, v] of Object.entries(values))
358
- fd.append(k, v);
359
- const res = await fetch(url, {
360
- method: 'POST',
361
- headers: { 'Accept': 'application/json' },
362
- body: fd,
363
- });
364
- // Download branch — handlers that return `{ download }` ask the server
365
- // to write the body inline with `Content-Disposition: attachment`. Trip
366
- // a browser download via a synthetic `<a download>` and exit early
367
- // (no notify drain / no SPA-nav — the file IS the success signal).
368
- if (res.ok && triggerDownloadIfAttachment(res)) {
369
- await res.blob().then(triggerBlobDownload(res));
370
- return;
371
- }
372
- const data = await res.json().catch(() => ({}));
373
- if (!res.ok) {
374
- const message = String(data.error ?? `Request failed (${res.status})`);
375
- notify({ type: 'error', title: 'Action failed', body: message });
376
- return;
377
- }
378
- dispatchNotifications(data, notify);
379
- const redirect = String(data.redirect ?? '');
380
- if (redirect)
381
- navigate(redirect);
382
- else if (typeof window !== 'undefined')
383
- navigate(window.location.pathname + window.location.search);
384
- }
385
- catch (err) {
386
- notify({ type: 'error', title: 'Action failed', body: err instanceof Error ? err.message : String(err) });
387
- }
388
- }
389
- /** Returns true when the response carries `Content-Disposition: attachment`,
390
- * which is how the route layer signals a download payload. The header
391
- * match is case-insensitive (different runtimes normalize differently). */
392
- function triggerDownloadIfAttachment(res) {
393
- const cd = res.headers.get('Content-Disposition') ?? res.headers.get('content-disposition') ?? '';
394
- return cd.toLowerCase().includes('attachment');
395
- }
396
- /** Returns a closure that converts the blob into a download by clicking
397
- * a synthetic `<a download="…">`. Filename is parsed from
398
- * `Content-Disposition`'s `filename="…"` parameter; falls back to
399
- * `'download'` when missing. Only mounted when `document` is present
400
- * (no-op in SSR). */
401
- function triggerBlobDownload(res) {
402
- const cd = res.headers.get('Content-Disposition') ?? res.headers.get('content-disposition') ?? '';
403
- const match = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)["']?/i);
404
- const filename = (match?.[1] ?? 'download').trim();
405
- return (blob) => {
406
- if (typeof document === 'undefined' || typeof URL === 'undefined')
407
- return;
408
- const objUrl = URL.createObjectURL(blob);
409
- const a = document.createElement('a');
410
- a.href = objUrl;
411
- a.download = filename;
412
- a.style.display = 'none';
413
- document.body.appendChild(a);
414
- a.click();
415
- a.remove();
416
- URL.revokeObjectURL(objUrl);
417
- };
418
- }
419
- /**
420
- * Modal-form action dialog. Opens a Dialog with an optional form schema
421
- * (rendered from `meta.children`) plus header/footer chrome from
422
- * `meta.modal`. On submit, fetches the dispatchUrl with `Accept:
423
- * application/json` so the server can return:
424
- * - 200 `{ ok: true, redirect }` → navigate (SPA via useNavigate)
425
- * - 422 `{ ok: false, errors: { field: string[] } }` → inline errors
426
- * - 500 `{ ok: false, error }` → server error banner
427
- *
428
- * Used for handler-style actions that have a schema and/or a modal config.
429
- * Replaces the older ConfirmActionDialog for that path; confirm-only
430
- * actions without a schema also flow through here (no fields rendered,
431
- * just header + footer = same UX as the old confirm dialog).
432
- */
433
- function ActionModalDialog({ trigger, meta, ids, initialValues = {}, open: controlledOpen, onOpenChange, }) {
434
- const [internalOpen, setInternalOpen] = useState(false);
435
- const isControlled = controlledOpen !== undefined;
436
- const open = isControlled ? controlledOpen : internalOpen;
437
- const setOpen = (o) => {
438
- if (isControlled)
439
- onOpenChange?.(o);
440
- else
441
- setInternalOpen(o);
442
- };
443
- const [errors, setErrors] = useState({});
444
- const [serverError, setServerError] = useState(null);
445
- const [submitting, setSubmitting] = useState(false);
446
- const navigate = useNavigate();
447
- const { notify } = useToast();
448
- const modal = meta['modal'];
449
- const confirm = meta['confirm'];
450
- const destructive = Boolean(meta['destructive']);
451
- const dispatchUrl = meta['dispatchUrl'];
452
- const fields = (meta.children ?? []);
453
- const hasForm = fields.length > 0;
454
- // Filament v5 — auxiliary Elements stamped by the resolver between
455
- // the body and the footer (Alert / Text / Heading / Action / …).
456
- const contentFooter = (meta['modalContentFooter'] ?? []);
457
- const heading = modal?.heading ?? confirm?.title ?? (hasForm ? String(meta['label'] ?? 'Submit') : 'Are you sure?');
458
- const description = modal?.description ?? confirm?.message;
459
- const submitLabel = modal?.submitLabel ?? (destructive ? 'Delete' : (hasForm ? 'Submit' : 'Confirm'));
460
- const cancelLabel = modal?.cancelLabel ?? 'Cancel';
461
- const widthClass = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl' }[modal?.width ?? 'md'];
462
- // Modal chrome extras (Tier-2 audit gap #2). Defaults match the
463
- // previous renderer behaviour exactly — sparse meta keys round-trip
464
- // as `undefined` so existing modals are byte-identical.
465
- const closeByClickingAway = modal?.closeByClickingAway !== false;
466
- const closeByEscaping = modal?.closeByEscaping !== false;
467
- const stickyHeader = modal?.stickyHeader === true;
468
- const stickyFooter = modal?.stickyFooter === true;
469
- const showCloseButton = modal?.closeButton === true;
470
- const alignmentClass = { start: 'text-left', center: 'text-center sm:text-left', end: 'text-right' }[modal?.alignment ?? 'center'];
471
- const iconColorClass = modal?.iconColor
472
- ? {
473
- gray: 'text-muted-foreground',
474
- primary: 'text-primary',
475
- success: 'text-emerald-600 dark:text-emerald-300',
476
- warning: 'text-amber-600 dark:text-amber-300',
477
- destructive: 'text-destructive',
478
- info: 'text-blue-600 dark:text-blue-300',
479
- }[modal.iconColor]
480
- : undefined;
481
- // Existing default: only the submit button autofocuses (and only for
482
- // confirm-only modals). When `modalAutofocus(false)` is set the user
483
- // wants nothing to autofocus; `modalAutofocus(true)` shifts focus to
484
- // the first form input via a mount-effect ref.
485
- const explicitAutofocus = modal?.autofocus;
486
- const submitAutofocus = explicitAutofocus === false ? false
487
- : explicitAutofocus === true ? !hasForm
488
- : !hasForm;
489
- const formRef = useRef(null);
490
- useEffect(() => {
491
- if (!open || explicitAutofocus !== true || !hasForm)
492
- return;
493
- // Wait for the popup to mount + fields to render. Microtask is enough
494
- // because Base UI's mount transition is decoupled from our render.
495
- const id = window.requestAnimationFrame(() => {
496
- const form = formRef.current;
497
- if (!form)
498
- return;
499
- const target = form.querySelector('input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])');
500
- if (target)
501
- target.focus();
502
- });
503
- return () => window.cancelAnimationFrame(id);
504
- }, [open, explicitAutofocus, hasForm]);
505
- const reset = () => { setErrors({}); setServerError(null); setSubmitting(false); };
506
- const onSubmit = async (e) => {
507
- e.preventDefault();
508
- if (!dispatchUrl)
509
- return;
510
- setSubmitting(true);
511
- setServerError(null);
512
- setErrors({});
513
- const fd = new FormData(e.currentTarget);
514
- for (const id of ids)
515
- fd.append('ids', id);
516
- try {
517
- const res = await fetch(dispatchUrl, {
518
- method: 'POST',
519
- headers: { 'Accept': 'application/json' },
520
- body: fd,
521
- });
522
- const data = await res.json().catch(() => ({}));
523
- if (res.status === 422) {
524
- setErrors(data.errors ?? {});
525
- setSubmitting(false);
526
- return;
527
- }
528
- if (!res.ok) {
529
- setServerError(String(data.error ?? `Request failed (${res.status})`));
530
- setSubmitting(false);
531
- return;
532
- }
533
- setOpen(false);
534
- reset();
535
- // Server-emitted notifications come through the JSON response;
536
- // surface them via the Toaster before navigating so the user
537
- // sees the success/error toast even when navigation re-renders.
538
- const notifs = data.notifications;
539
- if (notifs && notifs.length > 0) {
540
- for (const n of notifs)
541
- notify(n);
542
- }
543
- const redirect = String(data.redirect ?? '');
544
- if (redirect)
545
- navigate(redirect);
546
- else if (typeof window !== 'undefined')
547
- navigate(window.location.pathname + window.location.search);
548
- }
549
- catch (err) {
550
- setServerError(err instanceof Error ? err.message : 'Submit failed');
551
- setSubmitting(false);
552
- }
553
- };
554
- const cancelClass = 'inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground';
555
- const confirmClass = destructive
556
- ? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50'
557
- : 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50';
558
- // Resolved icon component for the modal header (Filament-style chrome
559
- // — leading glyph next to the heading). Passed through `useIconFor`
560
- // for the same registry lookup used by Resource / Page / Action icons.
561
- const HeaderIcon = useIconFor(modal?.icon);
562
- // Build a className for the popup that respects width + sticky-chrome
563
- // + slideOver. Sticky modes give the popup a max height + overflow so
564
- // the inner scroll surface exists for sticky to bite onto.
565
- const stickyMode = stickyHeader || stickyFooter;
566
- const popupClass = [
567
- widthClass,
568
- stickyMode ? 'max-h-[90vh] overflow-hidden p-0' : '',
569
- ].filter(Boolean).join(' ');
570
- // Inner scroll body (only used in sticky mode). When inactive the
571
- // existing flat layout applies (header / fields / footer flow).
572
- const headerCls = `${alignmentClass} ${stickyHeader ? 'sticky top-0 bg-background z-10 px-6 pt-6 pb-3 border-b' : ''}`.trim();
573
- const footerCls = stickyFooter ? 'sticky bottom-0 bg-background z-10 px-6 py-3 border-t' : '';
574
- const bodyCls = stickyMode ? 'flex-1 overflow-y-auto px-6 py-3' : '';
575
- const formCls = stickyMode ? 'flex flex-col h-full' : '';
576
- return (_jsxs(_Fragment, { children: [trigger?.(() => { reset(); setOpen(true); }), _jsx(Dialog, { open: open, disablePointerDismissal: !closeByClickingAway, onOpenChange: (o, details) => {
577
- // Cancel Esc-triggered closes when the user has opted out.
578
- // Base UI's `details.cancel()` aborts the open-state change.
579
- if (!o && !closeByEscaping && details && details.reason === 'escapeKey') {
580
- const cancel = details.cancel;
581
- if (typeof cancel === 'function')
582
- cancel();
583
- return;
584
- }
585
- if (!o)
586
- reset();
587
- setOpen(o);
588
- }, children: _jsxs(DialogContent, { className: popupClass, children: [showCloseButton && (_jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOpen(false), className: "absolute top-3 right-3 z-20 inline-flex items-center justify-center rounded-md h-8 w-8 text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: _jsx(XIcon, { className: "size-4" }) })), _jsxs("form", { ref: formRef, onSubmit: onSubmit, className: formCls, children: [_jsxs(DialogHeader, { className: headerCls, children: [_jsxs(DialogTitle, { className: modal?.icon ? 'flex items-center gap-2' : undefined, children: [HeaderIcon && (_jsx(HeaderIcon, { "aria-hidden": true, className: `size-5 shrink-0 ${iconColorClass ?? ''}`.trim() })), _jsx("span", { children: heading })] }), description && _jsx(DialogDescription, { children: description })] }), (hasForm || contentFooter.length > 0) && (_jsxs("div", { className: `flex flex-col gap-3 py-2 ${bodyCls}`.trim(), children: [fields.map((f, i) => renderFormChild(f, i, initialValues, errors)), contentFooter.map((c, i) => renderElement(c, fields.length + i))] })), !hasForm && contentFooter.length === 0 && stickyMode && _jsx("div", { className: bodyCls }), serverError && (_jsx("p", { className: `py-2 text-sm text-destructive ${stickyMode ? 'px-6' : ''}`.trim(), children: serverError })), _jsxs(DialogFooter, { className: footerCls, children: [_jsx("button", { type: "button", onClick: () => setOpen(false), className: cancelClass, children: cancelLabel }), _jsx("button", { type: "submit", disabled: submitting, autoFocus: submitAutofocus, className: confirmClass, children: submitting ? 'Working…' : submitLabel })] })] })] }) })] }));
589
- }
590
- /**
591
- * Confirm-style dialog wrapping an action's button. The trigger button is
592
- * rendered inline; clicking it opens the dialog. On confirm we run
593
- * `onConfirm` (which is action-style-specific — submit a form, programmatic
594
- * POST, etc.) and close the dialog. Used by submit-style and form-method
595
- * actions; handler-style + confirm/modal flows through ActionModalDialog
596
- * instead.
597
- */
598
- function ConfirmActionDialog({ trigger, title, message, destructive, onConfirm, }) {
599
- const [open, setOpen] = useState(false);
600
- const confirmClass = destructive
601
- ? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90'
602
- : 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90';
603
- return (_jsxs(_Fragment, { children: [trigger(() => setOpen(true)), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: title ?? 'Are you sure?' }), _jsx(DialogDescription, { children: message })] }), _jsxs(DialogFooter, { children: [_jsx("button", { type: "button", onClick: () => setOpen(false), className: "inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: "Cancel" }), _jsx("button", { type: "button", onClick: () => { setOpen(false); onConfirm(); }, className: confirmClass, autoFocus: true, children: destructive ? 'Delete' : 'Confirm' })] })] }) })] }));
604
- }
605
- /**
606
- * Button + optional confirm dialog for a form-method action (Delete and
607
- * the like). Click → fetch + JSON dispatch via `dispatchMethodAction` —
608
- * no full page reload, no server-rendered form. Confirm dialog gates the
609
- * dispatch when configured.
610
- */
611
- function MethodActionButton({ url, method, confirm, destructive, className, name, ariaLabel, tooltip, inner, }) {
612
- const navigate = useNavigate();
613
- const { notify } = useToast();
614
- const dispatch = () => {
615
- if (!url)
616
- return;
617
- void dispatchMethodAction(url, method, navigate, notify);
618
- };
619
- if (confirm) {
620
- return (_jsx(ConfirmActionDialog, { title: confirm.title, message: confirm.message, destructive: destructive, onConfirm: dispatch, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }));
621
- }
622
- return withTooltip(_jsx("button", { type: "button", onClick: dispatch, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip);
623
- }
624
- /**
625
- * Button for a handler-style action without confirm/modal. Click →
626
- * fetch + JSON via `dispatchHandlerAction`, then SPA-navigate +
627
- * show notifications. No full page reload.
628
- */
629
- function HandlerActionButton({ url, ids, className, name, ariaLabel, tooltip, inner, }) {
630
- const navigate = useNavigate();
631
- const { notify } = useToast();
632
- return withTooltip(_jsx("button", { type: "button", onClick: () => void dispatchHandlerAction(url, ids, navigate, notify), className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip);
633
- }
634
- /** Render either a single Action or an ActionGroup based on `el.type`.
635
- * Used by callsites that accept both (table header / bulk toolbars,
636
- * heading actions, container schemas). */
42
+ /** Thin wrapper that binds the renderer-injected deps so call sites
43
+ * inside this file can keep the original three-arg signature. The
44
+ * action layer (Phase 3) lives behind `renderActionLikeImpl`; it needs
45
+ * `renderElement` + `renderFormChild` for nested schemas + modal-form
46
+ * bodies. Both are function declarations so hoisting handles the
47
+ * forward reference cleanly. */
637
48
  function renderActionLike(el, index, opts = {}) {
638
- if (el.type === 'slotComponent') {
639
- // Plugin-contributed React mount — render through the main element
640
- // dispatcher, which looks up the registered component and forwards
641
- // its serialised props bag. Keeps every action-row slot (heading
642
- // children, alert footer, empty-state footer, table-toolbar bulk
643
- // strip) usable as a plugin extension point.
644
- return renderElement(el, index);
645
- }
646
- if (el.type === 'actionGroup') {
647
- return _jsx(ActionGroupTrigger, { el: el, ids: opts.ids ?? [] }, index);
648
- }
649
- return renderAction(el, index, opts);
650
- }
651
- /** Color preset → tailwind class group. `ghost` is bg-less and works
652
- * with hover:bg-accent. `destructive` uses a soft tonal style (Filament-
653
- * style) so per-row Delete buttons sit calmly next to primary actions
654
- * instead of shouting in saturated red — the modal confirm CTA still
655
- * renders solid red via its own hardcoded class. Others are solid + hover-
656
- * darken. */
657
- const COLOR_VARIANTS = {
658
- primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
659
- destructive: 'bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-400 dark:hover:bg-red-950/60',
660
- success: 'bg-emerald-600 text-white hover:bg-emerald-600/90',
661
- warning: 'bg-amber-500 text-white hover:bg-amber-500/90',
662
- info: 'bg-blue-600 text-white hover:bg-blue-600/90',
663
- ghost: 'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground',
664
- };
665
- /** Outlined variant — replaces solid bg with a border + transparent bg. */
666
- const OUTLINED_VARIANTS = {
667
- primary: 'border border-primary/40 text-primary bg-transparent hover:bg-primary/10',
668
- destructive: 'border border-destructive/40 text-destructive bg-transparent hover:bg-destructive/10',
669
- success: 'border border-emerald-600/40 text-emerald-700 dark:text-emerald-400 bg-transparent hover:bg-emerald-600/10',
670
- warning: 'border border-amber-500/40 text-amber-700 dark:text-amber-400 bg-transparent hover:bg-amber-500/10',
671
- info: 'border border-blue-600/40 text-blue-700 dark:text-blue-400 bg-transparent hover:bg-blue-600/10',
672
- ghost: 'border border-input text-foreground bg-transparent hover:bg-accent',
673
- };
674
- /** Size preset → tailwind sizing classes. Icon-only buttons use the
675
- * width=height variants from the second map. */
676
- const SIZE_CLASSES = {
677
- sm: 'h-7 px-2 text-xs',
678
- md: 'h-8 px-3 text-sm',
679
- lg: 'h-10 px-4 text-base',
680
- };
681
- const ICON_SIZE_CLASSES = {
682
- sm: 'h-7 w-7 text-xs',
683
- md: 'h-8 w-8 text-sm',
684
- lg: 'h-10 w-10 text-base',
685
- };
686
- /** Build the trigger button className from action meta + render context. */
687
- function actionButtonClass(el, opts) {
688
- const destructive = Boolean(el['destructive']);
689
- const placement = String(el['placement'] ?? 'inline');
690
- const outlined = Boolean(el['outlined']);
691
- const iconOnly = Boolean(el['iconOnly']);
692
- const explicitColor = el['color'];
693
- const explicitSize = el['size'];
694
- // Color: explicit `.color()` wins; `destructive` flag falls back to
695
- // 'destructive'; otherwise 'primary'.
696
- const color = explicitColor ?? (destructive ? 'destructive' : 'primary');
697
- const variant = (outlined ? OUTLINED_VARIANTS[color] : COLOR_VARIANTS[color]) ?? COLOR_VARIANTS['primary'];
698
- // Size: explicit `.size()` wins; otherwise small for row context, md elsewhere.
699
- const size = explicitSize ?? (opts.size === 'sm' || placement === 'row' ? 'sm' : 'md');
700
- const sizingMap = iconOnly ? ICON_SIZE_CLASSES : SIZE_CLASSES;
701
- const sizing = sizingMap[size] ?? sizingMap['md'];
702
- return `relative inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition ${variant} ${sizing}`;
703
- }
704
- /** Render the action's icon (when set). String names resolve through the
705
- * user-extensible icon registry; missing names render nothing rather
706
- * than a fallback glyph (action icons are decorative, not load-bearing). */
707
- function renderActionIcon(el) {
708
- const name = typeof el['icon'] === 'string' ? el['icon'] : undefined;
709
- const Icon = resolveIcon(name);
710
- if (!Icon)
711
- return null;
712
- return _jsx(Icon, { className: "size-4", "aria-hidden": "true" });
713
- }
714
- /** Tiny corner badge for actions that set `.badge(...)`. */
715
- function renderActionBadge(el) {
716
- const value = el['badge'];
717
- if (value === undefined || value === null || value === '')
718
- return null;
719
- const color = el['badgeColor'] ?? 'bg-primary text-primary-foreground';
720
- return (_jsx("span", { className: `absolute -top-1 -right-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium ${color}`, children: String(value) }));
721
- }
722
- /** If `meta.tooltip` is set, wrap the trigger in a Tooltip. The Tooltip's
723
- * provider mounts on demand so multiple actions on a page don't share
724
- * state. */
725
- function withTooltip(node, tooltip) {
726
- if (!tooltip)
727
- return node;
728
- return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: () => node }), _jsx(TooltipContent, { children: tooltip })] }) }));
729
- }
730
- function renderAction(el, index, opts = {}) {
731
- const name = String(el['name'] ?? '');
732
- const label = String(el['label'] ?? name);
733
- const destructive = Boolean(el['destructive']);
734
- const href = el['href'];
735
- const method = el['method'];
736
- const actionUrl = el['action'];
737
- const dispatchUrl = el['dispatchUrl'];
738
- const submit = Boolean(el['submit']);
739
- const confirm = el['confirm'];
740
- const tooltip = el['tooltip'];
741
- const iconOnly = Boolean(el['iconOnly']);
742
- const isDisabled = Boolean(el['disabled']);
743
- const className = actionButtonClass(el, opts) + (isDisabled ? ' opacity-50 cursor-not-allowed pointer-events-none' : '');
744
- const icon = renderActionIcon(el);
745
- const badge = renderActionBadge(el);
746
- // Icon-only buttons hide the label visually but expose it via aria-label.
747
- const ariaLabel = iconOnly ? label : undefined;
748
- const inner = iconOnly ? _jsxs(_Fragment, { children: [icon, badge] }) : _jsxs(_Fragment, { children: [icon, _jsx("span", { children: label }), badge] });
749
- // Submit-style action — renders as <button type="submit">. Optionally
750
- // targets a specific form via the HTML `form="<id>"` attribute so the
751
- // button can submit a form it lives outside of (e.g. a page-header
752
- // Save button driving a form below). When `formField` is set, the
753
- // button posts a sentinel name/value pair (e.g. `_continueCreate=1`)
754
- // so the server can branch on which submit was clicked.
755
- if (submit) {
756
- const formTarget = el['form'];
757
- const formField = el['formField'];
758
- if (confirm) {
759
- // Confirm-gated submit: render as type="button" so click opens the
760
- // dialog instead of submitting; on confirm, programmatically submit
761
- // the targeted form (or the closest enclosing form if no formTarget).
762
- // `formField` is intentionally not threaded here — programmatic
763
- // `requestSubmit()` has no submitter, so the name/value pair would
764
- // be lost anyway. Pair `.confirm()` with a hidden input on the form
765
- // if you need a sentinel under a confirm flow.
766
- return (_jsx(ConfirmActionDialog, { title: confirm.title, message: confirm.message, destructive: destructive, onConfirm: () => {
767
- if (typeof document === 'undefined')
768
- return;
769
- const form = formTarget
770
- ? document.getElementById(formTarget)
771
- : document.querySelector('form');
772
- form?.requestSubmit();
773
- }, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }, index));
774
- }
775
- return withTooltip(_jsx("button", { type: "submit", form: formTarget, className: className, "data-action-name": name, "aria-label": ariaLabel, ...(formField ? { name: formField.name, value: formField.value } : {}), children: inner }, index), tooltip);
776
- }
777
- // Substitute the `:id` placeholder with the current row id when this
778
- // action is rendered in a row context. Lets row-level link/form actions
779
- // ship a single template URL like `/admin/articles/:id/edit`.
780
- const rowId = opts.ids?.length === 1 ? opts.ids[0] : undefined;
781
- const resolveTemplate = (s) => s && rowId ? s.replace(':id', rowId) : s;
782
- // Link-style action.
783
- if (href) {
784
- return withTooltip(_jsx("a", { href: resolveTemplate(href), className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }, index), tooltip);
785
- }
786
- // Form-style action (POST/PUT/PATCH/DELETE) — fetch + JSON, no full reload.
787
- if (method) {
788
- const resolvedUrl = resolveTemplate(actionUrl);
789
- return (_jsx(MethodActionButton, { url: resolvedUrl, method: method, confirm: confirm, destructive: destructive, className: className, name: name, ariaLabel: ariaLabel, tooltip: tooltip, inner: inner }, index));
790
- }
791
- // Handler-style action — fetch + JSON dispatch with `ids[]` body.
792
- if (dispatchUrl) {
793
- const ids = opts.ids ?? [];
794
- const modal = el['modal'];
795
- if (confirm || modal) {
796
- return (_jsx(ActionModalDialog, { meta: el, ids: ids, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }, index));
797
- }
798
- return (_jsx(HandlerActionButton, { url: dispatchUrl, ids: ids, className: className, name: name, ariaLabel: ariaLabel, tooltip: tooltip, inner: inner }, index));
799
- }
800
- // No dispatch wired (no href / method / dispatchUrl). Render a disabled
801
- // placeholder so the user sees the button, but it does nothing.
802
- return withTooltip(_jsx("button", { type: "button", disabled: true, className: className + ' opacity-50 cursor-not-allowed', "data-action-name": name, "aria-label": ariaLabel, children: inner }, index), tooltip);
803
- }
804
- // ─── Layout helpers ─────────────────────────────────────────
805
- /**
806
- * Map `meta._layout` (Plan #8 — `columnSpan / columnStart / columnOrder`)
807
- * onto Tailwind utility classes. Returns an empty string when the
808
- * element has no layout hints. Outside of a parent Grid/Split the
809
- * classes have no effect — Tailwind generates `col-span-*` /
810
- * `col-start-*` / `order-*` regardless of context.
811
- *
812
- * Tailwind's JIT only ships utilities up to a fixed range; clamp here
813
- * to the safe defaults (1..12 for col, 1..12 for order).
814
- */
815
- function layoutClasses(el) {
816
- const layout = el['_layout'];
817
- if (!layout)
818
- return '';
819
- const out = [];
820
- if (typeof layout.columnSpan === 'number') {
821
- const span = Math.max(1, Math.min(12, layout.columnSpan));
822
- out.push(`col-span-${span}`);
823
- }
824
- if (typeof layout.columnStart === 'number') {
825
- const start = Math.max(1, Math.min(12, layout.columnStart));
826
- out.push(`col-start-${start}`);
827
- }
828
- if (typeof layout.columnOrder === 'number') {
829
- const order = Math.max(1, Math.min(12, layout.columnOrder));
830
- out.push(`order-${order}`);
831
- }
832
- return out.join(' ');
833
- }
834
- // ─── Container helpers ──────────────────────────────────────
835
- function renderChildren(children, gap = 'gap-4') {
836
- if (!children || children.length === 0)
837
- return null;
838
- return (_jsx("div", { className: `flex flex-col ${gap}`, children: children.map((child, i) => renderElement(child, i)) }));
839
- }
840
- // ─── Tabs (stateful — needs useState) ────────────────────────
841
- /**
842
- * Active-filters bar — pill row above the table summarising every filter
843
- * with a current value. Each pill shows the filter's `indicator` text
844
- * (server-formatted via `Filter.indicator()` / per-subclass defaults) and
845
- * an `×` button that clears that filter's URL key in place. Clicking ×
846
- * also drops `?page` so users land on the first page of the relaxed set.
847
- *
848
- * Renders nothing when no filter has an indicator.
849
- */
850
- function ActiveFiltersBar({ filters, prefix }) {
851
- const navigate = useNavigate();
852
- const active = filters.filter(f => typeof f['indicator'] === 'string' && f['indicator'] !== '');
853
- if (active.length === 0)
854
- return null;
855
- const clear = (name) => {
856
- if (typeof window === 'undefined')
857
- return;
858
- const url = new URL(window.location.href);
859
- url.searchParams.delete(prefixK(prefix, name));
860
- url.searchParams.delete(prefixK(prefix, 'page'));
861
- void navigate(url.pathname + url.search);
862
- };
863
- const clearAll = () => {
864
- if (typeof window === 'undefined')
865
- return;
866
- const url = new URL(window.location.href);
867
- for (const f of active)
868
- url.searchParams.delete(prefixK(prefix, String(f['name'] ?? '')));
869
- url.searchParams.delete(prefixK(prefix, 'page'));
870
- void navigate(url.pathname + url.search);
871
- };
872
- return (_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-xs", children: [active.map((f, i) => {
873
- const name = String(f['name'] ?? '');
874
- const indicator = String(f['indicator'] ?? '');
875
- return (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 pl-2.5 pr-1 py-0.5", children: [_jsx("span", { children: indicator }), _jsx("button", { type: "button", onClick: () => clear(name), "aria-label": `Clear filter ${indicator}`, className: "inline-flex size-4 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", children: "\u00D7" })] }, i));
876
- }), active.length > 1 && (_jsx("button", { type: "button", onClick: clearAll, className: "text-muted-foreground hover:text-foreground underline-offset-2 hover:underline", children: "Clear all" }))] }));
877
- }
878
- /**
879
- * Filter icon button + Popover containing every filter control.
880
- * Opens on click; the inner Selects don't dismiss the outer Popover when
881
- * an option is chosen (Base UI Popover doesn't auto-close on inner clicks).
882
- *
883
- * Each FilterSelect navigates the page on change (window.location), so the
884
- * filter form is no longer needed — keeps the search input in its own
885
- * lightweight form for native Enter-to-submit.
886
- */
887
- function FilterPopover({ filters, prefix }) {
888
- const activeCount = filters.filter(f => {
889
- const v = f['value'];
890
- return typeof v === 'string' && v !== '';
891
- }).length;
892
- return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { render: (props) => (_jsxs("button", { ...props, type: "button", "aria-label": "Filters", className: "relative inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: [_jsx(FilterIcon, { className: "size-4" }), _jsx("span", { children: "Filters" }), activeCount > 0 && (_jsx("span", { className: "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground", children: activeCount }))] })) }), _jsx(PopoverContent, { align: "start", className: filters.some(f => f['kind'] === 'queryBuilder')
893
- ? 'w-[36rem] max-w-[calc(100vw-2rem)] p-3'
894
- : 'w-72 p-3', children: _jsx("div", { className: "flex flex-col gap-3", children: filters.map((f, i) => renderFilterControl(f, i, prefix)) }) })] }));
895
- }
896
- /**
897
- * Inline strip of filter controls — used by `Table.filtersLayout('above-content'
898
- * | 'above-content-collapsible' | 'below-content')`. Mirrors `FilterPopover`'s
899
- * inner body but lays the controls out in a wrapping row instead of a
900
- * vertical stack inside a popover.
901
- */
902
- function FilterStrip({ filters, prefix }) {
903
- if (filters.length === 0)
904
- return null;
905
- return (_jsx("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:flex-wrap sm:items-end", children: filters.map((f, i) => (_jsx("div", { className: "min-w-[12rem] flex-1 sm:max-w-xs", children: renderFilterControl(f, i, prefix) }, i))) }));
906
- }
907
- /**
908
- * Toolbar button paired with `FilterStrip` for `Table.filtersLayout(
909
- * 'above-content-collapsible')`. Visually matches the modal-mode trigger
910
- * (filter icon + "Filters" label + active-count badge) but flips a parent-
911
- * owned `open` state instead of opening a Popover.
912
- */
913
- function FilterStripToggle({ filters, open, onToggle, }) {
914
- const activeCount = filters.filter(f => {
915
- const v = f['value'];
916
- return typeof v === 'string' && v !== '';
917
- }).length;
918
- return (_jsxs("button", { type: "button", "aria-label": "Filters", "aria-expanded": open, onClick: onToggle, className: "relative inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: [_jsx(FilterIcon, { className: "size-4" }), _jsx("span", { children: "Filters" }), activeCount > 0 && (_jsx("span", { className: "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground", children: activeCount }))] }));
919
- }
920
- /**
921
- * Render row actions inline. Each Action becomes a small button next to
922
- * the others; an `ActionGroup` placed in row position keeps its dropdown
923
- * via `ActionGroupTrigger` (the dropdown UX is opt-in via grouping, not
924
- * a default). Per-row visibility and disabled state come from the
925
- * server-side eval inside `dispatchTable` (`_visibleActions` /
926
- * `_disabledActions` keys on the row).
927
- *
928
- * Each Action's dispatch (link / fetch+JSON / modal / confirm) is handled
929
- * by `renderActionLike` → `renderAction`, same path as header / inline /
930
- * bulk placements. The `:id` substitution comes from `opts.ids = [rowId]`.
931
- */
932
- function renderRowActions(rowId, rowRecord, actions) {
933
- const rowVisibleSet = new Set(rowRecord?.['_visibleActions'] ?? []);
934
- const rowDisabledSet = new Set(rowRecord?.['_disabledActions'] ?? []);
935
- const visible = actions.filter(a => {
936
- if (!a['conditional'])
937
- return true;
938
- return rowVisibleSet.has(String(a['name'] ?? ''));
939
- });
940
- const decorate = (a) => {
941
- const name = String(a['name'] ?? '');
942
- if (rowDisabledSet.has(name)) {
943
- return { ...a, disabled: true };
944
- }
945
- return a;
946
- };
947
- return (_jsx("div", { className: "flex items-center justify-end gap-1", children: visible.map((a, i) => renderActionLike(decorate(a), i, { ids: [rowId], size: 'sm' })) }));
948
- }
949
- /**
950
- * Trigger button + dropdown menu for an `ActionGroup` meta. Reuses the
951
- * action button styling helpers so a group's chrome (color/size/outlined/
952
- * tooltip/iconButton) matches a regular Action. Each child Action
953
- * dispatches via the same logic as `renderAction` — link/method/handler/
954
- * confirm/modal — but routed through a `pending` state so the dropdown
955
- * closes before any dialog opens (shadcn pattern: one popup at a time).
956
- */
957
- function ActionGroupTrigger({ el, ids = [], }) {
958
- const [pending, setPending] = useState(null);
959
- const navigate = useNavigate();
960
- const { notify } = useToast();
961
- const name = String(el['name'] ?? '');
962
- const label = String(el['label'] ?? name);
963
- const tooltip = el['tooltip'];
964
- const iconOnly = Boolean(el['iconOnly']);
965
- const isDisabled = Boolean(el['disabled']);
966
- const childActions = (el.children ?? []).filter(c => c.type === 'action');
967
- const className = actionButtonClass(el, {}) + (isDisabled ? ' opacity-50 cursor-not-allowed pointer-events-none' : '');
968
- const ariaLabel = iconOnly ? label : undefined;
969
- // Direct-dispatch path mirrors renderAction's branches but skipping
970
- // confirm/modal (those queue into `pending` so the dropdown can close).
971
- const dispatch = (action) => {
972
- const href = action['href'];
973
- const method = action['method'];
974
- const actionUrl = action['action'];
975
- const dispatchUrl = action['dispatchUrl'];
976
- if (href) {
977
- navigate(href);
978
- return;
979
- }
980
- if (method && actionUrl) {
981
- void dispatchMethodAction(actionUrl, method, navigate, notify);
982
- return;
983
- }
984
- if (dispatchUrl) {
985
- void dispatchHandlerAction(dispatchUrl, ids, navigate, notify);
986
- return;
987
- }
988
- };
989
- const onItemClick = (action) => {
990
- if (action['modal'] || action['confirm']) {
991
- setPending(action);
992
- return;
993
- }
994
- dispatch(action);
995
- };
996
- const pendingHandler = pending && pending['dispatchUrl'];
997
- const pendingConfirmOnly = pending && !pendingHandler && pending['confirm'];
998
- const pendingConfirm = pendingConfirmOnly || pending?.['confirm'];
999
- return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: (props) => withTooltip(_jsx("button", { ...props, type: "button", className: className, "data-action-group-name": name, "aria-label": ariaLabel, children: iconOnly ? null : _jsx("span", { children: label }) }), tooltip) }), _jsx(DropdownMenuContent, { align: "end", children: childActions.map((a, i) => {
1000
- const itemLabel = String(a['label'] ?? a['name'] ?? '');
1001
- const destructive = Boolean(a['destructive']);
1002
- const itemDisabled = Boolean(a['disabled']);
1003
- return (_jsx(DropdownMenuItem, { destructive: destructive, disabled: itemDisabled, onClick: () => { if (!itemDisabled)
1004
- onItemClick(a); }, children: itemLabel }, i));
1005
- }) })] }), pendingHandler && pending && (_jsx(ActionModalDialog, { meta: pending, ids: ids, open: true, onOpenChange: (o) => { if (!o)
1006
- setPending(null); } })), _jsx(Dialog, { open: Boolean(pendingConfirmOnly), onOpenChange: (o) => { if (!o)
1007
- setPending(null); }, children: _jsx(DialogContent, { children: pendingConfirmOnly && pendingConfirm && (_jsxs(_Fragment, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: pendingConfirm.title ?? 'Are you sure?' }), _jsx(DialogDescription, { children: pendingConfirm.message })] }), _jsxs(DialogFooter, { children: [_jsx("button", { type: "button", onClick: () => setPending(null), className: "inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: "Cancel" }), _jsx("button", { type: "button", autoFocus: true, onClick: () => {
1008
- const action = pending;
1009
- setPending(null);
1010
- if (action)
1011
- dispatch(action);
1012
- }, className: pending && pending['destructive']
1013
- ? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90'
1014
- : 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90', children: pending && pending['destructive'] ? 'Delete' : 'Confirm' })] })] })) }) })] }));
1015
- }
1016
- function TabsRenderer({ el, index }) {
1017
- const tabs = (el.children ?? []).filter(c => c.type === 'tab');
1018
- if (tabs.length === 0)
1019
- return null;
1020
- const variant = el['variant'] === 'underline' ? 'underline' : 'pills';
1021
- const tabValues = tabs.map((_, i) => `tab-${i}`);
1022
- const defaultValue = tabValues[0];
1023
- // Underline variant overrides the primitive's pill chrome with a bottom
1024
- // border on the list and per-trigger underline-on-selected. No
1025
- // `<TabsIndicator>` is rendered, so there's no sliding pill to hide.
1026
- const listClass = variant === 'underline'
1027
- ? 'relative flex h-auto w-fit justify-start gap-0 rounded-none bg-transparent p-0 text-muted-foreground border-b border-border'
1028
- : undefined;
1029
- const triggerClass = variant === 'underline'
1030
- ? 'rounded-none border-0 border-b-2 border-transparent bg-transparent px-4 py-2 text-sm font-medium -mb-px data-[active]:border-primary data-[active]:text-foreground data-[active]:bg-transparent data-[active]:shadow-none'
1031
- : undefined;
1032
- return (_jsxs(Tabs, { defaultValue: defaultValue, children: [_jsx(TabsList, { className: listClass, children: tabs.map((tab, i) => (_jsxs(TabsTrigger, { value: tabValues[i], className: triggerClass, children: [String(tab['label'] ?? ''), tab['badge'] ? (_jsx("span", { className: "ml-2 text-xs px-1.5 py-0.5 rounded-full bg-muted", children: String(tab['badge']) })) : null] }, i))) }), tabs.map((tab, i) => (_jsx(TabsContent, { value: tabValues[i], className: "pt-2", children: renderChildren(tab['children']) }, i)))] }, index));
1033
- }
1034
- // ─── Section (stateful when collapsible) ────────────────────
1035
- function SectionRenderer({ el, index }) {
1036
- const title = el['title'] ? String(el['title']) : undefined;
1037
- const description = el['description'] ? String(el['description']) : undefined;
1038
- const iconName = el['icon'] ? String(el['icon']) : undefined;
1039
- const badge = el['badge'] ? String(el['badge']) : undefined;
1040
- const columns = Number(el['columns'] ?? 1);
1041
- const collapsible = Boolean(el['collapsible']);
1042
- const compact = Boolean(el['compact']);
1043
- const dense = Boolean(el['dense']);
1044
- const secondary = Boolean(el['secondary']);
1045
- const afterHeader = el['afterHeader'] ?? [];
1046
- const persist = Boolean(el['persistCollapsed']);
1047
- const persistKey = el['persistKey']
1048
- ? `pilotiq.section.${String(el['persistKey'])}`
1049
- : title
1050
- ? `pilotiq.section.${title.toLowerCase().replace(/\s+/g, '-')}`
1051
- : undefined;
1052
- const [collapsed, setCollapsed] = useState(Boolean(el['defaultCollapsed']));
1053
- // Plan #8 — persist open/closed state to localStorage. Hydration-safe:
1054
- // initial render uses `defaultCollapsed`; effect overrides from storage
1055
- // after mount so server + client first paint agree.
1056
- useEffect(() => {
1057
- if (!persist || !persistKey)
1058
- return;
1059
- if (typeof window === 'undefined')
1060
- return;
1061
- try {
1062
- const stored = window.localStorage.getItem(persistKey);
1063
- if (stored === '0')
1064
- setCollapsed(false);
1065
- if (stored === '1')
1066
- setCollapsed(true);
1067
- }
1068
- catch { /* localStorage may be unavailable (private mode) */ }
1069
- }, [persist, persistKey]);
1070
- useEffect(() => {
1071
- if (!persist || !persistKey)
1072
- return;
1073
- if (typeof window === 'undefined')
1074
- return;
1075
- try {
1076
- window.localStorage.setItem(persistKey, collapsed ? '1' : '0');
1077
- }
1078
- catch { /* ignore */ }
1079
- }, [persist, persistKey, collapsed]);
1080
- // `dense` tightens the inner spacing between the section's children
1081
- // (orthogonal to `compact`, which trims the section's outer padding /
1082
- // heading). gap-2 ≈ 8px vs gap-4 ≈ 16px.
1083
- const innerGap = dense ? 'gap-2' : 'gap-4';
1084
- const gridClass = columns === 2 ? `grid grid-cols-2 ${innerGap}` : columns === 3 ? `grid grid-cols-3 ${innerGap}` : `flex flex-col ${innerGap}`;
1085
- const padding = compact ? 'p-3' : 'p-4';
1086
- const titleSize = compact ? 'text-sm' : 'text-base';
1087
- const Icon = resolveIcon(iconName);
1088
- // `secondary()` flips the section background to the muted token so it
1089
- // visually recedes beneath a primary section. The border thins to the
1090
- // same muted tone for the same reason — a sharp `border-input` line
1091
- // around a muted block looks like a typographic ledger rather than a
1092
- // grouping container.
1093
- const surfaceClass = secondary ? 'bg-muted/40 border-muted' : 'bg-card';
1094
- return (_jsxs("section", { className: `flex flex-col ${compact ? 'gap-2' : 'gap-3'} rounded-lg border ${surfaceClass} ${padding} ${layoutClasses(el)}`.trim(), children: [(title || description || collapsible || badge || afterHeader.length > 0) && (_jsxs("header", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { className: "flex items-start gap-2", children: [Icon && _jsx(Icon, { className: "size-4 mt-0.5 text-muted-foreground", "aria-hidden": "true" }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [title && _jsx("h3", { className: `${titleSize} font-semibold`, children: title }), badge && (_jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: badge }))] }), description && _jsx("p", { className: "text-xs text-muted-foreground mt-0.5", children: description })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [afterHeader.length > 0 && (_jsx("div", { className: "flex items-center gap-1", children: afterHeader.map((a, i) => renderElement(a, i)) })), collapsible && (_jsx("button", { type: "button", onClick: () => setCollapsed(c => !c), className: "text-xs text-muted-foreground hover:text-foreground", children: collapsed ? 'Expand' : 'Collapse' }))] })] })), !collapsed && el.children && el.children.length > 0 && (_jsx("div", { className: gridClass, children: el.children.map((c, i) => renderElement(c, i)) }))] }, index));
1095
- }
1096
- // ─── Wizard (Plan #8) ───────────────────────────────────────
1097
- /**
1098
- * Resolve the initial active step for `WizardRenderer`. Priority:
1099
- * 1. URL `?<queryKey>=N` (1-based — wizards expose human-friendly indexes
1100
- * when `Wizard.persistStepInQueryString()` is enabled).
1101
- * 2. `localStorage[<storageKey>]` (0-based, set by the persist effect).
1102
- * 3. `startOnStep` configured on the Wizard.
1103
- *
1104
- * SSR-safe: returns `startOnStep` when `window` is undefined.
1105
- */
1106
- function readInitialWizardStep(total, startOnStep, storageKey, queryKey) {
1107
- if (typeof window === 'undefined')
1108
- return startOnStep;
1109
- if (queryKey) {
1110
- try {
1111
- const raw = new URL(window.location.href).searchParams.get(queryKey);
1112
- if (raw !== null && raw !== '') {
1113
- const n = Number(raw) - 1;
1114
- if (Number.isFinite(n) && n >= 0 && n < total)
1115
- return n;
1116
- }
1117
- }
1118
- catch { /* ignore */ }
1119
- }
1120
- if (storageKey) {
1121
- try {
1122
- const stored = window.localStorage.getItem(storageKey);
1123
- if (stored !== null) {
1124
- const n = Number(stored);
1125
- if (Number.isFinite(n) && n >= 0 && n < total)
1126
- return n;
1127
- }
1128
- }
1129
- catch { /* ignore */ }
1130
- }
1131
- return startOnStep;
1132
- }
1133
- /**
1134
- * Multi-step form layout. Tracks active step in `useState`, optionally
1135
- * persisted to localStorage and/or the URL query string. On Next click,
1136
- * POSTs `{ step, values }` to the form's `wizardUrl` (stamped by the
1137
- * route handler when the form has a Wizard descendant). 200 → advance;
1138
- * 422 → stamp inline errors; absent `wizardUrl` → advance immediately
1139
- * (no validation).
1140
- *
1141
- * Inactive steps render hidden (display:none) rather than unmounted so
1142
- * controlled inputs preserve their values across step transitions and
1143
- * cross-step `$get` works on the resolved meta.
1144
- *
1145
- * Nav buttons honor `Wizard.submitAction() / nextAction() / previousAction()`
1146
- * — chrome (label / icon / color / size / outlined / iconOnly / tooltip /
1147
- * disabled rules) carries through to the rendered button while the click
1148
- * behavior stays hardwired (advance / recede / submit-form). Bare wizards
1149
- * keep the built-in defaults.
1150
- */
1151
- function WizardRenderer({ el, index }) {
1152
- const formState = useFormState();
1153
- const formId = formState?.formMeta['formId'] ? String(formState.formMeta['formId']) : undefined;
1154
- const wizardUrl = formState?.formMeta['wizardUrl'] ? String(formState.formMeta['wizardUrl']) : undefined;
1155
- const steps = (el.children ?? []).filter(c => c.type === 'step');
1156
- const skippable = Boolean(el['skippable']);
1157
- const startOnStep = Math.max(0, Math.min(Math.max(0, steps.length - 1), Number(el['startOnStep'] ?? 0)));
1158
- const persist = el['persist'] !== false;
1159
- const storageKey = persist && formId ? `pilotiq.wizard.${formId}.step` : undefined;
1160
- const queryKey = typeof el['persistStepInQueryString'] === 'string'
1161
- ? String(el['persistStepInQueryString'])
1162
- : undefined;
1163
- const submitActionMeta = el['submitAction'];
1164
- const nextActionMeta = el['nextAction'];
1165
- const previousActionMeta = el['previousAction'];
1166
- // Initial-step resolution priority: URL (?<key>=N, 1-based) > localStorage >
1167
- // startOnStep. URL wins on first paint so deep links land on the right step
1168
- // before localStorage can override. Lazy initializer — resolution runs once.
1169
- const [active, setActive] = useState(() => readInitialWizardStep(steps.length, startOnStep, storageKey, queryKey));
1170
- const [advancing, setAdvancing] = useState(false);
1171
- const [advanceError, setAdvanceError] = useState(null);
1172
- // Persist active step changes to localStorage (when enabled).
1173
- useEffect(() => {
1174
- if (!storageKey)
1175
- return;
1176
- if (typeof window === 'undefined')
1177
- return;
1178
- try {
1179
- window.localStorage.setItem(storageKey, String(active));
1180
- }
1181
- catch { /* ignore */ }
1182
- }, [storageKey, active]);
1183
- // Mirror active step to the URL via replaceState — purely client-side state
1184
- // sync, no SPA re-fetch. 1-based externally; cleared when on the first step
1185
- // so bare URLs don't grow ?step=1 noise.
1186
- useEffect(() => {
1187
- if (!queryKey)
1188
- return;
1189
- if (typeof window === 'undefined')
1190
- return;
1191
- try {
1192
- const url = new URL(window.location.href);
1193
- if (active === 0)
1194
- url.searchParams.delete(queryKey);
1195
- else
1196
- url.searchParams.set(queryKey, String(active + 1));
1197
- window.history.replaceState(window.history.state, '', url.toString());
1198
- }
1199
- catch { /* ignore */ }
1200
- }, [queryKey, active]);
1201
- if (steps.length === 0) {
1202
- return (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: "No steps configured." }, index));
1203
- }
1204
- const isLast = active === steps.length - 1;
1205
- const isFirst = active === 0;
1206
- const advance = async (target) => {
1207
- setAdvanceError(null);
1208
- if (!wizardUrl) {
1209
- setActive(target);
1210
- return;
1211
- }
1212
- setAdvancing(true);
1213
- try {
1214
- const values = formState?.values ?? {};
1215
- // Validate intermediate steps in order when jumping ahead.
1216
- const path = target > active
1217
- ? Array.from({ length: target - active }, (_, k) => active + k)
1218
- : [active]; // jumping back is unconstrained
1219
- let landed = active;
1220
- for (const stepIdx of path) {
1221
- const res = await fetch(wizardUrl, {
1222
- method: 'POST',
1223
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
1224
- body: JSON.stringify({ step: stepIdx, values }),
1225
- });
1226
- if (res.status === 422) {
1227
- const data = await res.json().catch(() => ({}));
1228
- const errors = (data?.errors ?? {});
1229
- if (formState?.applyErrors)
1230
- formState.applyErrors(errors);
1231
- landed = stepIdx;
1232
- setAdvanceError('Please fix the highlighted fields.');
1233
- break;
1234
- }
1235
- if (!res.ok) {
1236
- setAdvanceError('Step validation failed.');
1237
- break;
1238
- }
1239
- landed = stepIdx + 1;
1240
- }
1241
- setActive(target > active ? landed : target);
1242
- }
1243
- catch {
1244
- setAdvanceError('Step validation failed.');
1245
- }
1246
- finally {
1247
- setAdvancing(false);
1248
- }
1249
- };
1250
- return (_jsxs("div", { className: `flex flex-col gap-6 ${layoutClasses(el)}`.trim(), children: [_jsx("ol", { className: "flex items-center gap-3 overflow-x-auto", "aria-label": "Wizard progress", children: steps.map((s, i) => {
1251
- const Icon = resolveIcon(s['icon'] ? String(s['icon']) : undefined);
1252
- const reachable = skippable || i <= active;
1253
- const isActive = i === active;
1254
- const isDone = i < active;
1255
- return (_jsxs("li", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("button", { type: "button", disabled: !reachable || advancing, onClick: () => reachable && advance(i), className: [
1256
- 'flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition',
1257
- isActive ? 'border-primary bg-primary/10 text-foreground'
1258
- : isDone ? 'border-border text-muted-foreground hover:bg-muted'
1259
- : 'border-border text-muted-foreground',
1260
- reachable ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed',
1261
- ].join(' '), "aria-current": isActive ? 'step' : undefined, children: [_jsx("span", { className: [
1262
- 'flex size-5 items-center justify-center rounded-full text-[11px] font-semibold',
1263
- isActive ? 'bg-primary text-primary-foreground'
1264
- : isDone ? 'bg-muted-foreground/20 text-foreground'
1265
- : 'bg-muted text-muted-foreground',
1266
- ].join(' '), children: Icon ? _jsx(Icon, { className: "size-3", "aria-hidden": "true" }) : i + 1 }), _jsx("span", { className: "font-medium", children: String(s['label'] ?? `Step ${i + 1}`) })] }), i < steps.length - 1 && _jsx("span", { className: "h-px w-6 bg-border", "aria-hidden": "true" })] }, i));
1267
- }) }), Boolean(steps[active]?.['description']) && (_jsx("p", { className: "text-sm text-muted-foreground", children: String(steps[active]['description']) })), steps.map((s, i) => (_jsx("div", { className: i === active ? 'flex flex-col gap-4' : 'hidden', "aria-hidden": i === active ? undefined : true, children: (s.children ?? []).map((c, ci) => renderElement(c, ci)) }, i))), advanceError && (_jsx("p", { className: "text-sm text-destructive", role: "alert", children: advanceError })), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(WizardNavButton, { actionMeta: previousActionMeta, fallbackLabel: "Back", disabled: isFirst || advancing, onClick: () => advance(active - 1) }), isLast
1268
- ? (submitActionMeta
1269
- ? _jsx(WizardNavButton, { actionMeta: submitActionMeta, fallbackLabel: "Submit", type: "submit", disabled: advancing })
1270
- : _jsx("span", { className: "text-xs text-muted-foreground", children: "Submit the form to finish." }))
1271
- : _jsx(WizardNavButton, { actionMeta: nextActionMeta, fallbackLabel: advancing ? 'Validating…' : 'Next', disabled: advancing, onClick: () => advance(active + 1) })] })] }, index));
1272
- }
1273
- /**
1274
- * Renders one wizard nav slot (Back / Next / Submit). Falls back to plain
1275
- * built-in chrome (border button for Back, primary button for Next/Submit)
1276
- * when no `actionMeta` is supplied; otherwise reads the resolved Action's
1277
- * chrome (`label / icon / color / size / outlined / iconOnly / tooltip /
1278
- * disabled`) and applies it to a button whose click is hardwired by the
1279
- * surrounding wizard. `type="submit"` lets the Submit slot trigger the
1280
- * surrounding form's onSubmit dispatcher (no `onClick` needed).
1281
- *
1282
- * Hidden actions (`.visible(false)` resolved-away) drop the slot entirely
1283
- * — the resolver returns `undefined` for hidden Action elements, which
1284
- * arrives here as `actionMeta == null` so we fall through to the default
1285
- * chrome. Use `Wizard.skippable()` semantics to hide nav buttons when
1286
- * appropriate; for permanent removal subclass the wizard.
1287
- */
1288
- function WizardNavButton({ actionMeta, fallbackLabel, type = 'button', disabled, onClick, }) {
1289
- // Bare default — keep historical chrome for back-compat (un-customized
1290
- // wizards look identical to before this change).
1291
- if (!actionMeta) {
1292
- const isPrimary = type === 'submit' || fallbackLabel !== 'Back';
1293
- return (_jsx("button", { type: type, disabled: disabled, onClick: onClick, className: isPrimary
1294
- ? 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
1295
- : 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed', children: fallbackLabel }));
1296
- }
1297
- const ownDisabled = Boolean(actionMeta['disabled']);
1298
- const label = String(actionMeta['label'] ?? fallbackLabel);
1299
- const tooltip = actionMeta['tooltip'] ? String(actionMeta['tooltip']) : undefined;
1300
- const iconOnly = Boolean(actionMeta['iconOnly']);
1301
- const className = actionButtonClass(actionMeta, {});
1302
- const node = (_jsxs("button", { type: type, disabled: disabled || ownDisabled, onClick: onClick, className: `${className} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": iconOnly ? label : undefined, children: [renderActionIcon(actionMeta), !iconOnly && _jsx("span", { children: label }), renderActionBadge(actionMeta)] }));
1303
- return _jsx(_Fragment, { children: withTooltip(node, tooltip) });
1304
- }
1305
- // ─── Top-level dispatch ─────────────────────────────────────
1306
- const TEXT_COLOR_CLASSES = {
1307
- default: '',
1308
- muted: 'text-muted-foreground',
1309
- primary: 'text-primary',
1310
- destructive: 'text-destructive',
1311
- success: 'text-emerald-600 dark:text-emerald-400',
1312
- warning: 'text-amber-600 dark:text-amber-400',
1313
- info: 'text-blue-600 dark:text-blue-400',
1314
- };
1315
- const TEXT_SIZE_CLASSES = {
1316
- xs: 'text-xs',
1317
- sm: 'text-sm',
1318
- base: 'text-base',
1319
- lg: 'text-lg',
1320
- xl: 'text-xl',
1321
- };
1322
- const TEXT_WEIGHT_CLASSES = {
1323
- normal: 'font-normal',
1324
- medium: 'font-medium',
1325
- semibold: 'font-semibold',
1326
- bold: 'font-bold',
1327
- };
1328
- function renderText(el, index) {
1329
- const content = String(el['content'] ?? '');
1330
- const color = el['color'] ? String(el['color']) : undefined;
1331
- const size = el['size'] ? String(el['size']) : undefined;
1332
- const weight = el['weight'] ? String(el['weight']) : undefined;
1333
- const isBadge = el['badge'] === true;
1334
- if (isBadge) {
1335
- const badgeKey = el['badgeColor'] ? String(el['badgeColor']) : 'gray';
1336
- const cls = BADGE_COLOR_CLASSES[badgeKey] ?? BADGE_COLOR_CLASSES['gray'];
1337
- return (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: content }, index));
1338
- }
1339
- // Defaults match the previous bare `<p>` for back-compat: text-sm + muted.
1340
- const sizeCls = size ? (TEXT_SIZE_CLASSES[size] ?? '') : 'text-sm';
1341
- const colorCls = color ? (TEXT_COLOR_CLASSES[color] ?? '') : 'text-muted-foreground';
1342
- const weightCls = weight ? (TEXT_WEIGHT_CLASSES[weight] ?? '') : '';
1343
- return (_jsx("p", { className: `${sizeCls} ${colorCls} ${weightCls}`.trim(), children: content }, index));
1344
- }
1345
- /** Coerce a `KeyValueEntry` state value (object | JSON string | …) into a
1346
- * flat record. Returns `null` when the value is empty or non-decodable. */
1347
- function normalizeKeyValueValue(value) {
1348
- if (value === null || value === undefined || value === '')
1349
- return null;
1350
- if (typeof value === 'string') {
1351
- try {
1352
- const parsed = JSON.parse(value);
1353
- if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
1354
- return parsed;
1355
- }
1356
- }
1357
- catch {
1358
- // Non-JSON string — fall through to null so the renderer shows the
1359
- // fallback rather than misrepresenting it as a one-row map.
1360
- }
1361
- return null;
1362
- }
1363
- if (Array.isArray(value))
1364
- return null;
1365
- if (typeof value === 'object')
1366
- return value;
1367
- return null;
1368
- }
1369
- /** Render a single kv cell value — primitives become their string form;
1370
- * nested objects/arrays JSON-stringify for compactness. */
1371
- function formatKeyValueCell(value) {
1372
- if (value === null || value === undefined)
1373
- return '';
1374
- if (typeof value === 'object')
1375
- return JSON.stringify(value);
1376
- return String(value);
1377
- }
1378
- /**
1379
- * Plan #16 — read-only label-value pair for `Resource.detail()` schemas.
1380
- * Dispatches on `meta.entryType` (`'text' | 'badge' | 'icon' | 'image' | 'keyValue' | 'color'`).
1381
- * Wraps the rendered value in `<EntryShell>` for the shared chrome
1382
- * (label / helperText / tooltip / copyable trigger).
1383
- */
1384
- function renderEntry(el, index) {
1385
- const entryType = String(el['entryType'] ?? 'text');
1386
- const value = el['value'];
1387
- const fallback = el['default'] ? String(el['default']) : '—';
1388
- let body;
1389
- switch (entryType) {
1390
- case 'text': {
1391
- const formatted = el['_formatted'] !== undefined
1392
- ? String(el['_formatted'])
1393
- : (el['format']
1394
- ? applyColumnFormat(value, el['format'])
1395
- : (value === null || value === undefined || value === '' ? '' : String(value)));
1396
- const display = formatted === '' ? fallback : formatted;
1397
- const isFallback = formatted === '';
1398
- const isRichText = el['richtext'] === true && !isFallback;
1399
- const sizeKey = el['size'] ? String(el['size']) : 'sm';
1400
- const colorKey = el['color'] ? String(el['color']) : (isFallback ? 'muted' : 'default');
1401
- const weightKey = el['weight'] ? String(el['weight']) : 'normal';
1402
- const sizeCls = TEXT_SIZE_CLASSES[sizeKey] ?? 'text-sm';
1403
- const colorCls = TEXT_COLOR_CLASSES[colorKey] ?? '';
1404
- const weightCls = TEXT_WEIGHT_CLASSES[weightKey] ?? '';
1405
- const lineClamp = el['lineClamp'];
1406
- const wrap = el['wrap'] === true;
1407
- const style = {};
1408
- if (lineClamp !== undefined) {
1409
- style.display = '-webkit-box';
1410
- style.WebkitLineClamp = lineClamp;
1411
- style.WebkitBoxOrient = 'vertical';
1412
- style.overflow = 'hidden';
1413
- }
1414
- const wrapCls = wrap ? 'whitespace-pre-wrap' : (lineClamp !== undefined ? '' : 'whitespace-nowrap');
1415
- if (isRichText) {
1416
- // Server-rendered HTML from a registered richtext renderer (e.g.
1417
- // `@pilotiq/tiptap`). Wrap in `prose` for sensible default
1418
- // styling — matches the read-only `Markdown` / `Html` primes.
1419
- const proseSize = sizeKey === 'lg' || sizeKey === 'xl'
1420
- ? 'prose-lg'
1421
- : sizeKey === 'sm' || sizeKey === 'xs'
1422
- ? 'prose-sm'
1423
- : '';
1424
- body = (_jsx("div", { className: `prose max-w-none dark:prose-invert ${proseSize} ${colorCls} ${weightCls}`.trim(), style: style, dangerouslySetInnerHTML: { __html: display } }));
1425
- break;
1426
- }
1427
- body = (_jsx("span", { className: `${sizeCls} ${colorCls} ${weightCls} ${wrapCls}`.trim(), style: style, children: display }));
1428
- break;
1429
- }
1430
- case 'badge': {
1431
- const isBlank = value === null || value === undefined || value === '';
1432
- if (isBlank) {
1433
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1434
- break;
1435
- }
1436
- const map = el['colors'] ?? {};
1437
- const colorKey = map[String(value)] ?? 'gray';
1438
- const cls = BADGE_COLOR_CLASSES[colorKey] ?? BADGE_COLOR_CLASSES['gray'];
1439
- body = (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: String(value) }));
1440
- break;
1441
- }
1442
- case 'icon': {
1443
- const isBlank = value === null || value === undefined || value === '';
1444
- const map = el['options'] ?? {};
1445
- const opt = isBlank ? undefined : map[String(value)];
1446
- if (!opt) {
1447
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1448
- break;
1449
- }
1450
- const Icon = resolveIcon(opt.icon) ?? CircleIcon;
1451
- const colorClass = opt.color ? (COLUMN_COLOR_CLASSES[opt.color] ?? '') : '';
1452
- const ariaLabel = opt.label ?? String(value);
1453
- body = _jsx(Icon, { className: `inline size-5 ${colorClass}`.trim(), "aria-label": ariaLabel });
1454
- break;
1455
- }
1456
- case 'image': {
1457
- const isBlank = value === null || value === undefined || value === '';
1458
- if (isBlank) {
1459
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1460
- break;
1461
- }
1462
- const url = String(value);
1463
- const width = el['imageWidth'] ?? el['imageSize'] ?? 64;
1464
- const height = el['imageHeight'] ?? el['imageSize'] ?? 64;
1465
- const shape = String(el['imageShape'] ?? 'rounded');
1466
- const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'square' ? '' : 'rounded-md';
1467
- body = (_jsx("img", { src: url, alt: "", width: width, height: height, className: `inline-block object-cover ${shapeCls}`.trim() }));
1468
- break;
1469
- }
1470
- case 'keyValue': {
1471
- const parsed = normalizeKeyValueValue(value);
1472
- const keys = parsed ? Object.keys(parsed) : [];
1473
- if (!parsed || keys.length === 0) {
1474
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1475
- break;
1476
- }
1477
- const keyLabel = el['keyLabel'] ? String(el['keyLabel']) : 'Key';
1478
- const valueLabel = el['valueLabel'] ? String(el['valueLabel']) : 'Value';
1479
- body = (_jsxs("table", { className: "w-full border border-border text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "bg-muted/50 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx("th", { className: "border-b border-border px-2 py-1", children: keyLabel }), _jsx("th", { className: "border-b border-border px-2 py-1", children: valueLabel })] }) }), _jsx("tbody", { children: keys.map(k => (_jsxs("tr", { className: "border-t border-border first:border-t-0", children: [_jsx("td", { className: "px-2 py-1 align-top font-mono text-xs", children: k }), _jsx("td", { className: "px-2 py-1 align-top font-mono text-xs break-all", children: formatKeyValueCell(parsed[k]) })] }, k))) })] }));
1480
- break;
1481
- }
1482
- case 'color': {
1483
- const isBlank = value === null || value === undefined || value === '';
1484
- if (isBlank) {
1485
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1486
- break;
1487
- }
1488
- const hex = String(value);
1489
- const width = el['colorWidth'] ?? el['colorSize'] ?? 24;
1490
- const height = el['colorHeight'] ?? el['colorSize'] ?? 24;
1491
- const shape = String(el['colorShape'] ?? 'rounded');
1492
- const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'square' ? '' : 'rounded-md';
1493
- const showValue = el['showValue'] !== false;
1494
- body = (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: `inline-block border border-border ${shapeCls}`.trim(), style: { width, height, backgroundColor: hex }, "aria-label": hex }), showValue && (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: hex }))] }));
1495
- break;
1496
- }
1497
- case 'code': {
1498
- const isBlank = value === null || value === undefined || value === '';
1499
- if (isBlank) {
1500
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1501
- break;
1502
- }
1503
- const text = typeof value === 'string' ? value : String(value);
1504
- const lang = el['language'] ? String(el['language']) : undefined;
1505
- body = (_jsx("pre", { className: "rounded-md border border-border bg-muted/40 p-3 text-xs overflow-x-auto", "data-language": lang, children: _jsx("code", { className: "font-mono", children: text }) }));
1506
- break;
1507
- }
1508
- case 'component': {
1509
- const componentName = String(el['component'] ?? '');
1510
- if (!componentName) {
1511
- body = (_jsxs(EntryComponentError, { children: ["ComponentEntry is missing its ", _jsx("code", { className: "font-mono", children: "component" }), " name \u2014 set ", _jsx("code", { className: "font-mono", children: "static componentName = '...'" }), " on the subclass or call ", _jsx("code", { className: "font-mono", children: ".component('...')" }), " in the fluent form."] }));
1512
- break;
1513
- }
1514
- const Component = getEntryComponent(componentName);
1515
- if (!Component) {
1516
- body = (_jsxs(EntryComponentError, { children: ["No component registered under name ", _jsx("code", { className: "font-mono", children: componentName }), ". Register it at app boot:", _jsx("pre", { className: "mt-2 overflow-x-auto rounded bg-amber-100/60 p-2 text-xs dark:bg-amber-900/30", children: `import { registerEntryComponents } from '@pilotiq/pilotiq/entries'\nregisterEntryComponents({ ${componentName}: ${componentName} })` })] }));
1517
- break;
1518
- }
1519
- // Render-time errors propagate to React's nearest error boundary —
1520
- // surfacing them inline here would require wrapping every entry in
1521
- // its own boundary, which v1 doesn't ship. The two pre-render
1522
- // sentinels above (missing name / missing registration) cover the
1523
- // typical wiring mistakes.
1524
- body = _jsx(Component, { value: value });
1525
- break;
1526
- }
1527
- case 'repeatable': {
1528
- // Read-only sibling of `Repeater`. Reads `meta.rows` (resolved by
1529
- // `resolveRepeatableRows`) and dispatches on the chosen layout —
1530
- // `table > grid > stack`. Empty / non-array state falls through to
1531
- // the inherited `default()` placeholder, same as every other entry.
1532
- const rows = el['rows'] ?? [];
1533
- if (rows.length === 0) {
1534
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1535
- break;
1536
- }
1537
- const tableCfg = el['table'];
1538
- const gridN = el['grid'];
1539
- const innerCols = el['columns'];
1540
- const contained = el['contained'] !== false;
1541
- if (tableCfg && tableCfg.columns.length > 0) {
1542
- const cols = tableCfg.columns;
1543
- body = (_jsxs("table", { className: "w-full border border-border text-sm", children: [cols.some(c => c.width) && (_jsx("colgroup", { children: cols.map((c, i) => (_jsx("col", { style: c.width ? { width: c.width } : undefined }, i))) })), _jsx("thead", { children: _jsx("tr", { className: "bg-muted/50 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground", children: cols.map((c, i) => (_jsx("th", { className: `border-b border-border px-2 py-1 ${c.alignment === 'right' ? 'text-right' : c.alignment === 'center' ? 'text-center' : ''}`.trim(), children: c.label }, i))) }) }), _jsx("tbody", { children: rows.map(row => (_jsx("tr", { className: "border-t border-border first:border-t-0 align-top", children: row.children.map((child, i) => {
1544
- const align = cols[i]?.alignment;
1545
- const alignCls = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : '';
1546
- return (_jsx("td", { className: `px-2 py-1 ${alignCls}`.trim(), children: renderElement(child, i) }, i));
1547
- }) }, row.id))) })] }));
1548
- break;
1549
- }
1550
- const cardCls = contained
1551
- ? 'rounded-md border border-border p-3 bg-background'
1552
- : '';
1553
- const innerColsCls = innerCols && innerCols >= 2
1554
- ? `grid gap-3 grid-cols-1 md:grid-cols-${Math.min(innerCols, 6)}`
1555
- : 'space-y-2';
1556
- const cards = rows.map(row => (_jsx("div", { className: `${cardCls} ${innerColsCls}`.trim(), children: row.children.map((child, i) => renderElement(child, i)) }, row.id)));
1557
- if (gridN && gridN >= 2) {
1558
- const cap = Math.min(gridN, 6);
1559
- body = (_jsx("div", { className: `w-full grid gap-3 grid-cols-1 md:grid-cols-${cap}`, children: cards }));
1560
- break;
1561
- }
1562
- body = _jsx("div", { className: "w-full space-y-3", children: cards });
1563
- break;
1564
- }
1565
- default:
1566
- body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
1567
- }
1568
- const copyable = el['copyable'];
1569
- const copyValue = el['_formatted'] !== undefined
1570
- ? String(el['_formatted'])
1571
- : value === null || value === undefined
1572
- ? ''
1573
- : typeof value === 'object'
1574
- ? JSON.stringify(value)
1575
- : String(value);
1576
- return (_jsx(EntryShell, { el: el, copyValue: copyable !== undefined ? copyValue : undefined, copyableLabel: copyable?.label, children: body }, index));
1577
- }
1578
- function EntryShell({ el, copyValue, copyableLabel, children }) {
1579
- const label = String(el['label'] ?? '');
1580
- const helperText = el['helperText'] ? String(el['helperText']) : undefined;
1581
- const tooltipText = el['tooltip'] ? String(el['tooltip']) : undefined;
1582
- const inline = el['inlineLabel'] === true;
1583
- const labelNode = label ? (_jsxs("div", { className: "flex items-center gap-1.5 text-sm font-medium text-muted-foreground", children: [_jsx("span", { children: label }), tooltipText && _jsx(EntryTooltip, { text: tooltipText })] })) : null;
1584
- const valueRow = (_jsxs("div", { className: "flex items-center gap-2", children: [children, copyValue !== undefined && (_jsx(EntryCopyButton, { text: copyValue, label: copyableLabel ?? 'Copy' }))] }));
1585
- if (inline) {
1586
- return (_jsxs("div", { className: "flex items-baseline gap-3", children: [labelNode && _jsx("div", { className: "min-w-32", children: labelNode }), _jsxs("div", { className: "min-w-0 flex-1", children: [valueRow, helperText && _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: helperText })] })] }));
1587
- }
1588
- return (_jsxs("div", { className: "space-y-1", children: [labelNode, valueRow, helperText && _jsx("p", { className: "text-xs text-muted-foreground", children: helperText })] }));
49
+ return renderActionLikeImpl(el, index, opts, { renderElement, renderFormChild });
1589
50
  }
1590
- function EntryComponentError({ children }) {
1591
- return (_jsx("div", { role: "alert", className: "rounded-md border border-amber-500/40 bg-amber-50 p-3 text-sm text-amber-800 dark:bg-amber-950/30 dark:text-amber-200", children: children }));
1592
- }
1593
- function EntryTooltip({ text }) {
1594
- const trigger = (_jsx("button", { type: "button", className: "inline-flex h-3.5 w-3.5 items-center justify-center rounded-full border text-[10px] text-muted-foreground", "aria-label": text, children: "?" }));
1595
- return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: () => trigger }), _jsx(TooltipContent, { children: text })] }) }));
51
+ /** Thin wrapper around `renderFieldImpl` that pre-binds `renderElement`.
52
+ * Lets `FormFields`, `renderFormChild`, and the renderElement switch
53
+ * call the form-layer field renderer with the original two-arg signature. */
54
+ function renderField(el, index) {
55
+ return renderFieldImpl(el, index, renderElement);
1596
56
  }
1597
- function EntryCopyButton({ text, label }) {
1598
- const [copied, setCopied] = useState(false);
1599
- const handleClick = () => {
1600
- if (typeof navigator !== 'undefined' && navigator.clipboard) {
1601
- navigator.clipboard.writeText(text).then(() => {
1602
- setCopied(true);
1603
- setTimeout(() => setCopied(false), 1500);
1604
- }).catch(() => { });
1605
- }
1606
- };
1607
- return (_jsx("button", { type: "button", onClick: handleClick, "aria-label": label, title: label, className: "inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted", children: copied ? _jsx(CheckIcon, { className: "size-3.5" }) : _jsx(CopyIcon, { className: "size-3.5" }) }));
57
+ /** Re-export the form-layer `renderFormChild` with `renderElement`
58
+ * pre-bound, so external consumers (e.g. `SelectFieldInput.tsx`) keep
59
+ * importing it from `SchemaRenderer.js` with the same four-arg signature.
60
+ * Internal callers (action layer dialogs, ActionGroupTrigger) get the
61
+ * same closure through prop injection. */
62
+ export function renderFormChild(child, index, values, errors) {
63
+ return renderFormChildImpl(child, index, values, errors, renderElement);
1608
64
  }
1609
- // ─── Alert renderer ─────────────────────────────────────────
1610
- //
1611
- // Owns dismissal state (per-mount + optional localStorage persistence)
1612
- // + icon dispatch + footer-actions alignment. Lifted out of the inline
1613
- // `case 'alert'` branch when Alert gained `dismissible() / iconColor() /
1614
- // footerActionsAlignment()` setters those need component-local state
1615
- // (the dismiss button, the persisted-dismissal hydration on mount), and
1616
- // inlining the hooks under a switch arm is fragile.
1617
- const ALERT_TYPE_ICONS = {
1618
- info: InfoIcon,
1619
- warning: TriangleAlertIcon,
1620
- success: CircleCheckIcon,
1621
- danger: CircleAlertIcon,
1622
- };
1623
- const ALERT_TYPE_DEFAULT_ICON_COLOR = {
1624
- info: 'info',
1625
- warning: 'warning',
1626
- success: 'success',
1627
- danger: 'destructive',
1628
- };
1629
- const ALERT_ACTIONS_ALIGNMENT = {
1630
- start: 'justify-start',
1631
- center: 'justify-center',
1632
- end: 'justify-end',
65
+ /** Local wrapper around the form-layer `FormRenderer` that pre-binds
66
+ * `renderElement`. Kept thin so the switch case below stays a one-liner. */
67
+ function FormRenderer({ el }) {
68
+ return _jsx(FormRendererImpl, { el: el, renderElement: renderElement });
69
+ }
70
+ /** Pre-bind the three injected deps that `TableRendererBody` needs:
71
+ * - `renderElement` for column cells holding Element-typed children
72
+ * - `renderActionLike` for row + bulk action dispatch
73
+ * - `renderFormChild` for the inline-edit modal's form schema body */
74
+ const tableBodyDeps = {
75
+ get renderElement() { return renderElement; },
76
+ get renderActionLike() { return renderActionLike; },
77
+ get renderFormChild() { return renderFormChild; },
1633
78
  };
1634
- function alertPersistKey(persistKey) {
1635
- return `pilotiq.alert.${persistKey}`;
1636
- }
1637
- function AlertRenderer(props) {
1638
- const { alertType, content, title, dismissible, persistDismissal, iconColor, actionsAlignment, footer, } = props;
1639
- const [dismissed, setDismissed] = useState(false);
1640
- // Hydrate persisted-dismissal on first paint. `useState(false)` keeps
1641
- // SSR + first client paint identical (Hydration safe); the effect
1642
- // flips to dismissed if localStorage has the flag set.
1643
- useEffect(() => {
1644
- if (!persistDismissal)
1645
- return;
1646
- if (typeof window === 'undefined')
1647
- return;
1648
- try {
1649
- if (window.localStorage.getItem(alertPersistKey(persistDismissal)) === '1') {
1650
- setDismissed(true);
1651
- }
1652
- }
1653
- catch { /* localStorage blocked (Safari ITP / SSR) — render visible */ }
1654
- }, [persistDismissal]);
1655
- if (dismissed)
1656
- return null;
1657
- const styles = alertStyles[alertType] ?? alertStyles['info'];
1658
- const Icon = ALERT_TYPE_ICONS[alertType] ?? InfoIcon;
1659
- const iconColorKey = iconColor ?? ALERT_TYPE_DEFAULT_ICON_COLOR[alertType] ?? 'info';
1660
- const iconColorCls = TEXT_COLOR_CLASSES[iconColorKey] ?? '';
1661
- const alignCls = ALERT_ACTIONS_ALIGNMENT[actionsAlignment ?? 'start'] ?? 'justify-start';
1662
- const handleDismiss = () => {
1663
- setDismissed(true);
1664
- if (persistDismissal && typeof window !== 'undefined') {
1665
- try {
1666
- window.localStorage.setItem(alertPersistKey(persistDismissal), '1');
1667
- }
1668
- catch { /* localStorage blocked — dismiss is per-mount only */ }
1669
- }
1670
- };
1671
- return (_jsxs("div", { className: `relative rounded-lg border p-4 ${styles} ${dismissible ? 'pr-9' : ''}`, children: [_jsxs("div", { className: "flex gap-3", children: [_jsx(Icon, { className: `size-5 shrink-0 mt-0.5 ${iconColorCls}`, "aria-hidden": "true" }), _jsxs("div", { className: "flex-1 min-w-0", children: [title !== undefined && _jsx("p", { className: "font-medium mb-1", children: title }), _jsx("p", { className: "text-sm", children: content }), footer.length > 0 && (_jsx("div", { className: `flex items-center gap-2 mt-3 ${alignCls}`, children: footer }))] })] }), dismissible && (_jsx("button", { type: "button", onClick: handleDismiss, "aria-label": "Dismiss", title: "Dismiss", className: "absolute top-3 right-3 inline-flex h-6 w-6 items-center justify-center rounded opacity-70 hover:opacity-100 transition-opacity", children: _jsx(XIcon, { className: "size-4", "aria-hidden": "true" }) }))] }));
79
+ /** Local wrapper around the table-layer `TableRenderer` that injects the
80
+ * three renderer deps. The body lives behind a separate import so the
81
+ * module cycle stays clean. */
82
+ function TableRenderer({ el }) {
83
+ return _jsx(TableRendererImpl, { el: el, deps: tableBodyDeps });
1672
84
  }
1673
85
  function renderElement(el, index) {
86
+ // Stateless leaves + layout primitives — text/image/icon/markdown/html/
87
+ // heading/emptyState/divider/unorderedList/card/grid/group/split/fieldset.
88
+ // Returns undefined for unhandled types so the switch below picks them up.
89
+ const simple = renderSimpleElement(el, index, { renderElement, renderActionLike });
90
+ if (simple !== undefined)
91
+ return simple;
1674
92
  switch (el.type) {
1675
- case 'text':
1676
- return renderText(el, index);
1677
- case 'image': {
1678
- const url = String(el['url'] ?? '');
1679
- const alt = String(el['alt'] ?? '');
1680
- const width = el['width'];
1681
- const height = el['height'];
1682
- const shape = String(el['shape'] ?? 'square');
1683
- const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'rounded' ? 'rounded-md' : '';
1684
- return (_jsx("img", { src: url, alt: alt, ...(width !== undefined ? { width } : {}), ...(height !== undefined ? { height } : {}), className: `inline-block object-cover ${shapeCls}` }, index));
1685
- }
1686
- case 'icon': {
1687
- const name = el['name'] ? String(el['name']) : undefined;
1688
- const size = el['size'] ?? 16;
1689
- const color = String(el['color'] ?? 'default');
1690
- const label = el['label'] ? String(el['label']) : undefined;
1691
- const Cmp = resolveIcon(name);
1692
- if (!Cmp)
1693
- return null;
1694
- const colorClass = COLUMN_COLOR_CLASSES[color] ?? '';
1695
- return (_jsx(Cmp, { className: `inline ${colorClass}`, ...(label ? { 'aria-label': label } : { 'aria-hidden': true }), style: { width: size, height: size } }, index));
1696
- }
1697
- case 'markdown':
1698
- case 'html': {
1699
- const html = String(el['html'] ?? '');
1700
- const prose = el['prose'] !== false;
1701
- const size = el['size'] ? String(el['size']) : undefined;
1702
- const proseCls = prose
1703
- ? `prose max-w-none ${size === 'sm' ? 'prose-sm' : size === 'lg' ? 'prose-lg' : ''}`.trim()
1704
- : '';
1705
- return (_jsx("div", { className: proseCls || undefined, dangerouslySetInnerHTML: { __html: html } }, index));
1706
- }
1707
- case 'heading': {
1708
- const level = el['level'] ?? 1;
1709
- const content = String(el['content'] ?? '');
1710
- const description = el['description'] ? String(el['description']) : undefined;
1711
- const headerActions = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
1712
- const Tag = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
1713
- const sizes = { 1: 'text-2xl', 2: 'text-xl', 3: 'text-lg' };
1714
- const titleBlock = (_jsxs("div", { children: [_jsx(Tag, { className: `${sizes[level]} font-bold tracking-tight`, children: content }), description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1", children: description }))] }));
1715
- if (headerActions.length === 0) {
1716
- return _jsx("div", { children: titleBlock }, index);
1717
- }
1718
- return (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [titleBlock, _jsx("div", { className: "flex items-center gap-2 shrink-0", children: headerActions.map((a, i) => renderActionLike(a, i)) })] }, index));
1719
- }
1720
93
  case 'alert': {
1721
94
  const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
1722
95
  return (_jsx(AlertRenderer, { alertType: String(el['alertType'] ?? 'info'), content: String(el['content'] ?? ''), ...(el['title'] !== undefined ? { title: String(el['title']) } : {}), ...(el['dismissible'] ? { dismissible: Boolean(el['dismissible']) } : {}), ...(el['persistDismissal'] !== undefined ? { persistDismissal: String(el['persistDismissal']) } : {}), ...(el['iconColor'] !== undefined ? { iconColor: String(el['iconColor']) } : {}), ...(el['actionsAlignment'] !== undefined ? { actionsAlignment: String(el['actionsAlignment']) } : {}), footer: footer.map((a, i) => renderActionLike(a, i)) }, index));
1723
96
  }
1724
- case 'emptyState': {
1725
- const heading = String(el['heading'] ?? '');
1726
- const description = el['description'] ? String(el['description']) : undefined;
1727
- const iconName = el['icon'] ? String(el['icon']) : undefined;
1728
- const contained = el['contained'] !== false;
1729
- const Icon = iconName ? resolveIcon(iconName) : undefined;
1730
- const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
1731
- const wrapper = contained
1732
- ? 'rounded-lg border border-border bg-card text-card-foreground py-12 px-6'
1733
- : 'py-8';
1734
- return (_jsxs("div", { className: `${wrapper} flex flex-col items-center text-center gap-3`, children: [Icon && _jsx(Icon, { className: "size-10 text-muted-foreground", "aria-hidden": "true" }), _jsx("h3", { className: "text-lg font-semibold", children: heading }), description && _jsx("p", { className: "text-sm text-muted-foreground max-w-md", children: description }), footer.length > 0 && (_jsx("div", { className: "flex items-center gap-2 mt-2", children: footer.map((a, i) => renderActionLike(a, i)) }))] }, index));
1735
- }
1736
- case 'divider': {
1737
- const label = el['label'] ? String(el['label']) : undefined;
1738
- return label
1739
- ? _jsxs("div", { className: "relative py-2", children: [_jsx("div", { className: "absolute inset-0 flex items-center", children: _jsx("span", { className: "w-full border-t border-border" }) }), _jsx("div", { className: "relative flex justify-center", children: _jsx("span", { className: "bg-background px-2 text-xs text-muted-foreground", children: label }) })] }, index)
1740
- : _jsx("hr", { className: "border-border" }, index);
1741
- }
1742
- case 'unorderedList': {
1743
- const items = el['items'] ?? [];
1744
- const color = el['color'] ? String(el['color']) : undefined;
1745
- const size = el['size'] ? String(el['size']) : undefined;
1746
- const weight = el['weight'] ? String(el['weight']) : undefined;
1747
- const sizeCls = size ? (TEXT_SIZE_CLASSES[size] ?? '') : 'text-sm';
1748
- const colorCls = color ? (TEXT_COLOR_CLASSES[color] ?? '') : '';
1749
- const weightCls = weight ? (TEXT_WEIGHT_CLASSES[weight] ?? '') : '';
1750
- return (_jsx("ul", { className: `list-disc list-inside space-y-1 ${sizeCls} ${colorCls} ${weightCls}`.trim(), children: items.map((item, i) => (_jsx("li", { children: String(item) }, i))) }, index));
1751
- }
1752
- case 'card': {
1753
- const title = el['title'] ? String(el['title']) : undefined;
1754
- const description = el['description'] ? String(el['description']) : undefined;
1755
- return (_jsxs("div", { className: "rounded-xl border bg-card p-6 shadow-sm", children: [title && _jsx("h3", { className: "font-semibold mb-1", children: title }), description && _jsx("p", { className: "text-sm text-muted-foreground mb-4", children: description }), renderChildren(el.children)] }, index));
1756
- }
1757
97
  case 'section':
1758
- return _jsx(SectionRenderer, { el: el, index: index }, index);
98
+ return _jsx(SectionRenderer, { el: el, index: index, renderElement: renderElement }, index);
1759
99
  case 'tabs':
1760
- return _jsx(TabsRenderer, { el: el, index: index }, index);
100
+ return _jsx(TabsRenderer, { el: el, index: index, renderElement: renderElement }, index);
1761
101
  case 'tab':
1762
102
  // Tabs are rendered by their parent `tabs` element; standalone Tab is a no-op.
1763
103
  return null;
@@ -1770,55 +110,19 @@ function renderElement(el, index) {
1770
110
  case 'listTab':
1771
111
  // List tabs are rendered by their parent `listTabs` strip; standalone is a no-op.
1772
112
  return null;
1773
- case 'grid': {
1774
- const columns = Math.max(1, Math.min(12, Number(el['columns'] ?? 2)));
1775
- const gapPx = el['gap'] !== undefined ? `${Number(el['gap'])}px` : undefined;
1776
- return (_jsx("div", { className: `grid gap-4 ${layoutClasses(el)}`.trim(), style: {
1777
- gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
1778
- ...(gapPx ? { gap: gapPx } : {}),
1779
- }, children: (el.children ?? []).map((c, i) => renderElement(c, i)) }, index));
1780
- }
1781
- case 'group': {
1782
- const layout = layoutClasses(el);
1783
- return (_jsx("div", { className: layout || undefined, children: renderChildren(el.children) }, index));
1784
- }
1785
- case 'split': {
1786
- const from = el['from'] === 'left' ? 'left' : 'right';
1787
- const gap = Math.max(0, Math.min(12, Number(el['gap'] ?? 6)));
1788
- const children = el.children ?? [];
1789
- // Find the explicit aside child first; fall back to "second child is
1790
- // aside" so terse Split.make().schema([main, aside]) still works.
1791
- let asideIdx = children.findIndex(c => c['aside'] === true);
1792
- if (asideIdx === -1 && children.length >= 2)
1793
- asideIdx = 1;
1794
- const mainChildren = children.filter((_, i) => i !== asideIdx);
1795
- const asideChild = asideIdx >= 0 ? children[asideIdx] : undefined;
1796
- const orderClasses = from === 'left'
1797
- ? { aside: '@md:order-first', main: '@md:order-last' }
1798
- : { aside: '@md:order-last', main: '@md:order-first' };
1799
- return (_jsxs("div", { className: `@container flex flex-col @md:flex-row gap-${gap} ${layoutClasses(el)}`.trim(), children: [_jsx("div", { className: `flex flex-col gap-4 flex-1 min-w-0 ${orderClasses.main}`, children: mainChildren.map((c, i) => renderElement(c, i)) }), asideChild && (_jsx("aside", { className: `flex flex-col gap-4 @md:w-80 @md:shrink-0 ${orderClasses.aside}`, children: renderElement(asideChild, asideIdx) }))] }, index));
1800
- }
1801
- case 'fieldset': {
1802
- const label = String(el['label'] ?? '');
1803
- const columns = Math.max(1, Math.min(3, Number(el['columns'] ?? 1)));
1804
- const gridStyle = columns > 1
1805
- ? { display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: '1rem' }
1806
- : undefined;
1807
- return (_jsxs("fieldset", { className: `rounded-md border border-border px-4 pt-3 pb-4 ${layoutClasses(el)}`.trim(), children: [label && _jsx("legend", { className: "px-1 text-xs font-medium text-muted-foreground", children: label }), _jsx("div", { className: columns === 1 ? 'flex flex-col gap-3' : undefined, style: gridStyle, children: (el.children ?? []).map((c, i) => renderElement(c, i)) })] }, index));
1808
- }
1809
113
  case 'wizard':
1810
- return _jsx(WizardRenderer, { el: el, index: index }, index);
114
+ return (_jsx(WizardRenderer, { el: el, index: index, deps: { renderElement } }, index));
1811
115
  case 'step':
1812
116
  // Steps are rendered by their parent Wizard; standalone Step is a no-op.
1813
117
  return null;
1814
118
  case 'field':
1815
119
  return renderField(el, index);
1816
120
  case 'entry':
1817
- return renderEntry(el, index);
121
+ return renderEntry(el, index, renderElement);
1818
122
  case 'action':
1819
- return renderAction(el, index);
123
+ return renderAction(el, index, {}, { renderElement, renderFormChild });
1820
124
  case 'actionGroup':
1821
- return _jsx(ActionGroupTrigger, { el: el }, index);
125
+ return (_jsx(ActionGroupTrigger, { el: el, renderFormChild: renderFormChild, renderElement: renderElement }, index));
1822
126
  case 'form': {
1823
127
  // Key on formId so SPA navigation between pages with different
1824
128
  // forms (list → edit, edit → edit-of-different-record, etc.)
@@ -1870,954 +174,6 @@ function renderElement(el, index) {
1870
174
  }
1871
175
  }
1872
176
  }
1873
- // ─── Form ───────────────────────────────────────────────────
1874
- function FormRenderer({ el }) {
1875
- const formId = String(el['formId'] ?? '');
1876
- const method = String(el['method'] ?? 'post').toLowerCase();
1877
- const action = el['action'] ? String(el['action']) : undefined;
1878
- const stateUrl = el['stateUrl'] ? String(el['stateUrl']) : undefined;
1879
- const serverValues = el['values'] ?? {};
1880
- const serverErrors = el['errors'] ?? {};
1881
- // Methods other than GET/POST are spoofed via _method, mirroring Laravel.
1882
- const httpMethod = method === 'get' ? 'get' : 'post';
1883
- const spoofedMethod = method !== 'get' && method !== 'post' ? method : undefined;
1884
- const navigate = useNavigate();
1885
- const { notify } = useToast();
1886
- // Client-side errors override server-rendered ones after a fetch-mode
1887
- // 422 response. Field values stay uncontrolled — the inputs in the DOM
1888
- // still hold whatever the user typed, so we don't need to mirror them.
1889
- const [clientErrors, setClientErrors] = useState(null);
1890
- const [submitting, setSubmitting] = useState(false);
1891
- const errors = clientErrors ?? serverErrors;
1892
- // Plan #14 — formRef is threaded into FormStateProvider so live triggers
1893
- // can snapshot the form's full DOM state via FormData (captures
1894
- // uncontrolled inner-Repeater inputs that don't participate in the
1895
- // controlled values map).
1896
- const formRef = useRef(null);
1897
- const formErrors = errors['_form'] ?? [];
1898
- const hasFieldErrors = Object.keys(errors).some(k => k !== '_form');
1899
- const onSubmit = async (e) => {
1900
- if (!action)
1901
- return; // no action URL → fall through to native submit
1902
- e.preventDefault();
1903
- if (submitting)
1904
- return;
1905
- setSubmitting(true);
1906
- setClientErrors(null);
1907
- try {
1908
- // Thread `event.submitter` so the clicked submit button's
1909
- // name/value pair lands in the FormData. Without this, secondary
1910
- // submits like "Create & create another" can't signal which
1911
- // button fired through the body. Supported in all evergreen
1912
- // browsers since 2022; cast through `as any` because TS lib.dom
1913
- // hasn't picked up the optional submitter argument on every
1914
- // version.
1915
- const submitter = e.nativeEvent.submitter;
1916
- const fd = new FormData(e.currentTarget, submitter ?? undefined);
1917
- const res = await fetch(action, {
1918
- method: 'POST',
1919
- headers: { 'Accept': 'application/json' },
1920
- body: fd,
1921
- });
1922
- const data = await res.json().catch(() => ({}));
1923
- if (res.status === 422) {
1924
- const next = data.errors ?? {};
1925
- setClientErrors(next);
1926
- // Surface a banner-level message if no field errors were returned
1927
- // — the form-level _form key lights up the existing banner.
1928
- setSubmitting(false);
1929
- return;
1930
- }
1931
- if (!res.ok) {
1932
- const message = String(data.error ?? `Request failed (${res.status})`);
1933
- notify({ type: 'error', title: 'Save failed', body: message });
1934
- setSubmitting(false);
1935
- return;
1936
- }
1937
- // Success — drain notifications and SPA-navigate to the redirect.
1938
- const notifs = data.notifications;
1939
- if (notifs && notifs.length > 0)
1940
- for (const n of notifs)
1941
- notify(n);
1942
- const redirect = String(data.redirect ?? '');
1943
- // The server may force a navigate even when the redirect equals
1944
- // the current URL — used by "Create & create another" so the
1945
- // form remounts with empty defaults instead of preserving the
1946
- // just-submitted values. Otherwise: skip navigate when the
1947
- // redirect matches the current URL, since re-fetching the same
1948
- // page would force a form remount and reset scroll.
1949
- const force = Boolean(data.force);
1950
- const currentUrl = typeof window !== 'undefined'
1951
- ? window.location.pathname + window.location.search
1952
- : '';
1953
- if (redirect && (force || redirect !== currentUrl)) {
1954
- navigate(redirect);
1955
- // Don't reset submitting on success — the navigation will unmount us.
1956
- }
1957
- else {
1958
- setSubmitting(false);
1959
- }
1960
- }
1961
- catch (err) {
1962
- notify({ type: 'error', title: 'Save failed', body: err instanceof Error ? err.message : String(err) });
1963
- setSubmitting(false);
1964
- }
1965
- };
1966
- return (_jsxs("form", { ref: formRef, id: formId || undefined, "data-form-id": formId || undefined, method: httpMethod, action: action, onSubmit: onSubmit, className: "flex flex-col gap-6", children: [formId && _jsx("input", { type: "hidden", name: "_formId", value: formId }), spoofedMethod && _jsx("input", { type: "hidden", name: "_method", value: spoofedMethod }), (formErrors.length > 0 || hasFieldErrors) && (_jsx("div", { className: "rounded-lg border border-destructive/40 bg-destructive/5 text-destructive p-3 text-sm", children: formErrors.length > 0 ? (_jsx("ul", { className: "list-disc pl-4", children: formErrors.map((msg, i) => _jsx("li", { children: msg }, i)) })) : ('Please correct the errors below.') })), _jsx(FormIdContext.Provider, { value: formId, children: stateUrl ? (_jsx(FormStateProvider, { initialMeta: el, initialErrors: errors, formRef: formRef, children: _jsx(FormBody, { fallbackChildren: el.children ?? [], fallbackValues: serverValues, fallbackErrors: errors }) })) : ((el.children ?? []).map((child, i) => renderFormChild(child, i, serverValues, errors))) })] }));
1967
- }
1968
- /**
1969
- * Renders the controlled-form's children, sourcing them from the
1970
- * `FormStateProvider`'s current `formMeta` (which gets replaced after
1971
- * each live POST). Falls back to the props if (somehow) used outside a
1972
- * provider — the shell only mounts this when `stateUrl` is set so the
1973
- * fallback path is dead code in practice, but keeping it defensive.
1974
- */
1975
- function FormBody({ fallbackChildren, fallbackValues, fallbackErrors, }) {
1976
- const ctx = useFormState();
1977
- if (!ctx) {
1978
- return _jsx(_Fragment, { children: fallbackChildren.map((child, i) => renderFormChild(child, i, fallbackValues, fallbackErrors)) });
1979
- }
1980
- const children = (ctx.formMeta.children ?? []);
1981
- return _jsx(_Fragment, { children: children.map((child, i) => renderFormChild(child, i, ctx.values, ctx.errors)) });
1982
- }
1983
- /**
1984
- * Render one child of a form's resolved schema with per-field values + errors.
1985
- *
1986
- * Exported so sibling renderers (e.g. `SelectFieldInput`'s inline-create
1987
- * modal) can render a sub-schema with the same FieldShell + error-stamping
1988
- * conventions as the parent form. Public surface beyond the file boundary
1989
- * stays narrow — callers should pass `child.type === 'field'` elements;
1990
- * non-field elements fall through to `renderElement`.
1991
- */
1992
- export function renderFormChild(child, index, values, errors) {
1993
- if (child.type === 'field') {
1994
- const name = String(child['name'] ?? '');
1995
- const fieldErrors = errors[name] ?? [];
1996
- const value = values[name];
1997
- return (_jsxs("div", { className: "flex flex-col gap-1", children: [renderFieldWithValue(child, index, value), fieldErrors.map((msg, i) => (_jsx("p", { className: "text-xs text-destructive", children: msg }, i)))] }, index));
1998
- }
1999
- return renderElement(child, index);
2000
- }
2001
- function renderFieldWithValue(el, index, value) {
2002
- // The form-state value (from `withValues` / record-fill) wins when present;
2003
- // otherwise the meta's own `defaultValue` (Plan #6 `Field.default()`) survives.
2004
- const enriched = value !== undefined
2005
- ? { ...el, defaultValue: value }
2006
- : el;
2007
- return renderField(enriched, index);
2008
- }
2009
- // Mirror of `prefixedKey` in `elements/dispatchTable.ts`. Kept inline so
2010
- // SchemaRenderer doesn't drag the server-side dispatcher into the client
2011
- // bundle.
2012
- function prefixK(prefix, key) {
2013
- return prefix === undefined || prefix === '' ? key : `${prefix}_${key}`;
2014
- }
2015
- let cachedSearchString = null;
2016
- let cachedSearchParams = null;
2017
- function getCurrentSearchParams() {
2018
- if (typeof window === 'undefined')
2019
- return null;
2020
- const s = window.location.search;
2021
- if (s === cachedSearchString && cachedSearchParams)
2022
- return cachedSearchParams;
2023
- cachedSearchString = s;
2024
- cachedSearchParams = new URLSearchParams(s);
2025
- return cachedSearchParams;
2026
- }
2027
- function SearchFormHiddenInputs({ prefix }) {
2028
- const sp = getCurrentSearchParams();
2029
- if (!sp)
2030
- return _jsx(_Fragment, {});
2031
- const searchKey = prefixK(prefix, 'search');
2032
- const pageKey = prefixK(prefix, 'page');
2033
- const inputs = [];
2034
- let i = 0;
2035
- for (const [k, v] of sp) {
2036
- if (k === searchKey || k === pageKey)
2037
- continue;
2038
- inputs.push(_jsx("input", { type: "hidden", name: k, value: v }, i++));
2039
- }
2040
- return _jsx(_Fragment, { children: inputs });
2041
- }
2042
- function buildTableQuery(state, override, pathname, filterValues = {}, prefix) {
2043
- const merged = { ...state, ...override };
2044
- const params = new URLSearchParams();
2045
- // Foreign URL params (other tables' state, app-level params) round-trip
2046
- // verbatim so this builder only ever rewrites its own slice.
2047
- const currentParams = getCurrentSearchParams();
2048
- if (currentParams) {
2049
- const ours = new Set([
2050
- prefixK(prefix, 'search'),
2051
- prefixK(prefix, 'sort'),
2052
- prefixK(prefix, 'page'),
2053
- prefixK(prefix, 'perPage'),
2054
- prefixK(prefix, 'group'),
2055
- prefixK(prefix, 'groupKey'),
2056
- ...Object.keys(filterValues).map(n => prefixK(prefix, n)),
2057
- ]);
2058
- for (const [k, v] of currentParams) {
2059
- if (ours.has(k))
2060
- continue;
2061
- params.set(k, v);
2062
- }
2063
- }
2064
- // Carry forward active filter values so sort/pagination links don't
2065
- // accidentally clear them. Filter names can't collide with reserved
2066
- // keys (search/sort/page/perPage/group) — that's enforced upstream.
2067
- for (const [name, val] of Object.entries(filterValues)) {
2068
- if (val)
2069
- params.set(prefixK(prefix, name), val);
2070
- }
2071
- if (merged.search)
2072
- params.set(prefixK(prefix, 'search'), merged.search);
2073
- if (merged.sort)
2074
- params.set(prefixK(prefix, 'sort'), `${merged.sort.column}:${merged.sort.direction}`);
2075
- if (merged.page && merged.page > 1)
2076
- params.set(prefixK(prefix, 'page'), String(merged.page));
2077
- if (merged.group !== undefined)
2078
- params.set(prefixK(prefix, 'group'), merged.group);
2079
- // groupKey is sparse — only writes when the override sets a non-empty
2080
- // value. Drill-out (chip ×) passes `''` to clear; the foreign-param
2081
- // dedupe set above already filtered the stale value out, so an empty
2082
- // override produces a URL without the key.
2083
- if (merged.groupKey)
2084
- params.set(prefixK(prefix, 'groupKey'), merged.groupKey);
2085
- const qs = params.toString();
2086
- // Always anchor to a real pathname — Vike's client-side router treats
2087
- // a bare `?qs` href as a fresh URL with empty pathname, which routes
2088
- // to the dashboard and blanks the page during SPA navigation.
2089
- const base = pathname || (typeof window !== 'undefined' ? window.location.pathname : '');
2090
- return qs ? `${base}?${qs}` : (base || '#');
2091
- }
2092
- function nextSortDir(current, column) {
2093
- if (current?.column === column) {
2094
- return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
2095
- }
2096
- return { column, direction: 'asc' };
2097
- }
2098
- /** Map ColumnColor → tailwind text-color class. Used by TextColumn and
2099
- * IconColumn alike. */
2100
- const COLUMN_COLOR_CLASSES = {
2101
- default: '',
2102
- muted: 'text-muted-foreground',
2103
- primary: 'text-primary',
2104
- destructive: 'text-destructive',
2105
- success: 'text-emerald-600 dark:text-emerald-400',
2106
- warning: 'text-amber-600 dark:text-amber-400',
2107
- info: 'text-blue-600 dark:text-blue-400',
2108
- };
2109
- const COLUMN_WEIGHT_CLASSES = {
2110
- normal: 'font-normal',
2111
- medium: 'font-medium',
2112
- semibold: 'font-semibold',
2113
- bold: 'font-bold',
2114
- };
2115
- const BADGE_COLOR_CLASSES = {
2116
- gray: 'bg-muted text-muted-foreground',
2117
- primary: 'bg-primary/10 text-primary',
2118
- success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200',
2119
- warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
2120
- destructive: 'bg-destructive/10 text-destructive',
2121
- info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
2122
- };
2123
- /** Apply a built-in `ColumnFormat` to a raw value; returns a string. */
2124
- function applyColumnFormat(value, format) {
2125
- if (value === null || value === undefined || value === '')
2126
- return '';
2127
- switch (format['kind']) {
2128
- case 'dateTime': {
2129
- const d = value instanceof Date ? value : new Date(String(value));
2130
- if (isNaN(d.getTime()))
2131
- return String(value);
2132
- // Default — locale-aware short date+time. Custom patterns aren't
2133
- // supported (no date-fns dep); pattern is kept on meta for future use.
2134
- return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
2135
- }
2136
- case 'since': {
2137
- const d = value instanceof Date ? value : new Date(String(value));
2138
- if (isNaN(d.getTime()))
2139
- return String(value);
2140
- const seconds = Math.round((Date.now() - d.getTime()) / 1000);
2141
- const abs = Math.abs(seconds);
2142
- const past = seconds >= 0;
2143
- const fmt = (n, unit) => past ? `${n} ${unit}${n === 1 ? '' : 's'} ago` : `in ${n} ${unit}${n === 1 ? '' : 's'}`;
2144
- if (abs < 60)
2145
- return past ? 'just now' : 'in a moment';
2146
- if (abs < 3600)
2147
- return fmt(Math.floor(abs / 60), 'minute');
2148
- if (abs < 86400)
2149
- return fmt(Math.floor(abs / 3600), 'hour');
2150
- if (abs < 2592000)
2151
- return fmt(Math.floor(abs / 86400), 'day');
2152
- if (abs < 31536000)
2153
- return fmt(Math.floor(abs / 2592000), 'month');
2154
- return fmt(Math.floor(abs / 31536000), 'year');
2155
- }
2156
- case 'money': {
2157
- const n = typeof value === 'number' ? value : Number(value);
2158
- if (isNaN(n))
2159
- return String(value);
2160
- const currency = String(format['currency'] ?? 'USD');
2161
- const locale = format['locale'];
2162
- return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(n);
2163
- }
2164
- case 'numeric': {
2165
- const n = typeof value === 'number' ? value : Number(value);
2166
- if (isNaN(n))
2167
- return String(value);
2168
- const decimals = format['decimals'];
2169
- const locale = format['locale'];
2170
- const opts = {};
2171
- if (decimals !== undefined) {
2172
- opts.minimumFractionDigits = decimals;
2173
- opts.maximumFractionDigits = decimals;
2174
- }
2175
- return new Intl.NumberFormat(locale, opts).format(n);
2176
- }
2177
- case 'limit': {
2178
- const s = String(value);
2179
- const n = format['chars'];
2180
- return s.length > n ? s.slice(0, n) + '…' : s;
2181
- }
2182
- case 'words': {
2183
- const s = String(value).trim();
2184
- if (s.length === 0)
2185
- return s;
2186
- const tokens = s.split(/\s+/);
2187
- const n = format['words'];
2188
- return tokens.length > n ? tokens.slice(0, n).join(' ') + '…' : s;
2189
- }
2190
- default:
2191
- return String(value);
2192
- }
2193
- }
2194
- /** Render a cell. Honors the column's `columnType` (badge/icon/boolean/
2195
- * image), built-in `format` spec, and per-row `_formatted[name]`
2196
- * overrides from server-side `formatStateUsing` callbacks. */
2197
- function formatCell(value, col, row) {
2198
- if (col === undefined) {
2199
- // Legacy raw-value fallback for non-column callsites.
2200
- if (value === null || value === undefined)
2201
- return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
2202
- if (value instanceof Date)
2203
- return value.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
2204
- if (typeof value === 'boolean')
2205
- return value ? 'Yes' : 'No';
2206
- if (typeof value === 'object')
2207
- return JSON.stringify(value);
2208
- return String(value);
2209
- }
2210
- const columnType = String(col['columnType'] ?? 'text');
2211
- const fallback = col['default'];
2212
- // Per-row server-eval result wins over everything.
2213
- const colName = String(col['name'] ?? '');
2214
- const formatted = row?.['_formatted']?.[colName];
2215
- const richtext = row?.['_richtextCells']?.[colName] === true;
2216
- const isBlank = value === null || value === undefined || value === '';
2217
- if (formatted !== undefined && formatted !== '') {
2218
- return wrapCell(formatted, col, richtext);
2219
- }
2220
- if (isBlank) {
2221
- return _jsx("span", { className: "text-muted-foreground", children: fallback ?? '—' });
2222
- }
2223
- switch (columnType) {
2224
- case 'badge': {
2225
- const map = col['badgeColors'] ?? {};
2226
- const color = map[String(value)] ?? 'gray';
2227
- const cls = BADGE_COLOR_CLASSES[color] ?? BADGE_COLOR_CLASSES['gray'];
2228
- return (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: String(value) }));
2229
- }
2230
- case 'icon':
2231
- case 'boolean': {
2232
- const map = col['iconOptions'] ?? {};
2233
- const opt = map[String(value)];
2234
- if (!opt)
2235
- return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
2236
- const Icon = resolveIcon(opt.icon) ?? CircleIcon;
2237
- const colorClass = opt.color ? (COLUMN_COLOR_CLASSES[opt.color] ?? '') : '';
2238
- return _jsx(Icon, { className: `size-4 inline ${colorClass}`, "aria-label": String(value) });
2239
- }
2240
- case 'image': {
2241
- const url = String(value);
2242
- const size = col['imageSize'] ?? 32;
2243
- const shape = col['imageShape'] === 'circle' ? 'rounded-full' : 'rounded-md';
2244
- return (_jsx("img", { src: url, alt: "", width: size, height: size, className: `${shape} object-cover` }));
2245
- }
2246
- case 'color': {
2247
- const css = String(value);
2248
- const shape = col['colorShape'];
2249
- const shapeClass = shape === 'circle' ? 'rounded-full' :
2250
- shape === 'square' ? 'rounded-none' : 'rounded';
2251
- const hideValue = col['colorHideValue'] === true;
2252
- return (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: `size-4 border border-border ${shapeClass}`, style: { backgroundColor: css }, "aria-hidden": "true" }), !hideValue && _jsx("span", { className: "text-sm", children: css })] }));
2253
- }
2254
- default: {
2255
- // Array-valued cells — `bulleted()` wins over `listWithLineBreaks()`
2256
- // when both are set. Falls through to the standard string path for
2257
- // non-array values so the per-cell formatters keep working.
2258
- if (Array.isArray(value)) {
2259
- const items = value.map(v => String(v));
2260
- if (col['bulleted'] === true) {
2261
- return wrapCellList(items, col, 'bulleted');
2262
- }
2263
- if (col['listWithLineBreaks'] === true) {
2264
- return wrapCellList(items, col, 'lines');
2265
- }
2266
- // Bare array — comma-join (matches the existing legacy fallback).
2267
- return wrapCell(items.join(', '), col);
2268
- }
2269
- // Text column — apply built-in format, then wrapper.
2270
- const fmt = col['format'];
2271
- const display = fmt ? applyColumnFormat(value, fmt) : String(value);
2272
- return wrapCell(display, col);
2273
- }
2274
- }
2275
- }
2276
- /** Apply text-rendering chrome (color, weight, line-clamp, wrap, tooltip)
2277
- * to a stringified cell value. Used by the text and per-row formatter
2278
- * paths so styling stays consistent. When `asHtml` is true the content
2279
- * is server-rendered HTML (e.g. from the registered richtext renderer)
2280
- * and gets injected via `dangerouslySetInnerHTML`. */
2281
- function wrapCell(content, col, asHtml = false) {
2282
- const color = col['color'];
2283
- const weight = col['weight'];
2284
- const tooltip = col['tooltip'];
2285
- const wrapping = Boolean(col['wrap']);
2286
- const clamp = col['lineClamp'];
2287
- const copyMsg = col['copyMessage'];
2288
- const colorCls = color ? (COLUMN_COLOR_CLASSES[color] ?? '') : '';
2289
- const weightCls = weight ? (COLUMN_WEIGHT_CLASSES[weight] ?? '') : '';
2290
- const wrapCls = wrapping ? 'whitespace-normal' : '';
2291
- const clampStyle = clamp !== undefined
2292
- ? { display: '-webkit-box', WebkitLineClamp: String(clamp), WebkitBoxOrient: 'vertical', overflow: 'hidden' }
2293
- : undefined;
2294
- const valueNode = asHtml
2295
- ? (_jsx("span", { className: `prose prose-sm max-w-none dark:prose-invert ${colorCls} ${weightCls} ${wrapCls}`.trim(), title: tooltip, style: clampStyle, dangerouslySetInnerHTML: { __html: content } }))
2296
- : (_jsx("span", { className: `${colorCls} ${weightCls} ${wrapCls}`.trim(), title: tooltip, style: clampStyle, children: content }));
2297
- if (copyMsg === undefined)
2298
- return valueNode;
2299
- // Copy-to-clipboard trigger — copies the rendered text. For richtext
2300
- // cells the underlying source isn't separately stamped on the wire
2301
- // (would double the row payload), so the rendered HTML is what gets
2302
- // copied; admins comfortable with HTML still get something usable.
2303
- return (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [valueNode, _jsx(CellCopyButton, { text: content, label: copyMsg })] }));
2304
- }
2305
- /** Tabular-list rendering used by `Column.bulleted()` /
2306
- * `Column.listWithLineBreaks()`. `mode='bulleted'` mounts a `<ul>` with
2307
- * bullet markers; `mode='lines'` separates entries with `<br>`. Both
2308
- * inherit the same color / weight / wrap / tooltip / clamp chrome as
2309
- * the text path. Empty arrays fall through to the muted dash. */
2310
- function wrapCellList(items, col, mode) {
2311
- if (items.length === 0) {
2312
- const fallback = col['default'] ?? '—';
2313
- return _jsx("span", { className: "text-muted-foreground", children: fallback });
2314
- }
2315
- const color = col['color'];
2316
- const weight = col['weight'];
2317
- const tooltip = col['tooltip'];
2318
- const colorCls = color ? (COLUMN_COLOR_CLASSES[color] ?? '') : '';
2319
- const weightCls = weight ? (COLUMN_WEIGHT_CLASSES[weight] ?? '') : '';
2320
- if (mode === 'bulleted') {
2321
- return (_jsx("ul", { className: `list-disc pl-4 space-y-0.5 ${colorCls} ${weightCls}`.trim(), title: tooltip, children: items.map((s, i) => _jsx("li", { children: s }, i)) }));
2322
- }
2323
- return (_jsx("span", { className: `${colorCls} ${weightCls}`.trim(), title: tooltip, children: items.map((s, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx("br", {}), s] }, i))) }));
2324
- }
2325
- /** Slim copy-to-clipboard button used by `Column.copyMessage()`. The
2326
- * label doubles as the toast text. Mirrors `EntryCopyButton`'s shape
2327
- * but compact enough to live inline next to a cell value. */
2328
- function CellCopyButton({ text, label }) {
2329
- const [copied, setCopied] = useState(false);
2330
- const handleClick = (e) => {
2331
- e.stopPropagation();
2332
- e.preventDefault();
2333
- if (typeof navigator !== 'undefined' && navigator.clipboard) {
2334
- navigator.clipboard.writeText(text).then(() => {
2335
- setCopied(true);
2336
- setTimeout(() => setCopied(false), 1500);
2337
- }).catch(() => { });
2338
- }
2339
- };
2340
- return (_jsx("button", { type: "button", onClick: handleClick, "aria-label": copied ? label : 'Copy', title: copied ? label : 'Copy', "data-no-row-nav": true, className: "inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted", children: copied ? _jsx(CheckIcon, { className: "size-3" }) : _jsx(CopyIcon, { className: "size-3" }) }));
2341
- }
2342
- function rowId(row, index) {
2343
- if (row && typeof row === 'object' && 'id' in row) {
2344
- const id = row.id;
2345
- if (id !== undefined && id !== null)
2346
- return String(id);
2347
- }
2348
- return String(index);
2349
- }
2350
- /**
2351
- * Filter dropdown that updates the URL directly on change. We don't rely
2352
- * on a wrapping `<form>` because filters now live inside a portaled
2353
- * Popover (the search input keeps its own form for Enter-to-submit).
2354
- *
2355
- * Empty value (`''`) is the "All" sentinel — the param is removed from
2356
- * the URL rather than serialized as `&name=`.
2357
- */
2358
- function FilterSelect({ name, label, defaultValue, placeholder, options, prefix, }) {
2359
- const [value, setValue] = useState(defaultValue);
2360
- const navigate = useNavigate();
2361
- const onChange = (next) => {
2362
- const v = typeof next === 'string' ? next : '';
2363
- setValue(v);
2364
- if (typeof window === 'undefined')
2365
- return;
2366
- const url = new URL(window.location.href);
2367
- const k = prefixK(prefix, name);
2368
- if (v === '')
2369
- url.searchParams.delete(k);
2370
- else
2371
- url.searchParams.set(k, v);
2372
- // Filter changes reset pagination — first page of the new result set.
2373
- url.searchParams.delete(prefixK(prefix, 'page'));
2374
- // SPA navigate via context (vike's navigate when mounted under the
2375
- // Vike-generated +Layout). Fallback is full reload — see useNavigate.
2376
- void navigate(url.pathname + url.search);
2377
- };
2378
- return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsxs(Select, { value: value, onValueChange: onChange, children: [_jsx(SelectTrigger, { size: "sm", className: "w-full", children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "", children: placeholder }), options.map(o => (_jsx(SelectItem, { value: o.value, children: o.label }, o.value)))] })] })] }));
2379
- }
2380
- /**
2381
- * Heading-row text for a group band. Shows `<label>: <value-or-title>`
2382
- * with an optional description below. Reused for both collapsible and
2383
- * static heading rows.
2384
- */
2385
- function GroupHeaderText({ label, value, title, description, }) {
2386
- const display = title ?? value ?? '';
2387
- return (_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsxs("span", { children: [label && _jsxs("span", { className: "text-muted-foreground/70", children: [label, ": "] }), _jsx("span", { className: "text-foreground", children: display || 'Ungrouped' })] }), description && (_jsx("span", { className: "text-[10px] font-normal normal-case text-muted-foreground/80", children: description }))] }));
2388
- }
2389
- /**
2390
- * "Group by" dropdown rendered above the table when 2+ TableGroups
2391
- * are registered (or 1 group with rich metadata). Selecting "None"
2392
- * sets `?group=` (empty) which explicitly overrides `defaultGroup`.
2393
- *
2394
- * URL-driven — `onChange` builds the next href via `buildTableQuery`
2395
- * and SPA-navigates; the page re-renders with the new active group.
2396
- */
2397
- function TableGroupPicker({ options, active, onChange, }) {
2398
- const value = active ?? '';
2399
- return (_jsxs(Select, { value: value, onValueChange: (v) => onChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-9 w-44", children: _jsx(SelectValue, { placeholder: "Group by\u2026" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "", children: "No grouping" }), options.map(o => (_jsx(SelectItem, { value: o.column, children: o.label }, o.column)))] })] }));
2400
- }
2401
- /**
2402
- * Pair-of-date-inputs filter for `kind === 'dateRange'`. Each side
2403
- * navigates the URL on change, encoding the pair as `from..to` keyed
2404
- * off the filter name. Empty pair drops the URL key.
2405
- */
2406
- function FilterDateRange({ name, label, defaultValue, placeholder, includesTime, minDate, maxDate, prefix, }) {
2407
- const initial = parseDateRangeValue(defaultValue);
2408
- const [from, setFrom] = useState(initial.from ?? '');
2409
- const [to, setTo] = useState(initial.to ?? '');
2410
- const navigate = useNavigate();
2411
- const inputType = includesTime ? 'datetime-local' : 'date';
2412
- const navigateTo = (nextFrom, nextTo) => {
2413
- if (typeof window === 'undefined')
2414
- return;
2415
- const url = new URL(window.location.href);
2416
- const encoded = encodeDateRangeValue({ from: nextFrom, to: nextTo });
2417
- const k = prefixK(prefix, name);
2418
- if (encoded === '')
2419
- url.searchParams.delete(k);
2420
- else
2421
- url.searchParams.set(k, encoded);
2422
- url.searchParams.delete(prefixK(prefix, 'page'));
2423
- void navigate(url.pathname + url.search);
2424
- };
2425
- const onFromChange = (e) => {
2426
- const v = e.target.value;
2427
- setFrom(v);
2428
- navigateTo(v, to);
2429
- };
2430
- const onToChange = (e) => {
2431
- const v = e.target.value;
2432
- setTo(v);
2433
- navigateTo(from, v);
2434
- };
2435
- const onClear = () => {
2436
- setFrom('');
2437
- setTo('');
2438
- navigateTo('', '');
2439
- };
2440
- const hasValue = from !== '' || to !== '';
2441
- return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: inputType, value: from, onChange: onFromChange, placeholder: placeholder, "aria-label": `${label} from`, ...(minDate !== undefined ? { min: minDate } : {}), ...(maxDate !== undefined ? { max: maxDate } : {}), className: "h-8 text-xs" }), _jsx("span", { className: "text-muted-foreground", children: "\u2192" }), _jsx(Input, { type: inputType, value: to, onChange: onToChange, placeholder: placeholder, "aria-label": `${label} to`, ...(minDate !== undefined ? { min: minDate } : {}), ...(maxDate !== undefined ? { max: maxDate } : {}), className: "h-8 text-xs" }), hasValue && (_jsx("button", { type: "button", onClick: onClear, "aria-label": `Clear ${label}`, className: "text-muted-foreground hover:text-foreground px-1", children: "\u00D7" }))] })] }));
2442
- }
2443
- /**
2444
- * Multi-value filter for `kind === 'multiSelect'`. Renders a checkbox
2445
- * stack inside the popover; toggling a box patches the comma-separated
2446
- * URL value for the filter's name. Empty selection drops the URL key.
2447
- */
2448
- function FilterMultiSelect({ name, label, defaultValue, options, prefix, }) {
2449
- const [selected, setSelected] = useState(() => parseMultiSelectValue(defaultValue));
2450
- const navigate = useNavigate();
2451
- const apply = (next) => {
2452
- setSelected(next);
2453
- if (typeof window === 'undefined')
2454
- return;
2455
- const url = new URL(window.location.href);
2456
- const encoded = encodeMultiSelectValue(next);
2457
- const k = prefixK(prefix, name);
2458
- if (encoded === '')
2459
- url.searchParams.delete(k);
2460
- else
2461
- url.searchParams.set(k, encoded);
2462
- url.searchParams.delete(prefixK(prefix, 'page'));
2463
- void navigate(url.pathname + url.search);
2464
- };
2465
- const toggle = (value, checked) => {
2466
- const next = checked
2467
- ? [...selected.filter(v => v !== value), value]
2468
- : selected.filter(v => v !== value);
2469
- apply(next);
2470
- };
2471
- return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("div", { className: "flex flex-col gap-1.5", children: options.map(o => {
2472
- const checked = selected.includes(o.value);
2473
- return (_jsxs("label", { className: "flex items-center gap-2 text-sm cursor-pointer", children: [_jsx(Checkbox, { checked: checked, onCheckedChange: (c) => toggle(o.value, c === true) }), _jsx("span", { children: o.label })] }, o.value));
2474
- }) })] }));
2475
- }
2476
- /**
2477
- * Multi-field filter for `kind === 'form'`. The popover renders an inner
2478
- * sub-form with the user-declared schema; submitting bundles all named
2479
- * inputs into a `Record<string, unknown>`, JSON-encodes the non-empty
2480
- * subset under the filter's URL key, and SPA-navigates. Empty submit
2481
- * drops the URL key entirely.
2482
- *
2483
- * The fields' `defaultValue` were pre-hydrated server-side from the
2484
- * active URL value (see `FormFilter.toMeta`), so an existing filter
2485
- * round-trips into the form on render. Inputs are uncontrolled — we
2486
- * read state via `new FormData(form)` on submit, matching how the
2487
- * outer page-level Form works on full submit.
2488
- */
2489
- function FilterForm({ name, label, defaultValue, formSchema, prefix, }) {
2490
- const formRef = useRef(null);
2491
- const navigate = useNavigate();
2492
- const hasValue = defaultValue !== '' && defaultValue !== '{}';
2493
- const onApply = (e) => {
2494
- e?.preventDefault();
2495
- if (!formRef.current)
2496
- return;
2497
- const fd = new FormData(formRef.current);
2498
- const values = {};
2499
- for (const [key, val] of fd.entries()) {
2500
- const existing = values[key];
2501
- if (existing === undefined) {
2502
- values[key] = val;
2503
- }
2504
- else if (Array.isArray(existing)) {
2505
- existing.push(val);
2506
- }
2507
- else {
2508
- values[key] = [existing, val];
2509
- }
2510
- }
2511
- if (typeof window === 'undefined')
2512
- return;
2513
- const url = new URL(window.location.href);
2514
- const encoded = encodeFormFilterValue(values);
2515
- const k = prefixK(prefix, name);
2516
- if (encoded === '')
2517
- url.searchParams.delete(k);
2518
- else
2519
- url.searchParams.set(k, encoded);
2520
- url.searchParams.delete(prefixK(prefix, 'page'));
2521
- void navigate(url.pathname + url.search);
2522
- };
2523
- const onClear = () => {
2524
- if (typeof window === 'undefined')
2525
- return;
2526
- const url = new URL(window.location.href);
2527
- url.searchParams.delete(prefixK(prefix, name));
2528
- url.searchParams.delete(prefixK(prefix, 'page'));
2529
- void navigate(url.pathname + url.search);
2530
- };
2531
- return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-muted-foreground text-xs", children: label }), _jsxs("form", { ref: formRef, onSubmit: onApply, className: "flex flex-col gap-2", children: [formSchema.map((child, i) => renderFormChild(child, i, {}, {})), _jsxs("div", { className: "flex gap-2 pt-1", children: [_jsx("button", { type: "submit", className: "inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90", children: "Apply" }), hasValue && (_jsx("button", { type: "button", onClick: onClear, className: "inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "Clear" }))] })] })] }));
2532
- }
2533
- /**
2534
- * Composable advanced filter for `kind === 'queryBuilder'`. v2 emits a
2535
- * full tree — root AND/OR connector + nested groups arbitrarily deep —
2536
- * JSON-encoded into a single URL key on Apply (see
2537
- * `encodeQueryBuilderValue`).
2538
- *
2539
- * State is local — typing into a value input doesn't navigate. Only the
2540
- * Apply button writes the URL. This mirrors `FilterForm`'s behavior and
2541
- * keeps the popover quiet under the cursor.
2542
- */
2543
- function FilterQueryBuilder({ name, label, defaultValue, constraints, prefix, }) {
2544
- const navigate = useNavigate();
2545
- const initialTree = parseQueryBuilderValue(defaultValue);
2546
- const [tree, setTree] = useState(initialTree);
2547
- const hasValue = defaultValue !== '' && initialTree.rules.length > 0;
2548
- const onApply = (e) => {
2549
- e?.preventDefault();
2550
- if (typeof window === 'undefined')
2551
- return;
2552
- const encoded = encodeQueryBuilderValue(tree);
2553
- const url = new URL(window.location.href);
2554
- const k = prefixK(prefix, name);
2555
- if (encoded === '')
2556
- url.searchParams.delete(k);
2557
- else
2558
- url.searchParams.set(k, encoded);
2559
- url.searchParams.delete(prefixK(prefix, 'page'));
2560
- void navigate(url.pathname + url.search);
2561
- };
2562
- const onClear = () => {
2563
- setTree({ operator: 'and', rules: [] });
2564
- if (typeof window === 'undefined')
2565
- return;
2566
- const url = new URL(window.location.href);
2567
- url.searchParams.delete(prefixK(prefix, name));
2568
- url.searchParams.delete(prefixK(prefix, 'page'));
2569
- void navigate(url.pathname + url.search);
2570
- };
2571
- if (constraints.length === 0) {
2572
- return (_jsxs("div", { className: "text-muted-foreground text-xs", children: [label, ": no constraints declared."] }));
2573
- }
2574
- return (_jsxs("div", { className: "flex flex-col gap-2 min-w-[24rem]", children: [_jsx("span", { className: "text-muted-foreground text-xs", children: label }), _jsxs("form", { onSubmit: onApply, className: "flex flex-col gap-2", children: [_jsx(QueryBuilderGroup, { tree: tree, constraints: constraints, isRoot: true, onChange: setTree }), _jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("div", { className: "flex-1" }), _jsx("button", { type: "submit", className: "inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90", children: "Apply" }), (hasValue || tree.rules.length > 0) && (_jsx("button", { type: "button", onClick: onClear, className: "inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "Clear" }))] })] })] }));
2575
- }
2576
- /**
2577
- * Recursive group renderer — emits a connector picker (AND / OR) at the
2578
- * top, a vertical stack of children (rules and sub-groups), and footer
2579
- * buttons for "+ Add condition" and "+ Add group". Calls `onChange` with
2580
- * the updated sub-tree so parents can splice it back into their own
2581
- * `rules` array. Root groups skip the outer border so the popover doesn't
2582
- * carry a redundant frame; nested groups draw a faint left rule + soft
2583
- * background so the nesting is visible without blowing up the width.
2584
- */
2585
- function QueryBuilderGroup({ tree, constraints, isRoot, onChange, onRemove, }) {
2586
- const constraintMap = new Map();
2587
- for (const c of constraints)
2588
- constraintMap.set(c.name, c);
2589
- const setOperator = (op) => {
2590
- onChange({ ...tree, operator: op });
2591
- };
2592
- const updateChildAt = (index, next) => {
2593
- onChange({ ...tree, rules: tree.rules.map((r, i) => i === index ? next : r) });
2594
- };
2595
- const removeChildAt = (index) => {
2596
- onChange({ ...tree, rules: tree.rules.filter((_, i) => i !== index) });
2597
- };
2598
- const addRule = () => {
2599
- const first = constraints[0];
2600
- if (!first)
2601
- return;
2602
- onChange({
2603
- ...tree,
2604
- rules: [...tree.rules, {
2605
- constraint: first.name,
2606
- operator: first.defaultOperator ?? first.operators[0]?.name ?? 'equals',
2607
- value: undefined,
2608
- }],
2609
- });
2610
- };
2611
- const addGroup = () => {
2612
- onChange({
2613
- ...tree,
2614
- rules: [...tree.rules, { operator: 'and', rules: [] }],
2615
- });
2616
- };
2617
- const wrapper = isRoot
2618
- ? 'flex flex-col gap-2'
2619
- : 'flex flex-col gap-2 rounded-md border-l-2 border-primary/40 bg-muted/30 pl-2 py-2 pr-2';
2620
- return (_jsxs("div", { className: wrapper, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectorToggle, { value: tree.operator, onChange: setOperator }), _jsx("span", { className: "text-muted-foreground text-[11px]", children: tree.operator === 'and' ? 'Match all of the following' : 'Match any of the following' }), !isRoot && onRemove && (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex-1" }), _jsx("button", { type: "button", onClick: onRemove, "aria-label": "Remove group", className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "\u00D7" })] }))] }), tree.rules.length === 0 && (_jsx("div", { className: "text-muted-foreground text-xs italic", children: "No conditions yet." })), tree.rules.map((child, i) => {
2621
- if (isQueryBuilderTree(child)) {
2622
- return (_jsx(QueryBuilderGroup, { tree: child, constraints: constraints, isRoot: false, onChange: (next) => updateChildAt(i, next), onRemove: () => removeChildAt(i) }, i));
2623
- }
2624
- return (_jsx(QueryBuilderRow, { rule: child, constraints: constraints, constraintMeta: constraintMap.get(child.constraint), onConstraintChange: (v) => {
2625
- const c = constraintMap.get(v);
2626
- if (!c)
2627
- return;
2628
- updateChildAt(i, {
2629
- constraint: v,
2630
- operator: c.defaultOperator ?? c.operators[0]?.name ?? 'equals',
2631
- value: undefined,
2632
- });
2633
- }, onOperatorChange: (v) => {
2634
- updateChildAt(i, {
2635
- ...child,
2636
- operator: v,
2637
- value: undefined,
2638
- });
2639
- }, onValueChange: (v) => updateChildAt(i, { ...child, value: v }), onRemove: () => removeChildAt(i) }, i));
2640
- }), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", onClick: addRule, className: "inline-flex h-8 items-center justify-center rounded-md border border-dashed border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "+ Add condition" }), _jsx("button", { type: "button", onClick: addGroup, className: "inline-flex h-8 items-center justify-center rounded-md border border-dashed border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "+ Add group" })] })] }));
2641
- }
2642
- /**
2643
- * Compact AND/OR segmented control used at the head of every group. Pure
2644
- * presentation — the parent owns the value.
2645
- */
2646
- function ConnectorToggle({ value, onChange, }) {
2647
- const base = 'inline-flex h-7 items-center px-2 text-[11px] font-medium uppercase tracking-wide transition';
2648
- const on = 'bg-primary text-primary-foreground';
2649
- const off = 'bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground';
2650
- return (_jsxs("div", { className: "inline-flex overflow-hidden rounded-md border border-input", children: [_jsx("button", { type: "button", onClick: () => onChange('and'), className: `${base} ${value === 'and' ? on : off}`, "aria-pressed": value === 'and', children: "AND" }), _jsx("button", { type: "button", onClick: () => onChange('or'), className: `${base} ${value === 'or' ? on : off}`, "aria-pressed": value === 'or', children: "OR" })] }));
2651
- }
2652
- /**
2653
- * One condition row inside `FilterQueryBuilder`. Three controls
2654
- * left-to-right: constraint picker, operator picker, value input. The
2655
- * value input dispatches off the operator's `valueKind` — `none` hides
2656
- * it entirely, `numberRange` / `dateRange` mount a pair, otherwise a
2657
- * single typed input.
2658
- */
2659
- function QueryBuilderRow({ rule, constraints, constraintMeta, onConstraintChange, onOperatorChange, onValueChange, onRemove, }) {
2660
- const operators = constraintMeta?.operators ?? [];
2661
- const activeOp = operators.find(o => o.name === rule.operator);
2662
- const valueKind = activeOp?.valueKind ?? 'text';
2663
- return (_jsxs("div", { className: "flex items-start gap-1.5 rounded-md border border-input bg-background p-2", children: [_jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-1.5", children: [_jsxs(Select, { value: rule.constraint, onValueChange: (v) => onConstraintChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 w-36 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: constraints.map(c => (_jsx(SelectItem, { value: c.name, children: c.label }, c.name))) })] }), _jsxs(Select, { value: rule.operator, onValueChange: (v) => onOperatorChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 w-32 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: operators.map(o => (_jsx(SelectItem, { value: o.name, children: o.label }, o.name))) })] }), _jsx(QueryBuilderValueInput, { kind: valueKind, value: rule.value, options: constraintMeta?.options, onChange: onValueChange })] }), _jsx("button", { type: "button", onClick: onRemove, "aria-label": "Remove condition", className: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "\u00D7" })] }));
2664
- }
2665
- /**
2666
- * Operator-aware value control. Switches over the constraint operator's
2667
- * `valueKind` and mounts the matching input. Value shapes:
2668
- * - `text / number / date / dateTime / select` → scalar
2669
- * - `multiSelect` → string[]
2670
- * - `numberRange / dateRange` → [string, string]
2671
- * - `boolean / none` → null / undefined
2672
- */
2673
- function QueryBuilderValueInput({ kind, value, options, onChange, }) {
2674
- if (kind === 'none' || kind === 'boolean')
2675
- return null;
2676
- if (kind === 'select') {
2677
- const opts = options ?? [];
2678
- const v = value === undefined || value === null ? '' : String(value);
2679
- return (_jsxs(Select, { value: v, onValueChange: (next) => onChange(typeof next === 'string' ? next : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 min-w-32 text-xs", children: _jsx(SelectValue, { placeholder: "Pick\u2026" }) }), _jsx(SelectContent, { children: opts.map(o => (_jsx(SelectItem, { value: o.value, children: o.label }, o.value))) })] }));
2680
- }
2681
- if (kind === 'multiSelect') {
2682
- const opts = options ?? [];
2683
- const list = Array.isArray(value) ? value.map(v => String(v)) : [];
2684
- const toggle = (val) => {
2685
- if (list.includes(val))
2686
- onChange(list.filter(v => v !== val));
2687
- else
2688
- onChange([...list, val]);
2689
- };
2690
- return (_jsx("div", { className: "flex flex-wrap items-center gap-1", children: opts.map(o => {
2691
- const active = list.includes(o.value);
2692
- return (_jsx("button", { type: "button", onClick: () => toggle(o.value), className: 'inline-flex h-7 items-center rounded-md border px-2 text-xs ' +
2693
- (active
2694
- ? 'border-primary bg-primary text-primary-foreground'
2695
- : 'border-input bg-background hover:bg-accent'), children: o.label }, o.value));
2696
- }) }));
2697
- }
2698
- if (kind === 'numberRange') {
2699
- const [min, max] = Array.isArray(value) ? [value[0], value[1]] : [undefined, undefined];
2700
- return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: "number", className: "h-8 w-24 text-xs", value: min === undefined || min === null ? '' : String(min), onChange: (e) => onChange([e.target.value, max ?? '']), placeholder: "Min" }), _jsx("span", { className: "text-muted-foreground text-xs", children: "\u2013" }), _jsx(Input, { type: "number", className: "h-8 w-24 text-xs", value: max === undefined || max === null ? '' : String(max), onChange: (e) => onChange([min ?? '', e.target.value]), placeholder: "Max" })] }));
2701
- }
2702
- if (kind === 'dateRange') {
2703
- const [from, to] = Array.isArray(value) ? [value[0], value[1]] : [undefined, undefined];
2704
- return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: "date", className: "h-8 w-36 text-xs", value: from === undefined || from === null ? '' : String(from), onChange: (e) => onChange([e.target.value, to ?? '']) }), _jsx("span", { className: "text-muted-foreground text-xs", children: "\u2192" }), _jsx(Input, { type: "date", className: "h-8 w-36 text-xs", value: to === undefined || to === null ? '' : String(to), onChange: (e) => onChange([from ?? '', e.target.value]) })] }));
2705
- }
2706
- if (kind === 'date' || kind === 'dateTime') {
2707
- const v = value === undefined || value === null ? '' : String(value);
2708
- return (_jsx(Input, { type: kind === 'dateTime' ? 'datetime-local' : 'date', className: "h-8 w-44 text-xs", value: v, onChange: (e) => onChange(e.target.value) }));
2709
- }
2710
- if (kind === 'number') {
2711
- const v = value === undefined || value === null ? '' : String(value);
2712
- return (_jsx(Input, { type: "number", className: "h-8 w-32 text-xs", value: v, onChange: (e) => onChange(e.target.value), placeholder: "Value" }));
2713
- }
2714
- // Default: text
2715
- const v = value === undefined || value === null ? '' : String(value);
2716
- return (_jsx(Input, { type: "text", className: "h-8 min-w-32 flex-1 text-xs", value: v, onChange: (e) => onChange(e.target.value), placeholder: "Value" }));
2717
- }
2718
- function renderFilterControl(el, index, prefix) {
2719
- const name = String(el['name'] ?? '');
2720
- const label = String(el['label'] ?? name);
2721
- const kind = String(el['kind'] ?? 'select');
2722
- const value = el['value'] ? String(el['value']) : '';
2723
- const placeholder = el['placeholder'] ? String(el['placeholder']) : 'All';
2724
- if (kind === 'queryBuilder') {
2725
- const constraints = el['constraints'] ?? [];
2726
- return (_jsx(FilterQueryBuilder, { name: name, label: label, defaultValue: value, constraints: constraints, prefix: prefix }, index));
2727
- }
2728
- if (kind === 'form') {
2729
- const formSchema = el['formSchema'] ?? [];
2730
- return (_jsx(FilterForm, { name: name, label: label, defaultValue: value, formSchema: formSchema, prefix: prefix }, index));
2731
- }
2732
- if (kind === 'boolean') {
2733
- return (_jsx(FilterSelect, { name: name, label: label, defaultValue: value, placeholder: placeholder, options: [{ value: '1', label: 'Yes' }, { value: '0', label: 'No' }], prefix: prefix }, index));
2734
- }
2735
- if (kind === 'multiSelect') {
2736
- const options = el['options'] ?? [];
2737
- return (_jsx(FilterMultiSelect, { name: name, label: label, defaultValue: value, options: options, prefix: prefix }, index));
2738
- }
2739
- if (kind === 'dateRange') {
2740
- const includesTime = Boolean(el['includesTime']);
2741
- const minDate = el['minDate'] ? String(el['minDate']) : undefined;
2742
- const maxDate = el['maxDate'] ? String(el['maxDate']) : undefined;
2743
- return (_jsx(FilterDateRange, { name: name, label: label, defaultValue: value, placeholder: placeholder, includesTime: includesTime, prefix: prefix, ...(minDate !== undefined ? { minDate } : {}), ...(maxDate !== undefined ? { maxDate } : {}) }, index));
2744
- }
2745
- // 'ternary' and 'select' both render as a single-select dropdown,
2746
- // differing only in their server-supplied option set.
2747
- const options = el['options'] ?? [];
2748
- return (_jsx(FilterSelect, { name: name, label: label, defaultValue: value, placeholder: placeholder, options: options, prefix: prefix }, index));
2749
- }
2750
- /**
2751
- * Resolve the record URL for a single data cell. Column-level override
2752
- * (`Column.recordUrl(fn)` → `_columnRecordUrls[name]`) wins over the
2753
- * table-level `Table.recordUrl(fn)` (`_recordUrl`). Explicit per-column
2754
- * opt-out (`Column.recordUrl(false)` → `meta.recordUrl === false`)
2755
- * suppresses the link entirely. Returns `undefined` when the cell is
2756
- * not linkable, in which case the renderer leaves it unwrapped.
2757
- */
2758
- function resolveColumnUrl(col, tableUrl, colUrls) {
2759
- if (col['recordUrl'] === false)
2760
- return undefined;
2761
- const own = colUrls[String(col['name'] ?? '')];
2762
- if (own !== undefined)
2763
- return own;
2764
- return tableUrl;
2765
- }
2766
- /**
2767
- * Cell-level link wrapper. Renders a real `<a href>` so right-click /
2768
- * cmd-click / middle-click "open in new tab" works, but intercepts plain
2769
- * left-clicks for SPA navigation via `useNavigate()`. Modified clicks
2770
- * (cmd / ctrl / shift / alt / non-primary buttons) fall through to the
2771
- * browser's default link behavior.
2772
- */
2773
- function RecordCellLink({ href, navigate, children, }) {
2774
- const onClick = (e) => {
2775
- if (e.button !== 0)
2776
- return;
2777
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
2778
- return;
2779
- e.preventDefault();
2780
- void navigate(href);
2781
- };
2782
- return (_jsx("a", { href: href, onClick: onClick, className: "block px-2 py-2 text-inherit no-underline hover:text-inherit focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: children }));
2783
- }
2784
- /**
2785
- * "Drilled into <Label>: <Value>" chip above the table when a group
2786
- * heading has been clicked. The × clears `?<prefix>groupKey=`, returning
2787
- * the table to its banded view. Real `<a href>` with `useNavigate()`
2788
- * intercept on plain left-click so cmd-click / middle-click open a
2789
- * fresh tab (rare but valid for sharing the banded view URL).
2790
- */
2791
- function ActiveGroupKeyChip({ label, value, displayValue, clearHref, navigate, }) {
2792
- const onClick = (e) => {
2793
- if (e.button !== 0)
2794
- return;
2795
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
2796
- return;
2797
- e.preventDefault();
2798
- void navigate(clearHref);
2799
- };
2800
- return (_jsxs("div", { className: "flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "Drilled into" }), _jsxs("span", { className: "font-medium text-foreground", children: [label ? `${label}: ` : '', displayValue || value] }), _jsx("a", { href: clearHref, onClick: onClick, "aria-label": "Clear drill-in", className: "ms-auto text-muted-foreground hover:text-foreground", children: "\u00D7" })] }));
2801
- }
2802
- /**
2803
- * Group-heading text wrapped in a real `<a href>` that SPA-navs into the
2804
- * drilled-in URL. Plain left-click intercepts for `useNavigate()`;
2805
- * cmd/ctrl/shift-click + middle-click fall through to the browser so
2806
- * "open in new tab" semantics work. Visually inherits the heading
2807
- * styling — the link adds underline-on-hover affordance without
2808
- * disturbing the surrounding text-transform / size.
2809
- */
2810
- function GroupHeadingLink({ href, navigate, children, }) {
2811
- const onClick = (e) => {
2812
- if (e.button !== 0)
2813
- return;
2814
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
2815
- return;
2816
- e.preventDefault();
2817
- void navigate(href);
2818
- };
2819
- return (_jsx("a", { href: href, onClick: onClick, className: "inline-flex items-center gap-1 text-inherit no-underline hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: children }));
2820
- }
2821
177
  /**
2822
178
  * List-page tab strip — Filament-style query shortcuts above the table
2823
179
  * ("All / Drafts / Published / Archived"). Each trigger is a real `<a>`
@@ -2919,939 +275,7 @@ function RelationTabIcon({ icon }) {
2919
275
  const Icon = useIconFor(icon);
2920
276
  if (!Icon)
2921
277
  return null;
2922
- return _jsx(Icon, { className: "size-4", "aria-hidden": "true" });
2923
- }
2924
- /**
2925
- * Sort-by dropdown for `contentLayout: 'cards'`. Since the column-header
2926
- * row (which usually doubles as the sort affordance) is hidden in cards
2927
- * mode, this picker appears in the top bar instead. Each `Column` flagged
2928
- * `.sortable()` contributes two options — ascending and descending —
2929
- * yielding "Title (A→Z) / Title (Z→A) / Date (oldest first) / Date (newest
2930
- * first)" style entries. Selecting an option resets `?page=1`.
2931
- */
2932
- function SortByPicker({ columns, active, onChange, }) {
2933
- const sortable = columns.filter(c => Boolean(c['sortable']));
2934
- if (sortable.length === 0)
2935
- return null;
2936
- const value = active ? `${active.column}:${active.direction}` : '';
2937
- return (_jsxs(Select, { value: value, onValueChange: (v) => {
2938
- if (typeof v !== 'string' || v === '')
2939
- return;
2940
- const idx = v.indexOf(':');
2941
- if (idx < 0)
2942
- return;
2943
- const col = v.slice(0, idx);
2944
- const dir = v.slice(idx + 1) === 'desc' ? 'desc' : 'asc';
2945
- onChange(col, dir);
2946
- }, children: [_jsx(SelectTrigger, { size: "sm", className: "h-9 w-44", children: _jsx(SelectValue, { placeholder: "Sort by\u2026" }) }), _jsx(SelectContent, { children: sortable.map(col => {
2947
- const name = String(col['name'] ?? '');
2948
- const label = String(col['label'] ?? name);
2949
- return (_jsxs(React.Fragment, { children: [_jsxs(SelectItem, { value: `${name}:asc`, children: [label, " (A\u2192Z)"] }), _jsxs(SelectItem, { value: `${name}:desc`, children: [label, " (Z\u2192A)"] })] }, name));
2950
- }) })] }));
2951
- }
2952
- /**
2953
- * Toolbar dropdown for `Column.toggleable()` columns. Lists every
2954
- * toggleable column with a checkbox; toggling writes through to a
2955
- * caller-supplied `onToggle` (the `TableRendererBody` owns the state
2956
- * + the localStorage round-trip). Mounted only when at least one
2957
- * column is toggleable.
2958
- */
2959
- function ColumnsToggleDropdown({ columns, hidden, onToggle, }) {
2960
- if (columns.length === 0)
2961
- return null;
2962
- return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: (props) => (_jsxs("button", { ...props, type: "button", className: "inline-flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground hover:bg-accent", "aria-label": "Show or hide columns", children: [_jsx(Columns3Icon, { className: "h-4 w-4", "aria-hidden": "true" }), _jsx("span", { children: "Columns" })] })) }), _jsx(DropdownMenuContent, { align: "end", className: "min-w-[12rem]", children: columns.map((col, i) => {
2963
- const name = String(col['name'] ?? '');
2964
- const label = String(col['label'] ?? name);
2965
- const isHidden = hidden.has(name);
2966
- return (_jsxs(DropdownMenuItem, {
2967
- // Suppress menu-close so users can toggle multiple columns
2968
- // without re-opening the dropdown.
2969
- closeOnClick: false, onClick: () => onToggle(name, !isHidden), children: [_jsx("span", { className: "inline-flex w-4 items-center justify-center", children: !isHidden && _jsx(CheckIcon, { className: "h-4 w-4", "aria-hidden": "true" }) }), _jsx("span", { children: label })] }, i));
2970
- }) })] }));
2971
- }
2972
- /**
2973
- * Lookup tables for responsive grid column-counts in `contentLayout:
2974
- * 'cards'`. Tailwind's JIT scanner needs **literal** class strings; we
2975
- * can't construct them at runtime via template literals (`grid-cols-${n}`
2976
- * would never be matched). Limit to 1–6 columns + 12 — covers every
2977
- * reasonable card grid; bigger values are silently capped at 6 for
2978
- * non-base breakpoints in `cardsPerRowClasses`.
2979
- */
2980
- const CARDS_GRID_COLS_BASE = {
2981
- 1: 'grid-cols-1',
2982
- 2: 'grid-cols-2',
2983
- 3: 'grid-cols-3',
2984
- 4: 'grid-cols-4',
2985
- 5: 'grid-cols-5',
2986
- 6: 'grid-cols-6',
2987
- 12: 'grid-cols-12',
2988
- };
2989
- const CARDS_GRID_COLS_SM = {
2990
- 1: 'sm:grid-cols-1', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3',
2991
- 4: 'sm:grid-cols-4', 5: 'sm:grid-cols-5', 6: 'sm:grid-cols-6',
2992
- 12: 'sm:grid-cols-12',
2993
- };
2994
- const CARDS_GRID_COLS_MD = {
2995
- 1: 'md:grid-cols-1', 2: 'md:grid-cols-2', 3: 'md:grid-cols-3',
2996
- 4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-6',
2997
- 12: 'md:grid-cols-12',
2998
- };
2999
- const CARDS_GRID_COLS_LG = {
3000
- 1: 'lg:grid-cols-1', 2: 'lg:grid-cols-2', 3: 'lg:grid-cols-3',
3001
- 4: 'lg:grid-cols-4', 5: 'lg:grid-cols-5', 6: 'lg:grid-cols-6',
3002
- 12: 'lg:grid-cols-12',
3003
- };
3004
- const CARDS_GRID_COLS_XL = {
3005
- 1: 'xl:grid-cols-1', 2: 'xl:grid-cols-2', 3: 'xl:grid-cols-3',
3006
- 4: 'xl:grid-cols-4', 5: 'xl:grid-cols-5', 6: 'xl:grid-cols-6',
3007
- 12: 'xl:grid-cols-12',
3008
- };
3009
- const CARDS_GRID_COLS_2XL = {
3010
- 1: '2xl:grid-cols-1', 2: '2xl:grid-cols-2', 3: '2xl:grid-cols-3',
3011
- 4: '2xl:grid-cols-4', 5: '2xl:grid-cols-5', 6: '2xl:grid-cols-6',
3012
- 12: '2xl:grid-cols-12',
3013
- };
3014
- function pickCardCols(table, raw) {
3015
- if (raw === undefined)
3016
- return undefined;
3017
- if (table[raw])
3018
- return table[raw];
3019
- // Snap unsupported values to nearest available — values outside [1,6]∪{12}
3020
- // round down. Already-clamped to [1,12] server-side.
3021
- if (raw >= 12)
3022
- return table[12];
3023
- if (raw >= 6)
3024
- return table[6];
3025
- if (raw >= 5)
3026
- return table[5];
3027
- if (raw >= 4)
3028
- return table[4];
3029
- if (raw >= 3)
3030
- return table[3];
3031
- if (raw >= 2)
3032
- return table[2];
3033
- return table[1];
3034
- }
3035
- /** Build a Tailwind grid-cols class string from a per-row config. Default
3036
- * `{ default: 1, sm: 2, lg: 3 }` mirrors Filament's typical card grid. */
3037
- function cardsPerRowClasses(opts) {
3038
- const cfg = opts ?? {};
3039
- const baseN = cfg['default'] ?? 1;
3040
- const out = [pickCardCols(CARDS_GRID_COLS_BASE, baseN)];
3041
- if (cfg['sm'] !== undefined) {
3042
- const c = pickCardCols(CARDS_GRID_COLS_SM, cfg['sm']);
3043
- if (c)
3044
- out.push(c);
3045
- }
3046
- if (cfg['md'] !== undefined) {
3047
- const c = pickCardCols(CARDS_GRID_COLS_MD, cfg['md']);
3048
- if (c)
3049
- out.push(c);
3050
- }
3051
- if (cfg['lg'] !== undefined) {
3052
- const c = pickCardCols(CARDS_GRID_COLS_LG, cfg['lg']);
3053
- if (c)
3054
- out.push(c);
3055
- }
3056
- if (cfg['xl'] !== undefined) {
3057
- const c = pickCardCols(CARDS_GRID_COLS_XL, cfg['xl']);
3058
- if (c)
3059
- out.push(c);
3060
- }
3061
- if (cfg['2xl'] !== undefined) {
3062
- const c = pickCardCols(CARDS_GRID_COLS_2XL, cfg['2xl']);
3063
- if (c)
3064
- out.push(c);
3065
- }
3066
- // Unset fallback covers Filament's typical default — 1 column on mobile,
3067
- // 2 on small screens, 3 on large.
3068
- if (Object.keys(cfg).length === 0) {
3069
- return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3';
3070
- }
3071
- return out.join(' ');
3072
- }
3073
- /**
3074
- * Tier-3 deferred-load shell. When `Resource.deferLoading = true`, the
3075
- * SSR pass marks each Table on the page as `deferred` + stamps a
3076
- * `tableUrl`. This wrapper paints a skeleton on first frame and fetches
3077
- * the actual rows from the JSON endpoint after mount; the inner
3078
- * `TableRendererBody` renders identically against either the SSR meta
3079
- * (non-deferred case) or the fetched meta (deferred case).
3080
- *
3081
- * SPA nav with a query change re-runs SSR, which re-stamps `deferred`
3082
- * — so the URL-change effect fires another fetch. The skeleton frame
3083
- * still shows current sort / search / page / filter chrome because the
3084
- * SSR pass mirrors URL state on the deferred Table.
3085
- */
3086
- function TableRenderer({ el }) {
3087
- const isDeferred = el['deferred'] === true && typeof el['tableUrl'] === 'string';
3088
- const tableUrl = isDeferred ? el['tableUrl'] : '';
3089
- // Track the URL search string so a navigation that changes filters /
3090
- // sort / page re-fires the fetch. Initialized lazy on first client
3091
- // render; on the SSR pass we just fall through to skeleton.
3092
- const [search, setSearch] = useState(() => typeof window === 'undefined' ? '' : window.location.search);
3093
- useEffect(() => {
3094
- if (!isDeferred)
3095
- return;
3096
- if (typeof window === 'undefined')
3097
- return;
3098
- setSearch(window.location.search);
3099
- }, [isDeferred, el]);
3100
- const [deferredMeta, setDeferredMeta] = useState(null);
3101
- const [deferredError, setDeferredError] = useState(null);
3102
- useEffect(() => {
3103
- if (!isDeferred || !tableUrl)
3104
- return;
3105
- if (typeof window === 'undefined')
3106
- return;
3107
- let cancelled = false;
3108
- setDeferredMeta(null);
3109
- setDeferredError(null);
3110
- fetch(tableUrl + search, {
3111
- headers: { 'Accept': 'application/json' },
3112
- credentials: 'same-origin',
3113
- })
3114
- .then(async (r) => {
3115
- const data = (await r.json());
3116
- if (cancelled)
3117
- return;
3118
- if (data.ok && Array.isArray(data.tables) && data.tables.length > 0) {
3119
- setDeferredMeta(data.tables[0]);
3120
- }
3121
- else {
3122
- setDeferredError(data.error ?? 'Failed to load table');
3123
- }
3124
- })
3125
- .catch(err => {
3126
- if (cancelled)
3127
- return;
3128
- setDeferredError(err instanceof Error ? err.message : 'Failed to load table');
3129
- });
3130
- return () => { cancelled = true; };
3131
- }, [isDeferred, tableUrl, search]);
3132
- if (isDeferred && deferredError) {
3133
- return (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive", children: ["Failed to load table: ", deferredError] }));
3134
- }
3135
- if (isDeferred && !deferredMeta) {
3136
- return _jsx(TableSkeleton, { el: el });
3137
- }
3138
- return _jsx(TableRendererBody, { el: isDeferred ? deferredMeta : el });
3139
- }
3140
- /**
3141
- * Skeleton placeholder painted while a deferred-loaded table fetches
3142
- * its rows. Mirrors the table's heading + description chrome (already
3143
- * present on `el`) so the frame doesn't pop layout when the real rows
3144
- * arrive. Renders a small column header strip + 5 placeholder rows.
3145
- */
3146
- function TableSkeleton({ el }) {
3147
- const heading = typeof el['heading'] === 'string' ? el['heading'] : undefined;
3148
- const description = typeof el['description'] === 'string' ? el['description'] : undefined;
3149
- const children = el.children ?? [];
3150
- const colCount = Math.max(1, children.filter(c => c.type === 'column').length);
3151
- return (_jsxs("div", { className: "space-y-3", children: [(heading || description) ? (_jsxs("div", { className: "space-y-1", children: [heading ? _jsx("div", { className: "text-lg font-semibold", children: heading }) : null, description ? _jsx("div", { className: "text-sm text-muted-foreground", children: description }) : null] })) : null, _jsxs("div", { className: "rounded-md border", children: [_jsx("div", { className: "grid border-b bg-muted/50 px-4 py-2", style: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }, children: Array.from({ length: colCount }).map((_, i) => (_jsx("div", { className: "h-4 w-20 rounded bg-muted-foreground/20" }, i))) }), _jsx("div", { className: "divide-y", children: Array.from({ length: 5 }).map((_, rowIdx) => (_jsx("div", { className: "grid items-center px-4 py-3", style: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }, children: Array.from({ length: colCount }).map((_, colIdx) => (_jsx("div", { className: "h-4 w-2/3 rounded bg-muted-foreground/10 animate-pulse" }, colIdx))) }, rowIdx))) })] })] }));
3152
- }
3153
- function TableRendererBody({ el }) {
3154
- const navigate = useNavigate();
3155
- const children = el.children ?? [];
3156
- const columns = children.filter(c => c.type === 'column');
3157
- // `Column.toggleable()` columns — sourced from the resolved meta. The
3158
- // user's per-table visibility map is owned + persisted below; the full
3159
- // `columns` list stays available for the toolbar dropdown so hidden
3160
- // columns can be re-shown without a roundtrip.
3161
- const toggleableColumns = columns.filter(c => c['toggleable'] !== undefined);
3162
- // Actions and ActionGroups share placement — both show up in the
3163
- // header/bulk/row toolbars depending on their `placement` field.
3164
- const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
3165
- const filters = children.filter(c => c.type === 'filter');
3166
- const hasRecordUrl = Boolean(el['recordUrl']);
3167
- const hasRecordClasses = Boolean(el['recordClasses']);
3168
- const pollInterval = typeof el['pollInterval'] === 'number' ? el['pollInterval'] : undefined;
3169
- const defaultGroup = typeof el['defaultGroup'] === 'string' ? el['defaultGroup'] : undefined;
3170
- const activeGroupKey = typeof el['activeGroupKey'] === 'string' ? el['activeGroupKey'] : undefined;
3171
- const summaries = el['summaries'];
3172
- const groupSummaries = el['groupSummaries'];
3173
- const groupOptions = el['groups'] ?? [];
3174
- // Active group's registered metadata (if any). Falls back to a synth
3175
- // for the bare-column form so the heading row still has a label.
3176
- const activeGroupMeta = defaultGroup
3177
- ? (groupOptions.find(g => g.column === defaultGroup) ?? {
3178
- column: defaultGroup,
3179
- label: (() => {
3180
- const col = columns.find(c => c['name'] === defaultGroup);
3181
- return col ? String(col['label'] ?? defaultGroup) : defaultGroup;
3182
- })(),
3183
- })
3184
- : undefined;
3185
- const groupColumnLabel = activeGroupMeta?.label;
3186
- // Heading text becomes a real `<a href>` when the active group opts in
3187
- // via `.scopable()`. Synthesized bare-column groups can't be scopable
3188
- // (no builder call ran).
3189
- const groupHeadingScopable = activeGroupMeta !== undefined
3190
- && activeGroupMeta.scopable === true;
3191
- // Auto-refresh: re-visit current URL on a timer so sort/filter/pagination
3192
- // state survives. Pause while the document is hidden — background tabs
3193
- // shouldn't keep hammering the server.
3194
- useEffect(() => {
3195
- if (!pollInterval || pollInterval <= 0)
3196
- return;
3197
- if (typeof document === 'undefined')
3198
- return;
3199
- let timerId;
3200
- const tick = () => navigate(window.location.pathname + window.location.search);
3201
- const start = () => {
3202
- if (timerId === undefined)
3203
- timerId = setInterval(tick, pollInterval * 1000);
3204
- };
3205
- const stop = () => {
3206
- if (timerId !== undefined) {
3207
- clearInterval(timerId);
3208
- timerId = undefined;
3209
- }
3210
- };
3211
- if (document.visibilityState === 'visible')
3212
- start();
3213
- const onVis = () => {
3214
- if (document.visibilityState === 'visible')
3215
- start();
3216
- else
3217
- stop();
3218
- };
3219
- document.addEventListener('visibilitychange', onVis);
3220
- return () => {
3221
- document.removeEventListener('visibilitychange', onVis);
3222
- stop();
3223
- };
3224
- }, [pollInterval, navigate]);
3225
- // Group actions by placement. `inline` defaults to header so it shows up
3226
- // somewhere visible — explicit placements always win.
3227
- const placementOf = (a) => String(a['placement'] ?? 'inline');
3228
- const headerActions = actionLike.filter(a => { const p = placementOf(a); return p === 'header' || p === 'inline'; });
3229
- const bulkActions = actionLike.filter(a => placementOf(a) === 'bulk');
3230
- const rowActions = actionLike.filter(a => placementOf(a) === 'row');
3231
- const rawRows = el['rows'] ?? [];
3232
- const total = el['total'] ?? rawRows.length;
3233
- const search = el['search'];
3234
- const currentSort = el['currentSort'];
3235
- const currentPage = el['currentPage'] ?? 1;
3236
- const perPage = el['perPage'];
3237
- const searchable = Boolean(el['searchable']);
3238
- const currentPath = el['currentPath'] ?? '';
3239
- // `Column.toggleable()` user-visibility map. Persisted per-table at
3240
- // `pilotiq.table.<currentPath>.columns.<name>` ('1' = hidden,
3241
- // '0' = visible). On first paint, fall back to `meta.toggleable.initiallyHidden`.
3242
- // SSR returns the meta default — the localStorage hydrate happens
3243
- // inside the effect so server + first client render match.
3244
- const columnsVisibilityKey = (name) => `pilotiq.table.${currentPath}.columns.${name}`;
3245
- const initialHidden = () => {
3246
- const out = new Set();
3247
- for (const col of toggleableColumns) {
3248
- const cfg = col['toggleable'];
3249
- if (cfg?.initiallyHidden)
3250
- out.add(String(col['name']));
3251
- }
3252
- return out;
3253
- };
3254
- const [hiddenColumns, setHiddenColumns] = useState(initialHidden);
3255
- useEffect(() => {
3256
- if (typeof window === 'undefined')
3257
- return;
3258
- if (toggleableColumns.length === 0)
3259
- return;
3260
- const next = new Set();
3261
- for (const col of toggleableColumns) {
3262
- const name = String(col['name']);
3263
- const cfg = col['toggleable'];
3264
- try {
3265
- const stored = window.localStorage.getItem(columnsVisibilityKey(name));
3266
- if (stored === '1')
3267
- next.add(name);
3268
- else if (stored === '0') { /* visible */ }
3269
- else if (cfg?.initiallyHidden)
3270
- next.add(name);
3271
- }
3272
- catch {
3273
- if (cfg?.initiallyHidden)
3274
- next.add(name);
3275
- }
3276
- }
3277
- setHiddenColumns(next);
3278
- // eslint-disable-next-line react-hooks/exhaustive-deps
3279
- }, [currentPath, toggleableColumns.length]);
3280
- const toggleColumnHidden = (name, nextHidden) => {
3281
- setHiddenColumns(prev => {
3282
- const next = new Set(prev);
3283
- if (nextHidden)
3284
- next.add(name);
3285
- else
3286
- next.delete(name);
3287
- if (typeof window !== 'undefined') {
3288
- try {
3289
- window.localStorage.setItem(columnsVisibilityKey(name), nextHidden ? '1' : '0');
3290
- }
3291
- catch { /* private mode / quota — silent */ }
3292
- }
3293
- return next;
3294
- });
3295
- };
3296
- // Filtered column list used by every render path (header, body cells,
3297
- // group + footer summaries, empty-state colSpan). Non-toggleable
3298
- // columns always survive.
3299
- const visibleColumns = columns.filter(c => !hiddenColumns.has(String(c['name'])));
3300
- // Tier-3 — when the table opts into `Table.queryStringIdentifier(...)`,
3301
- // every URL key (search / sort / page / perPage / group / filter names)
3302
- // gets prefixed with `${id}_` so multiple tables on one page don't
3303
- // collide on `?search=` etc. Bare keys still apply when unset.
3304
- const queryPrefix = typeof el['queryStringIdentifier'] === 'string'
3305
- ? el['queryStringIdentifier']
3306
- : undefined;
3307
- // Reorderable rows — grip column + HTML5 DnD wiring. Rows live in
3308
- // local state during a drag so the optimistic reorder happens
3309
- // immediately; on POST failure we roll back to the server's order.
3310
- const reorderableColumn = typeof el['reorderableColumn'] === 'string' ? el['reorderableColumn'] : undefined;
3311
- const reorderUrl = typeof el['reorderUrl'] === 'string' ? el['reorderUrl'] : undefined;
3312
- const [reorderRowsLocal, setReorderRowsLocal] = useState(null);
3313
- const rows = reorderRowsLocal ?? rawRows;
3314
- const { notify } = useToast();
3315
- // Read the explicit `?group=` value out of the URL so sort/pagination
3316
- // links preserve "None" overrides (`?group=`). Server render: no URL,
3317
- // so we fall back to `defaultGroup` from the meta — which is already
3318
- // the reconciled active column.
3319
- const urlGroup = typeof window === 'undefined'
3320
- ? undefined
3321
- : (() => {
3322
- const sp = new URLSearchParams(window.location.search);
3323
- const k = prefixK(queryPrefix, 'group');
3324
- return sp.has(k) ? sp.get(k) : undefined;
3325
- })();
3326
- // Collapsible groups — per-group fold state. Keyed by `_groupValue`
3327
- // (the raw column value, NOT the resolved title) so rows that share a
3328
- // group key fold together. Persisted in localStorage at
3329
- // `pilotiq.table.<currentPath>.groups.<column>.<value>`. Default-
3330
- // collapsed groups derive their initial state from `meta.collapsed`.
3331
- const groupCollapsible = activeGroupMeta?.collapsible === true;
3332
- const groupDefaultCollapsed = activeGroupMeta?.collapsed === true;
3333
- const groupStorageKey = (groupValue) => `pilotiq.table.${currentPath}.groups.${defaultGroup ?? ''}.${groupValue}`;
3334
- // Lazy-init from localStorage on mount; SSR returns the meta default.
3335
- const [collapsedGroups, setCollapsedGroups] = useState({});
3336
- useEffect(() => {
3337
- if (!groupCollapsible || !defaultGroup)
3338
- return;
3339
- if (typeof window === 'undefined')
3340
- return;
3341
- // Walk the rendered rows once on mount, picking up persisted state.
3342
- const next = {};
3343
- const seen = new Set();
3344
- for (const row of rows) {
3345
- const v = String(row['_groupValue'] ?? '');
3346
- if (seen.has(v))
3347
- continue;
3348
- seen.add(v);
3349
- try {
3350
- const stored = window.localStorage.getItem(groupStorageKey(v));
3351
- next[v] = stored === null ? groupDefaultCollapsed : stored === '1';
3352
- }
3353
- catch {
3354
- next[v] = groupDefaultCollapsed;
3355
- }
3356
- }
3357
- setCollapsedGroups(next);
3358
- // Re-run if the active group changes — different values, different
3359
- // localStorage namespace.
3360
- // eslint-disable-next-line react-hooks/exhaustive-deps
3361
- }, [defaultGroup, groupCollapsible, groupDefaultCollapsed, currentPath]);
3362
- const toggleGroupCollapsed = (groupValue) => {
3363
- setCollapsedGroups(prev => {
3364
- const nextOpen = !prev[groupValue];
3365
- const next = { ...prev, [groupValue]: nextOpen };
3366
- if (typeof window !== 'undefined') {
3367
- try {
3368
- window.localStorage.setItem(groupStorageKey(groupValue), nextOpen ? '1' : '0');
3369
- }
3370
- catch { /* private mode / quota — silent */ }
3371
- }
3372
- return next;
3373
- });
3374
- };
3375
- const state = {
3376
- ...(search !== undefined ? { search } : {}),
3377
- ...(currentSort !== undefined ? { sort: currentSort } : {}),
3378
- page: currentPage,
3379
- ...(urlGroup !== undefined ? { group: urlGroup }
3380
- : defaultGroup !== undefined ? { group: defaultGroup }
3381
- : {}),
3382
- ...(activeGroupKey !== undefined ? { groupKey: activeGroupKey } : {}),
3383
- };
3384
- // Snapshot active filter values for sort/pagination href construction.
3385
- // Filter form submits already carry these (selects are inside the
3386
- // form); `<a href>` links don't, so we re-emit them here.
3387
- const activeFilters = {};
3388
- for (const f of filters) {
3389
- const v = f['value'];
3390
- if (typeof v === 'string' && v !== '')
3391
- activeFilters[String(f['name'])] = v;
3392
- }
3393
- // Drill-in / drill-out URL builders for the group heading link and the
3394
- // active-key chip's clear button. Drill-in sets `?<prefix>groupKey=v`
3395
- // and resets `page`; drill-out clears it. Both round-trip foreign
3396
- // params (other tables' state) through `buildTableQuery`.
3397
- const buildGroupKeyHref = (value) => buildTableQuery(state, { groupKey: value, page: 1 }, currentPath, activeFilters, queryPrefix);
3398
- const drillOutHref = () => buildTableQuery(state, { groupKey: '', page: 1 }, currentPath, activeFilters, queryPrefix);
3399
- // Track which row ids are currently checked. Keyed by id (string), not
3400
- // by index, so pagination and re-renders don't drop selection state.
3401
- const [selected, setSelected] = useState(() => new Set());
3402
- const visibleIds = rows.map((row, i) => rowId(row, i));
3403
- const allChecked = visibleIds.length > 0 && visibleIds.every(id => selected.has(id));
3404
- const someChecked = selected.size > 0;
3405
- const toggleRow = (id) => {
3406
- setSelected(prev => {
3407
- const next = new Set(prev);
3408
- if (next.has(id))
3409
- next.delete(id);
3410
- else
3411
- next.add(id);
3412
- return next;
3413
- });
3414
- };
3415
- const toggleAll = () => {
3416
- setSelected(prev => {
3417
- if (visibleIds.every(id => prev.has(id))) {
3418
- const next = new Set(prev);
3419
- for (const id of visibleIds)
3420
- next.delete(id);
3421
- return next;
3422
- }
3423
- const next = new Set(prev);
3424
- for (const id of visibleIds)
3425
- next.add(id);
3426
- return next;
3427
- });
3428
- };
3429
- // ── Reorder DnD state + handlers ──────────────────────
3430
- // dragId — the row currently being dragged (string id), or null.
3431
- // dropAt — the boundary the cursor is hovering (0..rows.length), or null.
3432
- const [dragId, setDragId] = useState(null);
3433
- const [dropAt, setDropAt] = useState(null);
3434
- const onRowDragStart = (id) => (e) => {
3435
- if (!reorderEnabled)
3436
- return;
3437
- setDragId(id);
3438
- e.dataTransfer.effectAllowed = 'move';
3439
- try {
3440
- e.dataTransfer.setData('text/plain', id);
3441
- }
3442
- catch { /* IE quirk */ }
3443
- };
3444
- const onRowDragOver = (idx) => (e) => {
3445
- if (!reorderEnabled || dragId === null)
3446
- return;
3447
- e.preventDefault();
3448
- e.dataTransfer.dropEffect = 'move';
3449
- const rect = e.currentTarget.getBoundingClientRect();
3450
- const aboveHalf = e.clientY < rect.top + rect.height / 2;
3451
- setDropAt(aboveHalf ? idx : idx + 1);
3452
- };
3453
- const onRowDrop = async (e) => {
3454
- if (!reorderEnabled || dragId === null || dropAt === null || !reorderUrl) {
3455
- setDragId(null);
3456
- setDropAt(null);
3457
- return;
3458
- }
3459
- e.preventDefault();
3460
- const fromIdx = visibleIds.findIndex(id => id === dragId);
3461
- setDragId(null);
3462
- setDropAt(null);
3463
- if (fromIdx < 0)
3464
- return;
3465
- const target = dropAt > fromIdx ? dropAt - 1 : dropAt;
3466
- if (target === fromIdx)
3467
- return;
3468
- const reordered = rows.slice();
3469
- const moved = reordered.splice(fromIdx, 1)[0];
3470
- reordered.splice(target, 0, moved);
3471
- const newIds = reordered.map((row, i) => rowId(row, i));
3472
- const previousLocal = reorderRowsLocal;
3473
- setReorderRowsLocal(reordered);
3474
- try {
3475
- const res = await fetch(reorderUrl, {
3476
- method: 'POST',
3477
- headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
3478
- body: JSON.stringify({ ids: newIds }),
3479
- });
3480
- if (!res.ok)
3481
- throw new Error(`Reorder failed (${res.status})`);
3482
- }
3483
- catch (err) {
3484
- // Roll back to server order. The toast surfaces the failure;
3485
- // next page render fetches the persisted column.
3486
- setReorderRowsLocal(previousLocal);
3487
- notify({
3488
- type: 'error',
3489
- title: 'Could not save new order',
3490
- body: err instanceof Error ? err.message : 'Reorder failed',
3491
- });
3492
- }
3493
- };
3494
- const onRowDragEnd = () => {
3495
- setDragId(null);
3496
- setDropAt(null);
3497
- };
3498
- if (columns.length === 0) {
3499
- return (_jsx("div", { className: "rounded-xl border bg-card p-6 text-sm text-muted-foreground", children: "No columns configured for this table." }));
3500
- }
3501
- const isCardsLayout = el['contentLayout'] === 'cards';
3502
- const cardsPerRow = el['cardsPerRow'];
3503
- const totalPages = perPage && perPage > 0 ? Math.max(1, Math.ceil(total / perPage)) : 1;
3504
- const showPagination = totalPages > 1;
3505
- const hasFilters = filters.length > 0;
3506
- // Filter layout positions (Filament v5). `'modal'` (default) keeps the
3507
- // toolbar Filters button + popover. The three inline modes lay every
3508
- // filter widget out as a wrapping strip in the matching slot. The
3509
- // collapsible variant adds a toolbar toggle + per-table-path persisted
3510
- // open state.
3511
- const filtersLayout = el['filtersLayout'] ?? 'modal';
3512
- const filtersInModal = filtersLayout === 'modal';
3513
- const filtersAbove = filtersLayout === 'above-content'
3514
- || filtersLayout === 'above-content-collapsible';
3515
- const filtersBelow = filtersLayout === 'below-content';
3516
- const filtersCollapsible = filtersLayout === 'above-content-collapsible';
3517
- const filtersStripStorageKey = `pilotiq.table.${currentPath}.filters.open`;
3518
- const [filtersOpen, setFiltersOpen] = useState(() => {
3519
- if (!filtersCollapsible)
3520
- return true;
3521
- if (typeof window === 'undefined')
3522
- return false;
3523
- try {
3524
- const stored = window.localStorage.getItem(filtersStripStorageKey);
3525
- // Default to OPEN when filters are active (URL carried filter values
3526
- // in) so the user can see what's filtering — same UX cue as the
3527
- // active-filters pill row.
3528
- if (stored === null)
3529
- return Object.keys(activeFilters).length > 0;
3530
- return stored === '1';
3531
- }
3532
- catch {
3533
- return false;
3534
- }
3535
- });
3536
- const toggleFiltersOpen = () => {
3537
- setFiltersOpen(prev => {
3538
- const next = !prev;
3539
- if (typeof window !== 'undefined') {
3540
- try {
3541
- window.localStorage.setItem(filtersStripStorageKey, next ? '1' : '0');
3542
- }
3543
- catch { /* private mode / quota — silent */ }
3544
- }
3545
- return next;
3546
- });
3547
- };
3548
- // Show the "Group by" dropdown when 2+ groups are registered, or 1
3549
- // group with rich metadata (label/collapsible/etc.). A single bare
3550
- // `defaultGroup('col')` with no `groups([...])` registration shouldn't
3551
- // render the picker — there's nothing to pick.
3552
- const hasGroupPicker = groupOptions.length >= 2
3553
- || (groupOptions.length === 1 && Boolean(groupOptions[0].collapsible
3554
- || groupOptions[0].collapsed
3555
- || groupOptions[0].date));
3556
- const sortableColumns = isCardsLayout ? columns.filter(c => Boolean(c['sortable'])) : [];
3557
- const hasSortPicker = isCardsLayout && sortableColumns.length > 0;
3558
- // Only modal + collapsible mount a toolbar widget; the always-visible
3559
- // strip modes don't add anything to the header bar.
3560
- const showFiltersInToolbar = hasFilters && (filtersInModal || filtersCollapsible);
3561
- const hasColumnsToggle = toggleableColumns.length > 0;
3562
- const showHeaderBar = searchable || headerActions.length > 0 || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle;
3563
- const hasBulkActions = bulkActions.length > 0;
3564
- const hasRowActions = rowActions.length > 0;
3565
- // Drag-to-reorder is enabled only when the visible rows ARE the
3566
- // canonical sort. Filters / search / non-default sort / pagination
3567
- // beyond page 1 all break that invariant; we render the grip column
3568
- // greyed-out instead of letting the user reorder a slice that won't
3569
- // round-trip cleanly. `reorderableColumn` is set server-side when
3570
- // `Table.reorderable()` opts in.
3571
- const sortMatchesReorder = currentSort?.column === reorderableColumn &&
3572
- currentSort?.direction === 'asc';
3573
- const filtersActive = Object.keys(activeFilters).length > 0;
3574
- const searchActive = typeof search === 'string' && search !== '';
3575
- const reorderEnabled = reorderableColumn !== undefined &&
3576
- reorderUrl !== undefined &&
3577
- sortMatchesReorder &&
3578
- !filtersActive &&
3579
- !searchActive &&
3580
- currentPage === 1;
3581
- const reorderColumnVisible = reorderableColumn !== undefined;
3582
- const totalCols = visibleColumns.length
3583
- + (hasBulkActions ? 1 : 0)
3584
- + (hasRowActions ? 1 : 0)
3585
- + (reorderColumnVisible ? 1 : 0);
3586
- // Top-bar chrome (heading / description / striped / emptyState).
3587
- const tableHeading = el['heading'];
3588
- const tableDescription = el['description'];
3589
- const striped = Boolean(el['striped']);
3590
- const emptyState = el['emptyState'];
3591
- const filteredEmptyState = el['filteredEmptyState'];
3592
- const hasFilterOrSearch = (search !== undefined && search !== '') ||
3593
- Object.keys(activeFilters).length > 0;
3594
- // Distinct copy when a query / filter is active. Falls back to
3595
- // `emptyState` when `filteredEmptyState` is not set, preserving the
3596
- // pre-2026-05-04 behavior for tables that haven't opted in.
3597
- const activeEmpty = (hasFilterOrSearch && filteredEmptyState) ? filteredEmptyState : emptyState;
3598
- const EmptyIcon = activeEmpty?.icon ? (resolveIcon(activeEmpty.icon) ?? InboxIcon) : InboxIcon;
3599
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [(tableHeading || tableDescription) && (_jsxs("div", { className: "flex flex-col gap-1", children: [tableHeading && _jsx("h2", { className: "text-lg font-semibold", children: tableHeading }), tableDescription && _jsx("p", { className: "text-sm text-muted-foreground", children: tableDescription })] })), showHeaderBar && (_jsxs("div", { className: "flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between", children: [(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle) ? (_jsxs("div", { className: "flex items-center gap-2", children: [searchable && (_jsxs("form", { method: "get", action: currentPath || undefined, className: "flex items-end gap-2", children: [_jsx(SearchFormHiddenInputs, { prefix: queryPrefix }), _jsx(Input, { type: "search", name: prefixK(queryPrefix, 'search'), defaultValue: search ?? '', placeholder: "Search\u2026", className: "h-9 w-64" }), _jsx("button", { type: "submit", className: "sr-only", tabIndex: -1, "aria-hidden": "true", children: "Apply" })] })), hasFilters && filtersInModal && (_jsx(FilterPopover, { filters: filters, prefix: queryPrefix })), hasFilters && filtersCollapsible && (_jsx(FilterStripToggle, { filters: filters, open: filtersOpen, onToggle: toggleFiltersOpen })), hasGroupPicker && (_jsx(TableGroupPicker, { options: groupOptions, active: defaultGroup, onChange: (value) => {
3600
- // value === '' → explicit "None" (clears defaultGroup);
3601
- // value !== '' → switch to that column.
3602
- const href = buildTableQuery(state, { page: 1, group: value }, currentPath, activeFilters, queryPrefix);
3603
- navigate(href);
3604
- } })), hasSortPicker && (_jsx(SortByPicker, { columns: sortableColumns, active: currentSort, onChange: (column, direction) => {
3605
- const href = buildTableQuery(state, { sort: { column, direction }, page: 1 }, currentPath, activeFilters, queryPrefix);
3606
- navigate(href);
3607
- } })), toggleableColumns.length > 0 && (_jsx(ColumnsToggleDropdown, { columns: toggleableColumns, hidden: hiddenColumns, onToggle: toggleColumnHidden }))] })) : _jsx("span", {}), headerActions.length > 0 && (_jsx("div", { className: "flex items-center gap-2", children: headerActions.map((a, i) => renderActionLike(a, i)) }))] })), hasFilters && filtersInModal && _jsx(ActiveFiltersBar, { filters: filters, prefix: queryPrefix }), hasFilters && filtersAbove && filtersOpen && (_jsx(FilterStrip, { filters: filters, prefix: queryPrefix })), activeGroupKey !== undefined && (_jsx(ActiveGroupKeyChip, { label: groupColumnLabel ?? defaultGroup ?? '', value: activeGroupKey, displayValue: (() => {
3608
- // Prefer a row-resolved `_groupTitle` (server stamped via
3609
- // `getTitleFromRecordUsing`) so the chip reads the same as
3610
- // a banded heading. Falls back to the raw bucket key when
3611
- // no row matched — empty drilled-in pages still show what
3612
- // they're drilled into.
3613
- for (const r of rows) {
3614
- const obj = r;
3615
- if (String(obj['_groupValue'] ?? '') !== activeGroupKey)
3616
- continue;
3617
- const t = obj['_groupTitle'];
3618
- if (typeof t === 'string' && t !== '')
3619
- return t;
3620
- break;
3621
- }
3622
- return activeGroupKey;
3623
- })(), clearHref: drillOutHref(), navigate: navigate })), hasBulkActions && someChecked && (_jsxs("div", { className: "flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm", children: [_jsxs("span", { className: "text-muted-foreground", children: [selected.size, " selected"] }), _jsxs("div", { className: "flex items-center gap-2", children: [bulkActions.map((a, i) => renderActionLike(a, i, { ids: Array.from(selected) })), _jsx("button", { type: "button", onClick: () => setSelected(new Set()), className: "text-xs text-muted-foreground hover:text-foreground", children: "Clear" })] })] })), isCardsLayout ? (_jsx(CardsLayoutBody, { el: el, columns: columns, rows: rows, visibleIds: visibleIds, selected: selected, toggleRow: toggleRow, hasBulkActions: hasBulkActions, hasRowActions: hasRowActions, rowActions: rowActions, hasRecordUrl: hasRecordUrl, hasRecordClasses: hasRecordClasses, striped: striped, activeEmpty: activeEmpty, EmptyIcon: EmptyIcon, hasFilterOrSearch: hasFilterOrSearch, defaultGroup: defaultGroup, groupColumnLabel: groupColumnLabel, groupCollapsible: groupCollapsible, collapsedGroups: collapsedGroups, toggleGroupCollapsed: toggleGroupCollapsed, cardsPerRow: cardsPerRow, navigate: navigate, groupHeadingScopable: groupHeadingScopable, buildGroupKeyHref: buildGroupKeyHref })) : (_jsx("div", { className: "rounded-xl border bg-card overflow-hidden", children: _jsxs(DataTable, { children: [_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { children: [reorderColumnVisible && (_jsx(TableHead, { className: "w-9 px-2", "aria-label": "Reorder" })), hasBulkActions && (_jsx(TableHead, { className: "w-9 px-3", children: _jsx(Checkbox, { "aria-label": "Select all rows", checked: allChecked, onCheckedChange: () => toggleAll() }) })), visibleColumns.map((col, i) => {
3624
- const name = String(col['name'] ?? '');
3625
- const label = String(col['label'] ?? name);
3626
- const sortable = Boolean(col['sortable']);
3627
- const isActive = currentSort?.column === name;
3628
- if (!sortable) {
3629
- return (_jsx(TableHead, { className: "text-xs uppercase tracking-wider", children: label }, i));
3630
- }
3631
- const next = nextSortDir(currentSort, name);
3632
- const href = buildTableQuery(state, { sort: next, page: 1 }, currentPath, activeFilters, queryPrefix);
3633
- return (_jsx(TableHead, { className: "text-xs uppercase tracking-wider", children: _jsxs("a", { href: href, className: "inline-flex items-center gap-1 hover:text-foreground", children: [label, _jsx("span", { className: "text-muted-foreground/70", children: isActive ? (currentSort.direction === 'asc' ? '↑' : '↓') : '↕' })] }) }, i));
3634
- }), hasRowActions && (_jsx(TableHead, { className: "w-px text-right text-xs uppercase tracking-wider", children: _jsx("span", { className: "sr-only", children: "Actions" }) }))] }) }), _jsx(TableBody, { children: rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, className: "py-12 text-center", children: _jsxs("div", { className: "flex flex-col items-center gap-2 text-muted-foreground", children: [_jsx(EmptyIcon, { className: "size-8 opacity-60" }), _jsx("p", { className: "text-base font-medium text-foreground", children: activeEmpty?.heading
3635
- ?? (hasFilterOrSearch ? 'No matching records' : 'No records yet') }), (activeEmpty?.description ||
3636
- (hasFilterOrSearch && !activeEmpty?.description)) && (_jsx("p", { className: "text-sm", children: activeEmpty?.description
3637
- ?? 'Try clearing filters or adjusting your search.' }))] }) }) })) : rows.map((row, ri) => {
3638
- const id = visibleIds[ri];
3639
- const recordObj = row;
3640
- const isSelected = selected.has(id);
3641
- const stripedClass = striped && ri % 2 === 1 ? 'bg-muted/30' : '';
3642
- // Group banding — emit a heading row whenever `_groupValue`
3643
- // differs from the previous row. The first row in any group
3644
- // gets the heading; rows within keep their normal chrome.
3645
- const groupValue = defaultGroup
3646
- ? String(recordObj['_groupValue'] ?? '')
3647
- : undefined;
3648
- const groupTitle = defaultGroup
3649
- ? recordObj['_groupTitle']
3650
- : undefined;
3651
- const groupDescription = defaultGroup
3652
- ? recordObj['_groupDescription']
3653
- : undefined;
3654
- const prevGroupValue = defaultGroup && ri > 0
3655
- ? String((rows[ri - 1]['_groupValue'] ?? ''))
3656
- : undefined;
3657
- const showGroupHeader = defaultGroup !== undefined && groupValue !== prevGroupValue;
3658
- // Hide data rows whose group is collapsed. The heading row
3659
- // for that group still renders (so the user can re-expand).
3660
- const isInCollapsedGroup = groupCollapsible && groupValue !== undefined && collapsedGroups[groupValue] === true;
3661
- // Filament-style per-cell linking. Each data cell wraps
3662
- // its content in a real `<a href>` when the column resolves
3663
- // to a record URL — column override (`Column.recordUrl(fn)`)
3664
- // beats inheritance from the table (`Table.recordUrl(fn)`),
3665
- // and `Column.recordUrl(false)` opts out. Action and bulk
3666
- // cells are never wrapped, so clicks there fire only their
3667
- // own handlers — no event-bubbling gymnastics.
3668
- const tableUrl = hasRecordUrl ? recordObj['_recordUrl'] : undefined;
3669
- const colUrls = recordObj['_columnRecordUrls'] ?? {};
3670
- const rowHasAnyLink = tableUrl !== undefined || Object.keys(colUrls).length > 0;
3671
- const customRowClasses = hasRecordClasses
3672
- ? recordObj['_recordClasses'] ?? ''
3673
- : '';
3674
- const rowClassName = [stripedClass, rowHasAnyLink ? 'cursor-pointer' : '', customRowClasses]
3675
- .filter(Boolean)
3676
- .join(' ')
3677
- .trim();
3678
- return (_jsxs(React.Fragment, { children: [showGroupHeader && (_jsx(TableRow, { className: "bg-muted/40 hover:bg-muted/40", children: _jsx(TableCell, { colSpan: totalCols, className: "px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: (() => {
3679
- const drillable = groupHeadingScopable
3680
- && groupValue !== undefined
3681
- && groupValue !== '';
3682
- const headingText = (_jsx(GroupHeaderText, { label: groupColumnLabel, value: groupValue, title: groupTitle, description: groupDescription }));
3683
- const headingNode = drillable
3684
- ? _jsx(GroupHeadingLink, { href: buildGroupKeyHref(groupValue), navigate: navigate, children: headingText })
3685
- : headingText;
3686
- if (groupCollapsible) {
3687
- return (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("button", { type: "button", className: "inline-flex items-center", onClick: () => toggleGroupCollapsed(groupValue), "aria-expanded": !isInCollapsedGroup, "aria-label": isInCollapsedGroup ? 'Expand group' : 'Collapse group', children: _jsx(ChevronDownIcon, { className: [
3688
- 'size-4 transition-transform',
3689
- isInCollapsedGroup ? '-rotate-90' : '',
3690
- ].filter(Boolean).join(' ') }) }), headingNode] }));
3691
- }
3692
- return headingNode;
3693
- })() }) }, `group-${id}`)), isInCollapsedGroup ? null : (_jsxs(TableRow, { "data-state": isSelected ? 'selected' : undefined, className: [
3694
- rowClassName,
3695
- dragId === id ? 'opacity-50' : '',
3696
- dropAt === ri && dragId !== null ? 'border-t-2 border-t-primary' : '',
3697
- ].filter(Boolean).join(' ') || undefined, draggable: reorderEnabled || undefined, onDragStart: reorderEnabled ? onRowDragStart(id) : undefined, onDragOver: reorderEnabled ? onRowDragOver(ri) : undefined, onDrop: reorderEnabled ? onRowDrop : undefined, onDragEnd: reorderEnabled ? onRowDragEnd : undefined, children: [reorderColumnVisible && (_jsx(TableCell, { className: "w-9 px-2", children: _jsx("span", { "aria-label": reorderEnabled ? 'Drag to reorder' : 'Reorder paused — clear filters and sort to enable', className: reorderEnabled
3698
- ? 'inline-flex cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing'
3699
- : 'inline-flex cursor-not-allowed text-muted-foreground/40', children: _jsx(GripVerticalIcon, { className: "size-4" }) }) })), hasBulkActions && (_jsx(TableCell, { className: "w-9 px-3", children: _jsx(Checkbox, { "aria-label": `Select row ${id}`, checked: isSelected, onCheckedChange: () => toggleRow(id) }) })), visibleColumns.map((col, ci) => {
3700
- const name = String(col['name'] ?? '');
3701
- const value = recordObj[name];
3702
- const align = col['alignment'] === 'center' ? 'text-center'
3703
- : col['alignment'] === 'end' ? 'text-right'
3704
- : 'text-left';
3705
- const widthStyle = col['width']
3706
- ? { width: String(col['width']) }
3707
- : undefined;
3708
- // Inline-edit cells take priority over read-only chrome.
3709
- // `_cellEditable[name]` is set per row by `loadTableRecords`
3710
- // only when `R.canEdit(user, row)` passed; the URL was
3711
- // stamped by `tagCellEditUrls` immediately after.
3712
- const editableMap = recordObj['_cellEditable'];
3713
- const editUrlMap = recordObj['_cellEditUrls'];
3714
- const cellDisabledMap = recordObj['_cellDisabled'];
3715
- const editUrl = editableMap?.[name] ? editUrlMap?.[name] : undefined;
3716
- const EditableComp = editUrl !== undefined
3717
- ? pickEditableCell(String(col['columnType'] ?? 'text'))
3718
- : null;
3719
- if (EditableComp && editUrl !== undefined) {
3720
- const cellDisabled = col['disabled'] === true || cellDisabledMap?.[name] === true;
3721
- const cellSelectOptionsMap = recordObj['_cellSelectOptions'];
3722
- const rowOptions = cellSelectOptionsMap?.[name];
3723
- return (_jsx(TableCell, { className: `text-sm text-foreground ${align} p-0`, style: widthStyle, children: _jsx(EditableComp, { url: editUrl, col: col, value: value, disabled: cellDisabled, ...(rowOptions ? { rowOptions } : {}) }) }, ci));
3724
- }
3725
- const cellContent = formatCell(value, col, recordObj);
3726
- const colUrl = resolveColumnUrl(col, tableUrl, colUrls);
3727
- return (_jsx(TableCell, { className: `text-sm text-foreground ${align} p-0`, style: widthStyle, children: colUrl !== undefined
3728
- ? _jsx(RecordCellLink, { href: colUrl, navigate: navigate, children: cellContent })
3729
- : _jsx("div", { className: "px-2 py-2", children: cellContent }) }, ci));
3730
- }), hasRowActions && (_jsx(TableCell, { className: "w-px text-right", children: renderRowActions(id, recordObj, rowActions) }))] })), (() => {
3731
- if (!groupSummaries)
3732
- return null;
3733
- if (groupValue === undefined)
3734
- return null;
3735
- if (isInCollapsedGroup)
3736
- return null;
3737
- const isLastInGroup = ri === rows.length - 1
3738
- || String((rows[ri + 1]['_groupValue'] ?? '')) !== groupValue;
3739
- if (!isLastInGroup)
3740
- return null;
3741
- const perCol = groupSummaries[groupValue];
3742
- if (!perCol || Object.keys(perCol).length === 0)
3743
- return null;
3744
- return (_jsxs(TableRow, { className: "bg-muted/20 hover:bg-muted/20", children: [reorderColumnVisible && _jsx(TableCell, {}), hasBulkActions && _jsx(TableCell, {}), visibleColumns.map((col, ci) => {
3745
- const name = String(col['name'] ?? '');
3746
- const align = col['alignment'] === 'center' ? 'text-center'
3747
- : col['alignment'] === 'end' ? 'text-right'
3748
- : 'text-left';
3749
- const items = perCol[name];
3750
- return (_jsx(TableCell, { className: `text-xs font-medium ${align} px-2 py-1.5`, children: items?.map((s, i) => (_jsxs("div", { className: "leading-tight", children: [s.label && _jsxs("span", { className: "text-muted-foreground", children: [s.label, ": "] }), _jsx("span", { children: s.value })] }, i))) }, ci));
3751
- }), hasRowActions && _jsx(TableCell, {})] }, `group-summary-${id}`));
3752
- })()] }, id));
3753
- }) }), summaries && Object.keys(summaries).length > 0 && (_jsx(TableFooter, { children: _jsxs(TableRow, { children: [reorderColumnVisible && _jsx(TableCell, {}), hasBulkActions && _jsx(TableCell, {}), visibleColumns.map((col, ci) => {
3754
- const name = String(col['name'] ?? '');
3755
- const align = col['alignment'] === 'center' ? 'text-center'
3756
- : col['alignment'] === 'end' ? 'text-right'
3757
- : 'text-left';
3758
- const items = summaries[name];
3759
- return (_jsx(TableCell, { className: `text-sm font-medium ${align}`, children: items?.map((s, i) => (_jsxs("div", { className: "leading-tight", children: [s.label && _jsxs("span", { className: "text-muted-foreground", children: [s.label, ": "] }), _jsx("span", { children: s.value })] }, i))) }, ci));
3760
- }), hasRowActions && _jsx(TableCell, {})] }) }))] }) })), showPagination && (_jsxs("div", { className: "flex items-center justify-between text-sm text-muted-foreground", children: [_jsxs("span", { children: ["Page ", currentPage, " of ", totalPages, total > 0 ? ` · ${total} record${total === 1 ? '' : 's'}` : ''] }), _jsxs("div", { className: "flex items-center gap-2", children: [currentPage > 1 && (_jsx("a", { href: buildTableQuery(state, { page: currentPage - 1 }, currentPath, activeFilters, queryPrefix), className: "rounded-md border px-3 py-1 text-xs hover:bg-muted", children: "\u2190 Previous" })), currentPage < totalPages && (_jsx("a", { href: buildTableQuery(state, { page: currentPage + 1 }, currentPath, activeFilters, queryPrefix), className: "rounded-md border px-3 py-1 text-xs hover:bg-muted", children: "Next \u2192" }))] })] })), hasFilters && filtersBelow && (_jsx(FilterStrip, { filters: filters, prefix: queryPrefix }))] }));
3761
- }
3762
- /**
3763
- * Card-grid body for `Table.contentLayout('cards')`. Renders the rows
3764
- * area only — the surrounding chrome (heading / search / filters /
3765
- * pagination / bulk-action toolbar / "Sort by" picker) lives in the
3766
- * parent `TableRendererBody` so both layouts share it.
3767
- *
3768
- * Each card renders its `_cardChildren` schema via the standard
3769
- * `renderElement` walker, so any display-Element (Heading, Text, Image,
3770
- * Icon, Badge entries, layout primitives, etc.) drops in without a new
3771
- * renderer. Per-row chrome attaches via the same `_recordUrl` /
3772
- * `_recordClasses` / `_visibleActions` / `_disabledActions` keys the
3773
- * table-mode renderer reads from — `loadTableRecords` is unchanged.
3774
- *
3775
- * Group banding splits the rows into contiguous sections by
3776
- * `_groupValue`, emitting a heading row above each section. The user's
3777
- * configured per-card grid (`cardsPerRow`) re-applies inside every
3778
- * section so the column count stays consistent.
3779
- */
3780
- function CardsLayoutBody({ el, columns, rows, visibleIds, selected, toggleRow, hasBulkActions, hasRowActions, rowActions, hasRecordUrl, hasRecordClasses, striped, activeEmpty, EmptyIcon, hasFilterOrSearch, defaultGroup, groupColumnLabel, groupCollapsible, collapsedGroups, toggleGroupCollapsed, cardsPerRow, navigate, groupHeadingScopable, buildGroupKeyHref, }) {
3781
- void el; // keep prop for future telemetry; silences unused-prop lint
3782
- void columns;
3783
- void striped; // visual stripes don't apply to cards (each card has its own surface)
3784
- const gridClass = `grid gap-4 ${cardsPerRowClasses(cardsPerRow)}`;
3785
- if (rows.length === 0) {
3786
- return (_jsx("div", { className: "rounded-xl border bg-card py-12 text-center", children: _jsxs("div", { className: "flex flex-col items-center gap-2 text-muted-foreground", children: [_jsx(EmptyIcon, { className: "size-8 opacity-60" }), _jsx("p", { className: "text-base font-medium text-foreground", children: activeEmpty?.heading
3787
- ?? (hasFilterOrSearch ? 'No matching records' : 'No records yet') }), (activeEmpty?.description ||
3788
- (hasFilterOrSearch && !activeEmpty?.description)) && (_jsx("p", { className: "text-sm", children: activeEmpty?.description
3789
- ?? 'Try clearing filters or adjusting your search.' }))] }) }));
3790
- }
3791
- const sections = [];
3792
- if (defaultGroup === undefined) {
3793
- sections.push({ indices: rows.map((_, i) => i) });
3794
- }
3795
- else {
3796
- let current;
3797
- for (let i = 0; i < rows.length; i++) {
3798
- const r = rows[i];
3799
- const v = String(r['_groupValue'] ?? '');
3800
- if (current === undefined || current.groupValue !== v) {
3801
- const title = r['_groupTitle'];
3802
- const description = r['_groupDescription'];
3803
- current = { groupValue: v, indices: [], ...(title ? { title } : {}), ...(description ? { description } : {}) };
3804
- sections.push(current);
3805
- }
3806
- current.indices.push(i);
3807
- }
3808
- }
3809
- return (_jsx("div", { className: "flex flex-col gap-4", children: sections.map((section, si) => {
3810
- const collapsed = groupCollapsible
3811
- && section.groupValue !== undefined
3812
- && collapsedGroups[section.groupValue] === true;
3813
- return (_jsxs("div", { className: "flex flex-col gap-3", children: [section.groupValue !== undefined && (() => {
3814
- const drillable = groupHeadingScopable === true
3815
- && buildGroupKeyHref !== undefined
3816
- && section.groupValue !== '';
3817
- const headingText = (_jsx(GroupHeaderText, { label: groupColumnLabel, value: section.groupValue, title: section.title, description: section.description }));
3818
- const headingNode = drillable
3819
- ? _jsx(GroupHeadingLink, { href: buildGroupKeyHref(section.groupValue), navigate: navigate, children: headingText })
3820
- : headingText;
3821
- if (groupCollapsible) {
3822
- return (_jsxs("div", { className: "flex w-full items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: [_jsx("button", { type: "button", className: "inline-flex items-center", onClick: () => toggleGroupCollapsed(section.groupValue), "aria-expanded": !collapsed, "aria-label": collapsed ? 'Expand group' : 'Collapse group', children: _jsx(ChevronDownIcon, { className: [
3823
- 'size-4 transition-transform',
3824
- collapsed ? '-rotate-90' : '',
3825
- ].filter(Boolean).join(' ') }) }), headingNode] }));
3826
- }
3827
- return (_jsx("div", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: headingNode }));
3828
- })(), !collapsed && (_jsx("div", { className: gridClass, children: section.indices.map((ri) => {
3829
- const id = visibleIds[ri];
3830
- const recordObj = rows[ri];
3831
- const isSelected = selected.has(id);
3832
- const recordUrl = hasRecordUrl ? recordObj['_recordUrl'] : undefined;
3833
- const customRowClasses = hasRecordClasses
3834
- ? recordObj['_recordClasses'] ?? ''
3835
- : '';
3836
- const cardChildren = recordObj['_cardChildren'] ?? [];
3837
- const cardClassName = [
3838
- 'group relative flex flex-col gap-3 rounded-xl border bg-card p-4 transition-colors',
3839
- recordUrl ? 'hover:border-primary/40 hover:bg-accent/30' : '',
3840
- isSelected ? 'border-primary ring-2 ring-primary/20' : '',
3841
- customRowClasses,
3842
- ].filter(Boolean).join(' ');
3843
- const onLinkClick = (e) => {
3844
- if (e.button !== 0)
3845
- return;
3846
- if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
3847
- return;
3848
- e.preventDefault();
3849
- if (recordUrl)
3850
- void navigate(recordUrl);
3851
- };
3852
- return (_jsxs("div", { className: cardClassName, children: [recordUrl !== undefined && (_jsx("a", { href: recordUrl, onClick: onLinkClick, "aria-label": "Open record", className: "absolute inset-0 z-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: _jsx("span", { className: "sr-only", children: "Open record" }) })), hasBulkActions && (_jsx("div", { className: "absolute top-3 right-3 z-10", children: _jsx(Checkbox, { "aria-label": `Select row ${id}`, checked: isSelected, onCheckedChange: () => toggleRow(id), "data-no-row-nav": true }) })), _jsx("div", { className: "relative z-[1] flex flex-col gap-3", children: cardChildren.length === 0 ? (_jsx("div", { className: "text-xs italic text-muted-foreground", children: "No card content configured." })) : cardChildren.map((c, i) => renderElement(c, i)) }), hasRowActions && (_jsx("div", { className: "relative z-10 mt-auto flex items-center justify-end pt-2 border-t border-border/60", children: renderRowActions(id, recordObj, rowActions) }))] }, id));
3853
- }) }))] }, si));
3854
- }) }));
278
+ return _jsx(Icon, { className: "size-4 inline", "aria-hidden": "true" });
3855
279
  }
3856
280
  export function SchemaRenderer({ elements, widgetData }) {
3857
281
  if (!elements || elements.length === 0)