@pilotiq/pilotiq 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (367) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +142 -0
  3. package/CLAUDE.md +59 -3
  4. package/dist/Pilotiq.d.ts +83 -0
  5. package/dist/Pilotiq.d.ts.map +1 -1
  6. package/dist/Pilotiq.js +39 -0
  7. package/dist/Pilotiq.js.map +1 -1
  8. package/dist/actions/Action.d.ts +27 -99
  9. package/dist/actions/Action.d.ts.map +1 -1
  10. package/dist/actions/Action.js +52 -754
  11. package/dist/actions/Action.js.map +1 -1
  12. package/dist/actions/bulkFactories.d.ts +46 -0
  13. package/dist/actions/bulkFactories.d.ts.map +1 -0
  14. package/dist/actions/bulkFactories.js +144 -0
  15. package/dist/actions/bulkFactories.js.map +1 -0
  16. package/dist/actions/crudFactories.d.ts +94 -0
  17. package/dist/actions/crudFactories.d.ts.map +1 -0
  18. package/dist/actions/crudFactories.js +209 -0
  19. package/dist/actions/crudFactories.js.map +1 -0
  20. package/dist/actions/factoryHelpers.d.ts +108 -0
  21. package/dist/actions/factoryHelpers.d.ts.map +1 -0
  22. package/dist/actions/factoryHelpers.js +138 -0
  23. package/dist/actions/factoryHelpers.js.map +1 -0
  24. package/dist/actions/m2mFactories.d.ts +47 -0
  25. package/dist/actions/m2mFactories.d.ts.map +1 -0
  26. package/dist/actions/m2mFactories.js +173 -0
  27. package/dist/actions/m2mFactories.js.map +1 -0
  28. package/dist/actions/relationFactories.d.ts +93 -0
  29. package/dist/actions/relationFactories.d.ts.map +1 -0
  30. package/dist/actions/relationFactories.js +321 -0
  31. package/dist/actions/relationFactories.js.map +1 -0
  32. package/dist/elements/dispatchForm.js +1 -1
  33. package/dist/elements/dispatchForm.js.map +1 -1
  34. package/dist/elements/dispatchTable.js +1 -1
  35. package/dist/elements/dispatchTable.js.map +1 -1
  36. package/dist/fields/Field.d.ts +31 -0
  37. package/dist/fields/Field.d.ts.map +1 -1
  38. package/dist/fields/Field.js +25 -0
  39. package/dist/fields/Field.js.map +1 -1
  40. package/dist/pageData/breadcrumbs.d.ts +42 -0
  41. package/dist/pageData/breadcrumbs.d.ts.map +1 -0
  42. package/dist/pageData/breadcrumbs.js +172 -0
  43. package/dist/pageData/breadcrumbs.js.map +1 -0
  44. package/dist/pageData/forms.d.ts +137 -0
  45. package/dist/pageData/forms.d.ts.map +1 -0
  46. package/dist/pageData/forms.js +427 -0
  47. package/dist/pageData/forms.js.map +1 -0
  48. package/dist/pageData/helpers.d.ts +239 -0
  49. package/dist/pageData/helpers.d.ts.map +1 -0
  50. package/dist/pageData/helpers.js +703 -0
  51. package/dist/pageData/helpers.js.map +1 -0
  52. package/dist/pageData/misc.d.ts +76 -0
  53. package/dist/pageData/misc.d.ts.map +1 -0
  54. package/dist/pageData/misc.js +263 -0
  55. package/dist/pageData/misc.js.map +1 -0
  56. package/dist/pageData/navigation.d.ts +292 -0
  57. package/dist/pageData/navigation.d.ts.map +1 -0
  58. package/dist/pageData/navigation.js +591 -0
  59. package/dist/pageData/navigation.js.map +1 -0
  60. package/dist/pageData/relationPages.d.ts +172 -0
  61. package/dist/pageData/relationPages.d.ts.map +1 -0
  62. package/dist/pageData/relationPages.js +867 -0
  63. package/dist/pageData/relationPages.js.map +1 -0
  64. package/dist/pageData/relationTabs.d.ts +65 -0
  65. package/dist/pageData/relationTabs.d.ts.map +1 -0
  66. package/dist/pageData/relationTabs.js +258 -0
  67. package/dist/pageData/relationTabs.js.map +1 -0
  68. package/dist/pageData/resourcePages.d.ts +48 -0
  69. package/dist/pageData/resourcePages.d.ts.map +1 -0
  70. package/dist/pageData/resourcePages.js +504 -0
  71. package/dist/pageData/resourcePages.js.map +1 -0
  72. package/dist/pageData.d.ts +12 -792
  73. package/dist/pageData.d.ts.map +1 -1
  74. package/dist/pageData.js +24 -3797
  75. package/dist/pageData.js.map +1 -1
  76. package/dist/react/AppShell.d.ts +8 -0
  77. package/dist/react/AppShell.d.ts.map +1 -1
  78. package/dist/react/AppShell.js +11 -1
  79. package/dist/react/AppShell.js.map +1 -1
  80. package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
  81. package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
  82. package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
  83. package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
  84. package/dist/react/CollabRoomContext.d.ts +37 -0
  85. package/dist/react/CollabRoomContext.d.ts.map +1 -0
  86. package/dist/react/CollabRoomContext.js +12 -0
  87. package/dist/react/CollabRoomContext.js.map +1 -0
  88. package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
  89. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
  90. package/dist/react/FormCollabBindingRegistry.js +14 -0
  91. package/dist/react/FormCollabBindingRegistry.js.map +1 -0
  92. package/dist/react/RecordWrapperGate.d.ts +25 -0
  93. package/dist/react/RecordWrapperGate.d.ts.map +1 -0
  94. package/dist/react/RecordWrapperGate.js +30 -0
  95. package/dist/react/RecordWrapperGate.js.map +1 -0
  96. package/dist/react/RecordWrapperRegistry.d.ts +31 -0
  97. package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
  98. package/dist/react/RecordWrapperRegistry.js +15 -0
  99. package/dist/react/RecordWrapperRegistry.js.map +1 -0
  100. package/dist/react/SchemaRenderer.d.ts +17 -23
  101. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  102. package/dist/react/SchemaRenderer.js +71 -3647
  103. package/dist/react/SchemaRenderer.js.map +1 -1
  104. package/dist/react/component-slots.d.ts +103 -0
  105. package/dist/react/component-slots.d.ts.map +1 -0
  106. package/dist/react/component-slots.js +18 -0
  107. package/dist/react/component-slots.js.map +1 -0
  108. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  109. package/dist/react/fields/BuilderInput.js +21 -117
  110. package/dist/react/fields/BuilderInput.js.map +1 -1
  111. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  112. package/dist/react/fields/MarkdownInput.js +1 -3
  113. package/dist/react/fields/MarkdownInput.js.map +1 -1
  114. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  115. package/dist/react/fields/RepeaterInput.js +22 -127
  116. package/dist/react/fields/RepeaterInput.js.map +1 -1
  117. package/dist/react/fields/rowState.d.ts +40 -0
  118. package/dist/react/fields/rowState.d.ts.map +1 -0
  119. package/dist/react/fields/rowState.js +60 -0
  120. package/dist/react/fields/rowState.js.map +1 -0
  121. package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
  122. package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
  123. package/dist/react/fields/useRowReorderDnd.js +51 -0
  124. package/dist/react/fields/useRowReorderDnd.js.map +1 -0
  125. package/dist/react/index.d.ts +9 -0
  126. package/dist/react/index.d.ts.map +1 -1
  127. package/dist/react/index.js +8 -0
  128. package/dist/react/index.js.map +1 -1
  129. package/dist/react/layouts/SidebarLayout.d.ts +1 -1
  130. package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
  131. package/dist/react/layouts/SidebarLayout.js +10 -2
  132. package/dist/react/layouts/SidebarLayout.js.map +1 -1
  133. package/dist/react/layouts/TopbarLayout.d.ts +1 -1
  134. package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
  135. package/dist/react/layouts/TopbarLayout.js +19 -11
  136. package/dist/react/layouts/TopbarLayout.js.map +1 -1
  137. package/dist/react/parseRecordEditUrl.d.ts +29 -0
  138. package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
  139. package/dist/react/parseRecordEditUrl.js +25 -0
  140. package/dist/react/parseRecordEditUrl.js.map +1 -0
  141. package/dist/react/persistedState.d.ts +19 -0
  142. package/dist/react/persistedState.d.ts.map +1 -0
  143. package/dist/react/persistedState.js +51 -0
  144. package/dist/react/persistedState.js.map +1 -0
  145. package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
  146. package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
  147. package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
  148. package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
  149. package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
  150. package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
  151. package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
  152. package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
  153. package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
  154. package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
  155. package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
  156. package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
  157. package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
  158. package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
  159. package/dist/react/schemaRenderer/SimpleElements.js +147 -0
  160. package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
  161. package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
  162. package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
  163. package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
  164. package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
  165. package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
  166. package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
  167. package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
  168. package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
  169. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
  170. package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
  171. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
  172. package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
  173. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
  174. package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
  175. package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
  176. package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
  177. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
  178. package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
  179. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
  180. package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
  181. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
  182. package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
  183. package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
  184. package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
  185. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
  186. package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
  187. package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
  188. package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
  189. package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
  190. package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
  191. package/dist/react/schemaRenderer/action/buttons.js +74 -0
  192. package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
  193. package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
  194. package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
  195. package/dist/react/schemaRenderer/action/helpers.js +126 -0
  196. package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
  197. package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
  198. package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
  199. package/dist/react/schemaRenderer/action/renderAction.js +102 -0
  200. package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
  201. package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
  202. package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
  203. package/dist/react/schemaRenderer/columnFormat.js +76 -0
  204. package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
  205. package/dist/react/schemaRenderer/constants.d.ts +8 -0
  206. package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
  207. package/dist/react/schemaRenderer/constants.js +45 -0
  208. package/dist/react/schemaRenderer/constants.js.map +1 -0
  209. package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
  210. package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
  211. package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
  212. package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
  213. package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
  214. package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
  215. package/dist/react/schemaRenderer/form/renderField.js +239 -0
  216. package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
  217. package/dist/react/schemaRenderer/helpers.d.ts +32 -0
  218. package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
  219. package/dist/react/schemaRenderer/helpers.js +52 -0
  220. package/dist/react/schemaRenderer/helpers.js.map +1 -0
  221. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
  222. package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
  223. package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
  224. package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
  225. package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
  226. package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
  227. package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
  228. package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
  229. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
  230. package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
  231. package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
  232. package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
  233. package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
  234. package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
  235. package/dist/react/schemaRenderer/table/filters.js +497 -0
  236. package/dist/react/schemaRenderer/table/filters.js.map +1 -0
  237. package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
  238. package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
  239. package/dist/react/schemaRenderer/table/formatCell.js +172 -0
  240. package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
  241. package/dist/react/schemaRenderer/table/links.d.ts +42 -0
  242. package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
  243. package/dist/react/schemaRenderer/table/links.js +55 -0
  244. package/dist/react/schemaRenderer/table/links.js.map +1 -0
  245. package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
  246. package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
  247. package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
  248. package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
  249. package/dist/react/schemaRenderer/table/url.d.ts +41 -0
  250. package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
  251. package/dist/react/schemaRenderer/table/url.js +114 -0
  252. package/dist/react/schemaRenderer/table/url.js.map +1 -0
  253. package/dist/routes/globals.d.ts +13 -0
  254. package/dist/routes/globals.d.ts.map +1 -0
  255. package/dist/routes/globals.js +131 -0
  256. package/dist/routes/globals.js.map +1 -0
  257. package/dist/routes/helpers.d.ts +217 -0
  258. package/dist/routes/helpers.d.ts.map +1 -0
  259. package/dist/routes/helpers.js +498 -0
  260. package/dist/routes/helpers.js.map +1 -0
  261. package/dist/routes/pages.d.ts +15 -0
  262. package/dist/routes/pages.d.ts.map +1 -0
  263. package/dist/routes/pages.js +145 -0
  264. package/dist/routes/pages.js.map +1 -0
  265. package/dist/routes/panel.d.ts +19 -0
  266. package/dist/routes/panel.d.ts.map +1 -0
  267. package/dist/routes/panel.js +191 -0
  268. package/dist/routes/panel.js.map +1 -0
  269. package/dist/routes/relations.d.ts +21 -0
  270. package/dist/routes/relations.d.ts.map +1 -0
  271. package/dist/routes/relations.js +1239 -0
  272. package/dist/routes/relations.js.map +1 -0
  273. package/dist/routes/resources.d.ts +28 -0
  274. package/dist/routes/resources.d.ts.map +1 -0
  275. package/dist/routes/resources.js +741 -0
  276. package/dist/routes/resources.js.map +1 -0
  277. package/dist/routes/theme.d.ts +12 -0
  278. package/dist/routes/theme.d.ts.map +1 -0
  279. package/dist/routes/theme.js +82 -0
  280. package/dist/routes/theme.js.map +1 -0
  281. package/dist/routes.d.ts.map +1 -1
  282. package/dist/routes.js +64 -3078
  283. package/dist/routes.js.map +1 -1
  284. package/dist/vite.d.ts +1 -0
  285. package/dist/vite.d.ts.map +1 -1
  286. package/dist/vite.js +26 -5
  287. package/dist/vite.js.map +1 -1
  288. package/package.json +2 -1
  289. package/src/Pilotiq.ts +95 -0
  290. package/src/actions/Action.ts +79 -723
  291. package/src/actions/bulkFactories.ts +168 -0
  292. package/src/actions/crudFactories.ts +220 -0
  293. package/src/actions/factoryHelpers.ts +177 -0
  294. package/src/actions/m2mFactories.ts +193 -0
  295. package/src/actions/relationFactories.ts +372 -0
  296. package/src/elements/dispatchForm.ts +1 -1
  297. package/src/elements/dispatchTable.ts +1 -1
  298. package/src/fields/Field.ts +39 -0
  299. package/src/pageData/breadcrumbs.ts +288 -0
  300. package/src/pageData/forms.ts +578 -0
  301. package/src/pageData/helpers.ts +764 -0
  302. package/src/pageData/misc.ts +347 -0
  303. package/src/pageData/navigation.ts +779 -0
  304. package/src/pageData/relationPages.ts +1246 -0
  305. package/src/pageData/relationTabs.ts +286 -0
  306. package/src/pageData/resourcePages.ts +593 -0
  307. package/src/pageData.ts +122 -4731
  308. package/src/react/AppShell.tsx +27 -1
  309. package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
  310. package/src/react/CollabRoomContext.ts +42 -0
  311. package/src/react/FormCollabBindingRegistry.ts +72 -0
  312. package/src/react/RecordWrapperGate.tsx +40 -0
  313. package/src/react/RecordWrapperRegistry.ts +39 -0
  314. package/src/react/SchemaRenderer.tsx +230 -6479
  315. package/src/react/component-slots.test.ts +103 -0
  316. package/src/react/component-slots.ts +116 -0
  317. package/src/react/fields/BuilderInput.tsx +29 -117
  318. package/src/react/fields/MarkdownInput.tsx +0 -1
  319. package/src/react/fields/RepeaterInput.tsx +29 -130
  320. package/src/react/fields/rowState.ts +106 -0
  321. package/src/react/fields/useRowReorderDnd.ts +78 -0
  322. package/src/react/index.ts +38 -0
  323. package/src/react/layouts/SidebarLayout.tsx +39 -28
  324. package/src/react/layouts/TopbarLayout.tsx +70 -57
  325. package/src/react/parseRecordEditUrl.test.ts +75 -0
  326. package/src/react/parseRecordEditUrl.ts +55 -0
  327. package/src/react/persistedState.ts +40 -0
  328. package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
  329. package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
  330. package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
  331. package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
  332. package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
  333. package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
  334. package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
  335. package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
  336. package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
  337. package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
  338. package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
  339. package/src/react/schemaRenderer/action/buttons.tsx +99 -0
  340. package/src/react/schemaRenderer/action/helpers.ts +140 -0
  341. package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
  342. package/src/react/schemaRenderer/columnFormat.ts +65 -0
  343. package/src/react/schemaRenderer/constants.ts +50 -0
  344. package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
  345. package/src/react/schemaRenderer/form/renderField.tsx +511 -0
  346. package/src/react/schemaRenderer/helpers.tsx +81 -0
  347. package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
  348. package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
  349. package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
  350. package/src/react/schemaRenderer/table/filters.tsx +1233 -0
  351. package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
  352. package/src/react/schemaRenderer/table/links.tsx +112 -0
  353. package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
  354. package/src/react/schemaRenderer/table/url.tsx +143 -0
  355. package/src/routes/globals.ts +154 -0
  356. package/src/routes/helpers.ts +668 -0
  357. package/src/routes/pages.ts +173 -0
  358. package/src/routes/panel.ts +204 -0
  359. package/src/routes/relations.ts +1219 -0
  360. package/src/routes/resources.ts +786 -0
  361. package/src/routes/theme.ts +109 -0
  362. package/src/routes.test.ts +1 -1
  363. package/src/routes.ts +64 -3176
  364. package/src/schema/TableWidget.test.ts +2 -2
  365. package/src/theme/migrate.test.ts +178 -0
  366. package/src/vite.test.ts +184 -0
  367. package/src/vite.ts +26 -4
@@ -1,180 +1,10 @@
1
1
  import { Element } from '../schema/Element.js';
2
- import { safeManagerPolicy, } from '../RelationManager.js';
3
- import { computeMorphPayload, getMorphRelationDescriptor, getParentRelationDescriptor, } from '../orm/modelDefaults.js';
4
- import { resolveM2MAccessor } from '../orm/m2mAccessor.js';
5
2
  import { buildImportSchema as buildImportModalSchema } from './importFactory.js';
6
- import { buildAttachModalSchema } from './attachFactory.js';
7
- /** Cluster-aware resource base path. Mirrors `clusterPaths.resourceBasePath`
8
- * but uses the structural `ResourceLike` shape so `Action.ts` stays
9
- * cycle-free against `Resource.ts`. */
10
- function resourceBase(basePath, R) {
11
- if (R.cluster)
12
- return `${basePath}/${R.cluster.getSlug()}/${R.getSlug()}`;
13
- return `${basePath}/${R.getSlug()}`;
14
- }
15
- /** Pick the right label form for a count — `labelSingular` for 1,
16
- * `label` (plural, lowercased) for any other count. Fall back to a
17
- * naive `${labelSingular}s` when no plural label is set. Used by bulk
18
- * notification copy so we don't ship "1 posts moved to trash". */
19
- function labelForCount(R, n) {
20
- if (n === 1)
21
- return R.labelSingular.toLowerCase();
22
- const plural = R.label?.toLowerCase();
23
- return plural ?? `${R.labelSingular.toLowerCase()}s`;
24
- }
25
- /** True when a `RelationManagerContext.mode` denotes a pivot-mutation
26
- * shape — i.e. a many-to-many relation. All three modes share the
27
- * `attach` / `detach` / `sync` accessor surface (the rudder ORM stamps
28
- * + filters the polymorphic discriminator transparently for the morph
29
- * variants). The `relationCreate / Edit / Delete` factories auto-hide
30
- * under any of these modes because per-pivot-row create / edit / delete
31
- * is meaningless — users create the related record via its own Resource,
32
- * then attach via `relationAttach`. */
33
- function isM2MMode(mode) {
34
- return mode === 'belongsToMany' || mode === 'morphToMany' || mode === 'morphedByMany';
35
- }
36
- /**
37
- * Phase B — build the URL prefix for a relation factory action. Without
38
- * a `chain` (depth-1 manager), this is the familiar
39
- * `${base}/${parentSlug}/${parentId}/${relationship}`. With a chain
40
- * (depth-2 nested manager), it threads the outer record + relationship
41
- * between the parent slug and the leaf parent id:
42
- *
43
- * `${base}/${parentSlug}/${chain[0].recordId}/${chain[0].relationship}/${parentId}/${relationship}`
44
- *
45
- * Pure; takes a `RelationManagerContext` and emits a string. The leaf
46
- * record id (and trailing `/edit`, `/delete`, etc.) gets appended by
47
- * the caller.
48
- */
49
- function relationUrlPrefix(ctx) {
50
- const head = `${ctx.basePath}/${ctx.parentSlug}`;
51
- const chain = ctx.chain ?? [];
52
- let mid = '';
53
- for (const step of chain) {
54
- mid += `/${step.recordId}/${step.relationship}`;
55
- }
56
- return `${head}${mid}/${ctx.parentId}/${ctx.relationship}`;
57
- }
58
- /**
59
- * Compute the parent-attachment payload to force-pin onto a relation
60
- * replica. For `hasMany`, returns `{ [foreignKey]: parentId }` from the
61
- * parent's `static relations[name]` descriptor. For `morphMany` /
62
- * `morphOne`, returns `{ <morphName>Id, <morphName>Type }` via
63
- * `computeMorphPayload(parentRecord)`. Returns `{}` when no descriptor
64
- * matches — the route dispatcher already auto-hides under M2M / morphTo,
65
- * so missing descriptors there are a no-op rather than an error. Pure;
66
- * exported for tests and re-used by both factories.
67
- */
68
- function computeRelationPin(ctx) {
69
- const parentModel = ctx.parentRecord?.constructor;
70
- if (!parentModel)
71
- return {};
72
- const rel = ctx.relationship;
73
- // Polymorphic owner side first — `morphMany` carries no foreignKey
74
- // and would fail the hasMany descriptor's gate.
75
- if (ctx.mode === 'morphMany') {
76
- const morph = getMorphRelationDescriptor(parentModel, rel);
77
- if (!morph)
78
- return {};
79
- try {
80
- return computeMorphPayload(ctx.parentRecord, morph);
81
- }
82
- catch {
83
- return {};
84
- }
85
- }
86
- const desc = getParentRelationDescriptor(parentModel, rel);
87
- if (!desc)
88
- return {};
89
- return { [desc.foreignKey]: ctx.parentId };
90
- }
91
- /**
92
- * Build + persist a single relation replica. Runs the strip set
93
- * (PK + soft-delete column on the **related** Resource +
94
- * `opts.excludeAttributes`), force-pins the parent attachment columns,
95
- * runs the optional `beforeReplicaSaved` hook, and calls
96
- * `Related.model.create(...)`. Returns the model's create result so
97
- * callers can read its primary key for redirect targeting.
98
- *
99
- * Throws when the related Resource has no model — caller (single-row
100
- * factory) catches and surfaces an error notification; bulk caller
101
- * checks the model presence ahead of the loop.
102
- */
103
- async function persistRelationReplica(_M, ctx, source, opts) {
104
- const Related = ctx.related;
105
- if (!Related?.model || typeof Related.model.create !== 'function') {
106
- throw new Error('Related Resource has no model.create');
107
- }
108
- const M2 = Related.model;
109
- const pkCol = M2.primaryKey ?? 'id';
110
- const trashedCol = Related.deletedAtColumn ?? 'deletedAt';
111
- const skip = new Set([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])]);
112
- let replica = {};
113
- for (const [k, v] of Object.entries(source)) {
114
- if (skip.has(k))
115
- continue;
116
- replica[k] = v;
117
- }
118
- // Force-pin the parent attachment AFTER the strip but BEFORE the
119
- // user mutator, so `beforeReplicaSaved` can read / override the FK
120
- // if it really wants to (rare). Tampered source rows can't slip a
121
- // different parent in by riding their own FK column — the pin
122
- // overwrites whatever value was there.
123
- Object.assign(replica, computeRelationPin(ctx));
124
- if (opts.beforeReplicaSaved) {
125
- replica = await opts.beforeReplicaSaved(replica, source);
126
- }
127
- return M2.create(replica);
128
- }
129
- /**
130
- * Single-row dispatch for `Action.relationReplicate`. Resolves
131
- * `ctx.record` (loaded by the route's resolveRecord hook), validates,
132
- * persists the replica, and shapes the success notification. Errors
133
- * are caught and surfaced as error toasts.
134
- */
135
- async function runRelationReplicateRow(M, ctx, hctx, opts) {
136
- const source = hctx.record;
137
- if (!source || typeof source !== 'object') {
138
- return { notify: { title: 'Replicate failed: source record missing', type: 'error' } };
139
- }
140
- const Related = ctx.related;
141
- if (!Related?.model || typeof Related.model.create !== 'function') {
142
- return { notify: { title: 'Replicate not configured (related Resource has no model.create)', type: 'error' } };
143
- }
144
- let created;
145
- try {
146
- created = await persistRelationReplica(M, ctx, source, opts);
147
- }
148
- catch (err) {
149
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } };
150
- }
151
- const overrideTitle = opts.getCreatedNotificationTitle
152
- ? await opts.getCreatedNotificationTitle({ replica: created, source })
153
- : undefined;
154
- const title = overrideTitle !== undefined ? overrideTitle : `${M.getLabelSingular()} replicated`;
155
- // The manager-scoped `_action/:actionName` route falls back to the
156
- // manager list URL when `result.redirect` is undefined, so we only
157
- // emit `redirect` when the user override returned a string. That
158
- // way default behavior (route owns the fallback) is unchanged.
159
- const overrideRedirect = opts.getRedirectUrl
160
- ? await opts.getRedirectUrl({ replica: created, source })
161
- : undefined;
162
- return {
163
- ...(overrideRedirect !== undefined ? { redirect: overrideRedirect } : {}),
164
- notify: { title, type: 'success' },
165
- };
166
- }
167
- /** Read `record[R.deletedAtColumn ?? 'deletedAt']` and return true when
168
- * the row is currently trashed (soft-deleted). Permissive on shape —
169
- * bare `null` / `undefined` count as live; any other truthy value is
170
- * trashed. */
171
- function isTrashed(record, R) {
172
- if (!record || typeof record !== 'object')
173
- return false;
174
- const col = R.deletedAtColumn ?? 'deletedAt';
175
- const v = record[col];
176
- return v !== null && v !== undefined;
177
- }
3
+ import { callPredicate } from './factoryHelpers.js';
4
+ import { createAction, deleteAction, editAction, forceDeleteAction, markAsReadAction, replicateAction, restoreAction, viewAction, } from './crudFactories.js';
5
+ import { bulkDeleteAction, bulkForceDeleteAction, bulkReplicateAction, bulkRestoreAction, } from './bulkFactories.js';
6
+ import { relationBulkReplicateAction, relationCreateAction, relationDeleteAction, relationEditAction, relationForceDeleteAction, relationReplicateAction, relationRestoreAction, } from './relationFactories.js';
7
+ import { relationAttachAction, relationBulkDetachAction, relationDetachAction, } from './m2mFactories.js';
178
8
  /** Lazy-load the `Table` class for use inside Action handlers. Direct
179
9
  * module-level import would cycle (Table → Action → Table); dynamic
180
10
  * import inside a handler runs after both modules have finished
@@ -189,14 +19,6 @@ async function loadTableClass() {
189
19
  _TableClass = mod.Table;
190
20
  return _TableClass.make();
191
21
  }
192
- /** Call a (possibly undefined) Resource predicate. When unset, the
193
- * predicate is treated as "allowed" (returns true) so the factory
194
- * doesn't hide actions on Resources that haven't opted into Plan #10. */
195
- function callPredicate(fn, user, record) {
196
- if (!fn)
197
- return true;
198
- return fn(user, record);
199
- }
200
22
  /**
201
23
  * Action — a button-or-menu-entry that performs work when clicked.
202
24
  *
@@ -318,10 +140,7 @@ export class Action extends Element {
318
140
  /** Create-action factory — link to `${basePath}/${R.slug}/create`.
319
141
  * Auto-hides when `R.canCreate(user)` returns false. */
320
142
  static create(R, basePath) {
321
- return Action.make('create')
322
- .label(`New ${R.labelSingular}`)
323
- .href(`${resourceBase(basePath, R)}/create`)
324
- .visible(({ user }) => callPredicate(R.canCreate, user));
143
+ return createAction(R, basePath);
325
144
  }
326
145
  /**
327
146
  * Edit-action factory — link to the resource's edit page.
@@ -331,26 +150,15 @@ export class Action extends Element {
331
150
  * Omit `recordId` for row context (`Table.recordActions(...)`); the
332
151
  * URL keeps the `:id` template and the renderer substitutes per-row.
333
152
  *
334
- * Auto-hides when `R.canEdit(user, record)` returns false. For row
335
- * context the per-row record threads in via `loadTableRecords`'s
336
- * per-row eval; for view-page context, `resolveSchema` provides the
337
- * resolved record on the eval context.
153
+ * Auto-hides when `R.canEdit(user, record)` returns false.
338
154
  */
339
155
  static edit(R, basePath, recordId) {
340
- const id = recordId ?? ':id';
341
- return Action.make('edit')
342
- .label('Edit')
343
- .href(`${resourceBase(basePath, R)}/${id}/edit`)
344
- .visible(({ user, record }) => callPredicate(R.canEdit, user, record));
156
+ return editAction(R, basePath, recordId);
345
157
  }
346
158
  /** View-action factory — link to the resource's view page. See `Action.edit` for the `recordId` semantics.
347
159
  * Auto-hides when `R.canView(user, record)` returns false. */
348
160
  static view(R, basePath, recordId) {
349
- const id = recordId ?? ':id';
350
- return Action.make('view')
351
- .label('View')
352
- .href(`${resourceBase(basePath, R)}/${id}`)
353
- .visible(({ user, record }) => callPredicate(R.canView, user, record));
161
+ return viewAction(R, basePath, recordId);
354
162
  }
355
163
  /**
356
164
  * Delete-action factory — POSTs to the resource's delete route,
@@ -359,138 +167,36 @@ export class Action extends Element {
359
167
  * Auto-hides when `R.canDelete(user, record)` returns false.
360
168
  *
361
169
  * Plan #13 — when `R.softDeletes = true`, additionally hides on
362
- * rows whose `deletedAtColumn` is set (already-trashed rows get the
363
- * Restore + ForceDelete pair instead, surfaced via the matching
364
- * factories below).
170
+ * already-trashed rows (Restore + ForceDelete take over).
365
171
  */
366
172
  static delete(R, basePath, recordId) {
367
- const id = recordId ?? ':id';
368
- return Action.make('delete')
369
- .label('Delete')
370
- .destructive()
371
- .method('post')
372
- .action(`${resourceBase(basePath, R)}/${id}/delete`)
373
- .confirm(`Delete this ${R.labelSingular.toLowerCase()}?`)
374
- .visible(async ({ user, record }) => {
375
- if (R.softDeletes && isTrashed(record, R))
376
- return false;
377
- return callPredicate(R.canDelete, user, record);
378
- });
173
+ return deleteAction(R, basePath, recordId);
379
174
  }
380
175
  /**
381
- * Replicate-action factory — handler-style. Loads the source record
382
- * from `ctx.record` (the `_action/:actionName` route already resolves
383
- * it through `R.query(ctx)` for row + single-target placements),
384
- * strips PK + soft-delete column + any `opts.excludeAttributes`,
385
- * optionally runs `opts.beforeReplicaSaved`, and creates a new row
386
- * via `R.model.create(...)`. Redirects to the new record's edit page
387
- * on success so the user can review + tweak before saving again.
388
- *
389
- * `recordId` kept in the signature for parity with `delete / edit /
390
- * view` so users can swap factories without rewriting call sites; the
391
- * dispatcher resolves the source record from the URL and hands it to
392
- * the handler as `ctx.record`, so we don't reference `recordId` here.
393
- *
394
- * Auto-hides when `R.canCreate(user)` returns false — replicating
395
- * writes a new row, so the gate is `canCreate`, not `canView`.
176
+ * Replicate-action factory — handler-style. Strips PK + soft-delete
177
+ * column + `opts.excludeAttributes` from `ctx.record`, optionally
178
+ * runs `opts.beforeReplicaSaved`, and creates a new row via
179
+ * `R.model.create(...)`. Redirects to the new record's edit page
180
+ * on success. Auto-hides when `R.canCreate(user)` returns false.
396
181
  */
397
182
  static replicate(R, basePath, recordId, opts = {}) {
398
- void recordId;
399
- return Action.make('replicate')
400
- .label('Replicate')
401
- .handler(async (ctx) => {
402
- const source = ctx.record;
403
- if (!source || typeof source !== 'object') {
404
- return { notify: { title: 'Replicate failed: source record missing', type: 'error' } };
405
- }
406
- const M = R.model;
407
- if (!M || typeof M.create !== 'function') {
408
- return { notify: { title: 'Replicate not configured (resource has no model.create)', type: 'error' } };
409
- }
410
- const pkCol = M.primaryKey ?? 'id';
411
- const trashedCol = R.deletedAtColumn ?? 'deletedAt';
412
- const skip = new Set([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])]);
413
- let replica = {};
414
- for (const [k, v] of Object.entries(source)) {
415
- if (skip.has(k))
416
- continue;
417
- replica[k] = v;
418
- }
419
- if (opts.beforeReplicaSaved) {
420
- try {
421
- replica = await opts.beforeReplicaSaved(replica, source);
422
- }
423
- catch (err) {
424
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } };
425
- }
426
- }
427
- let created;
428
- try {
429
- created = await M.create(replica);
430
- }
431
- catch (err) {
432
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } };
433
- }
434
- const newId = created?.[pkCol];
435
- const defaultRedirect = newId !== undefined && newId !== null
436
- ? `${resourceBase(basePath, R)}/${String(newId)}/edit`
437
- : `${resourceBase(basePath, R)}`;
438
- // `!== undefined` rather than `??` so an override returning
439
- // `null`/empty-string isn't silently swallowed (see
440
- // feedback_nullish_swallows_explicit_null).
441
- const overrideRedirect = opts.getRedirectUrl
442
- ? await opts.getRedirectUrl({ replica: created, source })
443
- : undefined;
444
- const redirect = overrideRedirect !== undefined ? overrideRedirect : defaultRedirect;
445
- const overrideTitle = opts.getCreatedNotificationTitle
446
- ? await opts.getCreatedNotificationTitle({ replica: created, source })
447
- : undefined;
448
- const title = overrideTitle !== undefined ? overrideTitle : `${R.labelSingular} replicated`;
449
- return {
450
- redirect,
451
- notify: { title, type: 'success' },
452
- };
453
- })
454
- .visible(({ user }) => callPredicate(R.canCreate, user));
183
+ return replicateAction(R, basePath, recordId, opts);
455
184
  }
456
185
  /**
457
186
  * Plan #13 — Restore factory. POSTs to the resource's restore route,
458
- * success-styled, no confirm prompt (restoration is reversible).
459
- * Auto-hides on live (non-trashed) rows AND when `R.canRestore(user,
460
- * record)` returns false. Same `recordId` semantics as `Action.edit`.
187
+ * success-styled, no confirm prompt. Auto-hides on live (non-trashed)
188
+ * rows AND when `R.canRestore(user, record)` returns false.
461
189
  */
462
190
  static restore(R, basePath, recordId) {
463
- const id = recordId ?? ':id';
464
- return Action.make('restore')
465
- .label('Restore')
466
- .color('success')
467
- .method('post')
468
- .action(`${resourceBase(basePath, R)}/${id}/restore`)
469
- .visible(async ({ user, record }) => {
470
- if (!isTrashed(record, R))
471
- return false;
472
- return callPredicate(R.canRestore, user, record);
473
- });
191
+ return restoreAction(R, basePath, recordId);
474
192
  }
475
193
  /**
476
194
  * Plan #13 — Force-delete factory. POSTs to the resource's
477
195
  * force-delete route, destructive-styled, with a stricter confirm
478
- * prompt referencing permanence. Auto-hides on live (non-trashed)
479
- * rows AND when `R.canForceDelete(user, record)` returns false.
196
+ * prompt. Auto-hides on live rows + when `R.canForceDelete` denies.
480
197
  */
481
198
  static forceDelete(R, basePath, recordId) {
482
- const id = recordId ?? ':id';
483
- return Action.make('forceDelete')
484
- .label('Delete forever')
485
- .destructive()
486
- .method('post')
487
- .action(`${resourceBase(basePath, R)}/${id}/force-delete`)
488
- .confirm(`Permanently delete this ${R.labelSingular.toLowerCase()}? This cannot be undone.`)
489
- .visible(async ({ user, record }) => {
490
- if (!isTrashed(record, R))
491
- return false;
492
- return callPredicate(R.canForceDelete, user, record);
493
- });
199
+ return forceDeleteAction(R, basePath, recordId);
494
200
  }
495
201
  // ─── Notification factories ───────────────────────────────────
496
202
  //
@@ -520,11 +226,7 @@ export class Action extends Element {
520
226
  * to hide on already-read rows.
521
227
  */
522
228
  static markAsRead(basePath, notificationId) {
523
- const id = notificationId ?? ':id';
524
- return Action.make('markAsRead')
525
- .label('Mark as read')
526
- .method('post')
527
- .action(`${basePath}/_notifications/${id}/read`);
229
+ return markAsReadAction(basePath, notificationId);
528
230
  }
529
231
  // ─── Bulk factories (Plan #13) ────────────────────────────────
530
232
  //
@@ -539,163 +241,25 @@ export class Action extends Element {
539
241
  // them via your own logging if needed.
540
242
  /** Bulk delete — calls `R.deleteRecord(id)` per row. On a
541
243
  * soft-delete resource that hits `Model.delete()` which writes
542
- * `deletedAt`. Notification: "N posts moved to trash" / "N posts
543
- * deleted" depending on `R.softDeletes`. */
244
+ * `deletedAt`. */
544
245
  static bulkDelete(R, _basePath) {
545
- return Action.make('bulkDelete')
546
- .label('Delete selected')
547
- .destructive()
548
- .bulk()
549
- .confirm(`Delete the selected ${labelForCount(R, 0)}?`)
550
- .handler(async (ctx) => {
551
- const records = ctx.records ?? [];
552
- const Rfull = R;
553
- let n = 0;
554
- for (const record of records) {
555
- const id = String(record.id ?? '');
556
- if (!id)
557
- continue;
558
- const allowed = await callPredicate(R.canDelete, ctx.user, record);
559
- if (!allowed)
560
- continue;
561
- try {
562
- await Rfull.deleteRecord(id);
563
- n++;
564
- }
565
- catch { /* skip — agg notify shows total */ }
566
- }
567
- const verb = R.softDeletes ? 'moved to trash' : 'deleted';
568
- return { notify: { title: `${n} ${labelForCount(R, n)} ${verb}`, type: 'success' } };
569
- });
246
+ return bulkDeleteAction(R, _basePath);
570
247
  }
571
- /** Bulk restore — calls `R.model.restore(id)` per row. Visible only
572
- * on soft-delete resources (the entire bulk-restore concept is
573
- * specific to them). */
248
+ /** Bulk restore — calls `R.model.restore(id)` per row. */
574
249
  static bulkRestore(R, _basePath) {
575
- return Action.make('bulkRestore')
576
- .label('Restore selected')
577
- .color('success')
578
- .bulk()
579
- .confirm(`Restore the selected ${labelForCount(R, 0)}?`)
580
- .handler(async (ctx) => {
581
- const records = ctx.records ?? [];
582
- const Rfull = R;
583
- const restore = Rfull.model?.restore;
584
- if (!restore) {
585
- return { notify: { title: 'Restore not configured', type: 'error' } };
586
- }
587
- let n = 0;
588
- for (const record of records) {
589
- const id = String(record.id ?? '');
590
- if (!id)
591
- continue;
592
- const allowed = await callPredicate(R.canRestore, ctx.user, record);
593
- if (!allowed)
594
- continue;
595
- try {
596
- await restore(id);
597
- n++;
598
- }
599
- catch { /* skip */ }
600
- }
601
- return { notify: { title: `${n} ${labelForCount(R, n)} restored`, type: 'success' } };
602
- });
250
+ return bulkRestoreAction(R, _basePath);
603
251
  }
604
- /** Bulk force-delete — calls `R.model.forceDelete(id)` per row. Same
605
- * destructive confirm as the per-row variant. Visible only on
606
- * soft-delete resources. */
252
+ /** Bulk force-delete — calls `R.model.forceDelete(id)` per row. */
607
253
  static bulkForceDelete(R, _basePath) {
608
- return Action.make('bulkForceDelete')
609
- .label('Delete forever')
610
- .destructive()
611
- .bulk()
612
- .confirm(`Permanently delete the selected ${labelForCount(R, 0)}? This cannot be undone.`)
613
- .handler(async (ctx) => {
614
- const records = ctx.records ?? [];
615
- const Rfull = R;
616
- const forceDelete = Rfull.model?.forceDelete;
617
- if (!forceDelete) {
618
- return { notify: { title: 'Force-delete not configured', type: 'error' } };
619
- }
620
- let n = 0;
621
- for (const record of records) {
622
- const id = String(record.id ?? '');
623
- if (!id)
624
- continue;
625
- const allowed = await callPredicate(R.canForceDelete, ctx.user, record);
626
- if (!allowed)
627
- continue;
628
- try {
629
- await forceDelete(id);
630
- n++;
631
- }
632
- catch { /* skip */ }
633
- }
634
- return { notify: { title: `${n} ${labelForCount(R, n)} permanently deleted`, type: 'success' } };
635
- });
254
+ return bulkForceDeleteAction(R, _basePath);
636
255
  }
637
256
  /**
638
257
  * Bulk replicate — calls `R.model.create(...)` once per selected row
639
258
  * with the source row's attributes minus PK / soft-delete column /
640
- * `opts.excludeAttributes`. Optional `opts.beforeReplicaSaved(replica,
641
- * source)` runs per-row. Rows that throw during create are skipped
642
- * silently so a single bad row doesn't abort the batch (the user sees
643
- * the success count on the toast). Visibility delegates to
644
- * `R.canCreate(user)`.
645
- *
646
- * Sibling of `Action.replicate` — same options bag, same strip set,
647
- * same authorization gate. Stays on the list page (no per-row
648
- * redirect possible for N rows).
259
+ * `opts.excludeAttributes`. Sibling of `Action.replicate`.
649
260
  */
650
261
  static bulkReplicate(R, _basePath, opts = {}) {
651
- return Action.make('bulkReplicate')
652
- .label('Replicate selected')
653
- .bulk()
654
- .confirm(`Replicate the selected ${labelForCount(R, 0)}?`)
655
- .handler(async (ctx) => {
656
- const M = R.model;
657
- if (!M || typeof M.create !== 'function') {
658
- return { notify: { title: 'Replicate not configured (resource has no model.create)', type: 'error' } };
659
- }
660
- const records = ctx.records ?? [];
661
- const pkCol = M.primaryKey ?? 'id';
662
- const trashedCol = R.deletedAtColumn ?? 'deletedAt';
663
- const skip = new Set([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])]);
664
- let n = 0;
665
- for (const source of records) {
666
- if (!source || typeof source !== 'object')
667
- continue;
668
- const allowed = await callPredicate(R.canCreate, ctx.user);
669
- if (!allowed)
670
- continue;
671
- let replica = {};
672
- for (const [k, v] of Object.entries(source)) {
673
- if (skip.has(k))
674
- continue;
675
- replica[k] = v;
676
- }
677
- if (opts.beforeReplicaSaved) {
678
- try {
679
- replica = await opts.beforeReplicaSaved(replica, source);
680
- }
681
- catch {
682
- continue;
683
- }
684
- }
685
- try {
686
- await M.create(replica);
687
- n++;
688
- }
689
- catch { /* skip — agg notify shows total */ }
690
- }
691
- const defaultTitle = `${n} ${labelForCount(R, n)} replicated`;
692
- const overrideTitle = opts.getCreatedNotificationTitle
693
- ? await opts.getCreatedNotificationTitle({ count: n, records })
694
- : undefined;
695
- const title = overrideTitle !== undefined ? overrideTitle : defaultTitle;
696
- return { notify: { title, type: 'success' } };
697
- })
698
- .visible(({ user }) => callPredicate(R.canCreate, user));
262
+ return bulkReplicateAction(R, _basePath, opts);
699
263
  }
700
264
  // ─── Import / Export factories ────────────────────────────────
701
265
  //
@@ -858,123 +422,36 @@ export class Action extends Element {
858
422
  // the row's *child* id.
859
423
  /** Relation create-action factory — link to
860
424
  * `${base}/${parentSlug}/${parentId}/${relationship}/create`.
861
- *
862
- * Visibility delegates to `M.canCreate(user, parentRecord)` (or the
863
- * related Resource's `canCreate(user)` when the manager hasn't
864
- * overridden). Drop into `headerActions([...])` from inside
865
- * `RelationManager.table(table, ctx)`.
425
+ * Visibility delegates to `M.canCreate(user, parentRecord)` with
426
+ * fall-through to the related Resource's `canCreate(user)`.
866
427
  */
867
428
  static relationCreate(M, ctx) {
868
- const labelSingular = M.getLabelSingular();
869
- return Action.make('create')
870
- .label(`New ${labelSingular}`)
871
- .href(`${relationUrlPrefix(ctx)}/create`)
872
- .visible(({ user }) => {
873
- // M2M managers don't have a per-pivot-row create surface — the
874
- // related record is created via its own Resource, then attached
875
- // via `relationAttach`. Auto-hide so dropping this factory into
876
- // any M2M manager (belongsToMany / morphToMany / morphedByMany)
877
- // is a no-op (visible=false) instead of a 404-on-click foot-gun.
878
- if (isM2MMode(ctx.mode))
879
- return false;
880
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord);
881
- });
429
+ return relationCreateAction(M, ctx);
882
430
  }
883
431
  /** Relation edit-action factory — link to
884
432
  * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/edit`.
885
- *
886
- * Same `recordId` semantics as `Action.edit`: omit for row context
887
- * so the renderer substitutes `:id` per row; pass explicitly when
888
- * building actions for a single-record context. Visibility delegates
889
- * to `M.canEdit(user, child, parentRecord)` with fall-through to the
890
- * related Resource's `canEdit(user, record)`.
891
433
  */
892
434
  static relationEdit(M, ctx, recordId) {
893
- const id = recordId ?? ':id';
894
- return Action.make('edit')
895
- .label('Edit')
896
- .href(`${relationUrlPrefix(ctx)}/${id}/edit`)
897
- .visible(({ user, record }) => {
898
- // M2M: per-pivot-row "edit" doesn't exist; users edit the
899
- // related record via its own Resource. Auto-hide for every M2M
900
- // mode (belongsToMany / morphToMany / morphedByMany).
901
- if (isM2MMode(ctx.mode))
902
- return false;
903
- return safeManagerPolicy(M, 'canEdit', ctx.related, user, ctx.parentRecord, record);
904
- });
435
+ return relationEditAction(M, ctx, recordId);
905
436
  }
906
437
  /** Relation delete-action factory — POST to
907
- * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/delete`,
908
- * destructive style with a labeled confirmation. Visibility delegates
909
- * to `M.canDelete(user, child, parentRecord)` with fall-through to the
910
- * related Resource's `canDelete(user, record)`.
438
+ * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/delete`.
911
439
  */
912
440
  static relationDelete(M, ctx, recordId) {
913
- const id = recordId ?? ':id';
914
- const singular = M.getLabelSingular().toLowerCase();
915
- return Action.make('delete')
916
- .label('Delete')
917
- .destructive()
918
- .method('post')
919
- .action(`${relationUrlPrefix(ctx)}/${id}/delete`)
920
- .confirm(`Delete this ${singular}?`)
921
- .visible(async ({ user, record }) => {
922
- // M2M: "delete" of the related record is destructive in a way
923
- // that "detach" isn't — surface only `relationDetach` on every
924
- // M2M manager (belongsToMany / morphToMany / morphedByMany).
925
- // Users who genuinely want to delete the related record reach
926
- // for `Action.delete(R)` on the related Resource instead.
927
- if (isM2MMode(ctx.mode))
928
- return false;
929
- if (ctx.related?.softDeletes && isTrashed(record, ctx.related))
930
- return false;
931
- return safeManagerPolicy(M, 'canDelete', ctx.related, user, ctx.parentRecord, record);
932
- });
441
+ return relationDeleteAction(M, ctx, recordId);
933
442
  }
934
443
  /**
935
444
  * Plan #13 polish — Restore factory for relation managers. POSTs to
936
- * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/restore`,
937
- * success-styled, no confirm prompt. Auto-hides on live (non-trashed)
938
- * rows AND when `M.canRestore` (or related Resource fall-through)
939
- * denies. Drop into `recordActions([...])` from `RelationManager.table(table, ctx)`.
445
+ * the relation-restore route. Auto-hides on live rows + policy denies.
940
446
  */
941
447
  static relationRestore(M, ctx, recordId) {
942
- const id = recordId ?? ':id';
943
- return Action.make('restore')
944
- .label('Restore')
945
- .color('success')
946
- .method('post')
947
- .action(`${relationUrlPrefix(ctx)}/${id}/restore`)
948
- .visible(async ({ user, record }) => {
949
- if (!ctx.related?.softDeletes)
950
- return false;
951
- if (!isTrashed(record, ctx.related))
952
- return false;
953
- return safeManagerPolicy(M, 'canRestore', ctx.related, user, ctx.parentRecord, record);
954
- });
448
+ return relationRestoreAction(M, ctx, recordId);
955
449
  }
956
450
  /**
957
- * Plan #13 polish — Force-delete factory for relation managers. POSTs
958
- * to `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/force-delete`,
959
- * destructive style with a permanence-aware confirmation. Auto-hides on
960
- * live (non-trashed) rows and when policy denies.
451
+ * Plan #13 polish — Force-delete factory for relation managers.
961
452
  */
962
453
  static relationForceDelete(M, ctx, recordId) {
963
- const id = recordId ?? ':id';
964
- const singular = M.getLabelSingular().toLowerCase();
965
- return Action.make('forceDelete')
966
- .label('Delete forever')
967
- .destructive()
968
- .method('post')
969
- .action(`${relationUrlPrefix(ctx)}/${id}/force-delete`)
970
- .confirm(`Permanently delete this ${singular}? This cannot be undone.`)
971
- .visible(async ({ user, record }) => {
972
- if (!ctx.related?.softDeletes)
973
- return false;
974
- if (!isTrashed(record, ctx.related))
975
- return false;
976
- return safeManagerPolicy(M, 'canForceDelete', ctx.related, user, ctx.parentRecord, record);
977
- });
454
+ return relationForceDeleteAction(M, ctx, recordId);
978
455
  }
979
456
  // ─── Relation-manager replicate factories ─────────────────
980
457
  //
@@ -1009,86 +486,19 @@ export class Action extends Element {
1009
486
  // wins, otherwise falls through to the related Resource's `canCreate`.
1010
487
  /**
1011
488
  * Relation row-replicate factory. Clones the row's child record
1012
- * inside the manager's parent scope.
1013
- *
1014
- * Strips the related model's primary key, soft-delete column, and
1015
- * `opts.excludeAttributes`. Re-applies the parent attachment columns
1016
- * after the strip + before the optional `beforeReplicaSaved` hook,
1017
- * so user code can still mutate non-FK fields without accidentally
1018
- * unlinking the replica.
1019
- *
1020
- * On success the manager-scoped route falls back to the manager
1021
- * list URL (`${base}/${parentSlug}/${parentId}/${relationship}`)
1022
- * because no explicit `redirect` is returned — same default as the
1023
- * other handler-style relation factories.
1024
- *
1025
- * `recordId` kept in the signature for parity with the rest of the
1026
- * relation factory family. The dispatcher resolves the source row
1027
- * from the request body, so it isn't referenced here.
489
+ * inside the manager's parent scope. Strips PK + soft-delete column
490
+ * + `opts.excludeAttributes`, then force-pins the parent attachment
491
+ * columns before optional `beforeReplicaSaved` runs.
1028
492
  */
1029
493
  static relationReplicate(M, ctx, recordId, opts = {}) {
1030
- void recordId;
1031
- return Action.make('relationReplicate')
1032
- .label('Replicate')
1033
- .row()
1034
- .handler(async (hctx) => {
1035
- const result = await runRelationReplicateRow(M, ctx, hctx, opts);
1036
- return result;
1037
- })
1038
- .visible(({ user }) => {
1039
- if (isM2MMode(ctx.mode) || ctx.mode === 'morphTo')
1040
- return false;
1041
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord);
1042
- });
494
+ return relationReplicateAction(M, ctx, recordId, opts);
1043
495
  }
1044
496
  /**
1045
497
  * Bulk sibling — replicates every selected child row inside the
1046
- * manager's parent scope. Same strip + force-pin pipeline applied
1047
- * per row. Per-row `safeManagerPolicy(M, 'canCreate', …)` runs
1048
- * inside the loop so a partially-permitted selection still proceeds
1049
- * for the rows that pass. Rows that throw are skipped silently —
1050
- * the toast count reflects only successful creates.
498
+ * manager's parent scope. Same strip + force-pin pipeline per row.
1051
499
  */
1052
500
  static relationBulkReplicate(M, ctx, opts = {}) {
1053
- return Action.make('relationBulkReplicate')
1054
- .label('Replicate selected')
1055
- .bulk()
1056
- .confirm(`Replicate the selected ${M.getLabel().toLowerCase()}?`)
1057
- .handler(async (hctx) => {
1058
- const Related = ctx.related;
1059
- if (!Related?.model || typeof Related.model.create !== 'function') {
1060
- return { notify: { title: 'Replicate not configured (related Resource has no model.create)', type: 'error' } };
1061
- }
1062
- const records = hctx.records ?? [];
1063
- let n = 0;
1064
- for (const source of records) {
1065
- if (!source || typeof source !== 'object')
1066
- continue;
1067
- const allowed = await safeManagerPolicy(M, 'canCreate', Related, hctx.user, ctx.parentRecord);
1068
- if (!allowed)
1069
- continue;
1070
- try {
1071
- await persistRelationReplica(M, ctx, source, opts);
1072
- n++;
1073
- }
1074
- catch { /* skip — agg notify shows total */ }
1075
- }
1076
- const labelPlural = M.getLabel().toLowerCase();
1077
- const labelSingular = M.getLabelSingular().toLowerCase();
1078
- const defaultTitle = `${n} ${n === 1 ? labelSingular : labelPlural} replicated`;
1079
- const overrideTitle = opts.getCreatedNotificationTitle
1080
- ? await opts.getCreatedNotificationTitle({ count: n, records })
1081
- : undefined;
1082
- const title = overrideTitle !== undefined ? overrideTitle : defaultTitle;
1083
- return {
1084
- notify: { title, type: 'success' },
1085
- };
1086
- })
1087
- .visible(({ user }) => {
1088
- if (isM2MMode(ctx.mode) || ctx.mode === 'morphTo')
1089
- return false;
1090
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord);
1091
- });
501
+ return relationBulkReplicateAction(M, ctx, opts);
1092
502
  }
1093
503
  // ─── M2M relation factories ───────────────────────────────
1094
504
  //
@@ -1113,133 +523,21 @@ export class Action extends Element {
1113
523
  /** Header-placement attach factory — opens a modal with a SelectField
1114
524
  * listing related records that aren't already attached, and POSTs the
1115
525
  * selected id to the manager's `_action/relationAttach` endpoint.
1116
- *
1117
526
  * Visibility delegates to `M.canAttach(user, parentRecord)` AND
1118
527
  * guards against being dropped into a non-M2M manager. */
1119
528
  static relationAttach(M, ctx) {
1120
- const labelSingular = M.getLabelSingular();
1121
- const a = Action.make('relationAttach')
1122
- .label(`Attach ${labelSingular}`)
1123
- .header()
1124
- .modalHeading(`Attach ${labelSingular}`)
1125
- .modalSubmitLabel('Attach')
1126
- .modalCancelLabel('Cancel')
1127
- .handler(async (hctx) => {
1128
- const rel = hctx.relation;
1129
- if (!rel) {
1130
- return { notify: { title: 'Attach handler missing parent context — manager-scoped _action route not wired', type: 'error' } };
1131
- }
1132
- const Related = ctx.related;
1133
- if (!Related?.model) {
1134
- return { notify: { title: 'Cannot attach: related Resource has no model', type: 'error' } };
1135
- }
1136
- const idStr = String(hctx.values?.['_attachId'] ?? '');
1137
- if (idStr.length === 0) {
1138
- return { notify: { title: 'Pick a record to attach', type: 'error' } };
1139
- }
1140
- const accessor = resolveM2MAccessor(rel.parent, rel.relationship);
1141
- if (!accessor || typeof accessor.attach !== 'function') {
1142
- return { notify: { title: `Pivot accessor missing on ${rel.relationship} — wrong relation type or ORM version?`, type: 'error' } };
1143
- }
1144
- try {
1145
- await accessor.attach([idStr]);
1146
- }
1147
- catch (err) {
1148
- return { notify: { title: `Attach failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } };
1149
- }
1150
- return { notify: { title: `${labelSingular} attached`, type: 'success' } };
1151
- })
1152
- .visible(({ user }) => {
1153
- if (!isM2MMode(ctx.mode))
1154
- return false;
1155
- return safeManagerPolicy(M, 'canAttach', ctx.related, user, ctx.parentRecord);
1156
- });
1157
- // Build the modal-form schema only when this is actually an M2M
1158
- // manager — non-M2M drops keep the action hidden via the visibility
1159
- // predicate, but still need a schema-less Action so the meta walker
1160
- // doesn't blow up. Static import is fine: `attachFactory` only
1161
- // depends on `SelectField` + ORM helpers, no cycle back to Action.
1162
- if (isM2MMode(ctx.mode) && ctx.related?.model) {
1163
- a.schema(buildAttachModalSchema({
1164
- Related: ctx.related,
1165
- relationship: ctx.relationship,
1166
- recordTitleAttr: M.getRecordTitleAttribute() ?? ctx.related.recordTitleAttribute,
1167
- labelSingular,
1168
- }));
1169
- }
1170
- return a;
529
+ return relationAttachAction(M, ctx);
1171
530
  }
1172
531
  /** Row-placement detach factory — POSTs to
1173
532
  * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/_detach`,
1174
- * destructive style with a confirmation prompt that says "Detach"
1175
- * (not "Delete") so users understand the target record stays.
1176
- * Visibility delegates to `M.canDetach`. */
533
+ * destructive style with a "Detach" confirmation. */
1177
534
  static relationDetach(M, ctx, recordId) {
1178
- const id = recordId ?? ':id';
1179
- const singular = M.getLabelSingular().toLowerCase();
1180
- return Action.make('relationDetach')
1181
- .label('Detach')
1182
- .destructive()
1183
- .method('post')
1184
- .action(`${relationUrlPrefix(ctx)}/${id}/_detach`)
1185
- .confirm(`Detach this ${singular}? The ${singular} record stays in place; only the link is removed.`)
1186
- .visible(async ({ user, record }) => {
1187
- if (!isM2MMode(ctx.mode))
1188
- return false;
1189
- return safeManagerPolicy(M, 'canDetach', ctx.related, user, ctx.parentRecord, record);
1190
- });
535
+ return relationDetachAction(M, ctx, recordId);
1191
536
  }
1192
537
  /** Bulk-placement bulk-detach factory — handler-dispatched. Calls
1193
- * `parent.related(rel).detach(ids)` for the selected rows. Visibility
1194
- * delegates to `M.canAttach` (acts like a "manager admin" gate; we
1195
- * intentionally don't enforce per-row `canDetach` on the visibility
1196
- * side because the bulk button needs to be visible before the user
1197
- * has selected anything — per-row gating happens inside the handler). */
538
+ * `parent.related(rel).detach(ids)` for the selected rows. */
1198
539
  static relationBulkDetach(M, ctx) {
1199
- const labelPlural = M.getLabel().toLowerCase();
1200
- return Action.make('relationBulkDetach')
1201
- .label('Detach selected')
1202
- .destructive()
1203
- .bulk()
1204
- .confirm(`Detach the selected ${labelPlural}? The records stay in place; only the links are removed.`)
1205
- .handler(async (hctx) => {
1206
- const rel = hctx.relation;
1207
- if (!rel) {
1208
- return { notify: { title: 'Bulk-detach handler missing parent context — manager-scoped _action route not wired', type: 'error' } };
1209
- }
1210
- const records = hctx.records ?? [];
1211
- const ids = [];
1212
- for (const r of records) {
1213
- const id = String(r.id ?? '');
1214
- if (!id)
1215
- continue;
1216
- const allowed = await safeManagerPolicy(M, 'canDetach', ctx.related, hctx.user, ctx.parentRecord, r);
1217
- if (!allowed)
1218
- continue;
1219
- ids.push(id);
1220
- }
1221
- if (ids.length === 0) {
1222
- return { notify: { title: 'Nothing to detach (no permitted rows)', type: 'warning' } };
1223
- }
1224
- const accessor = resolveM2MAccessor(rel.parent, rel.relationship);
1225
- if (!accessor || typeof accessor.detach !== 'function') {
1226
- return { notify: { title: `Pivot accessor missing on ${rel.relationship} — wrong relation type or ORM version?`, type: 'error' } };
1227
- }
1228
- try {
1229
- await accessor.detach(ids);
1230
- }
1231
- catch (err) {
1232
- return { notify: { title: `Bulk detach failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } };
1233
- }
1234
- return { notify: { title: `${ids.length} ${labelPlural} detached`, type: 'success' } };
1235
- })
1236
- .visible(({ user }) => {
1237
- if (!isM2MMode(ctx.mode))
1238
- return false;
1239
- // Bulk gate uses canAttach as a stand-in for "manager admin" —
1240
- // per-row canDetach is enforced inside the handler.
1241
- return safeManagerPolicy(M, 'canAttach', ctx.related, user, ctx.parentRecord);
1242
- });
540
+ return relationBulkDetachAction(M, ctx);
1243
541
  }
1244
542
  label(l) { this._label = l; return this; }
1245
543
  icon(i) { this._icon = i; return this; }