@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,20 +1,39 @@
1
1
  import { Element, type ElementMeta } from '../schema/Element.js'
2
2
  import type { ValidationErrors } from '../validation/index.js'
3
3
  import type { Notification, NotificationMeta } from '../notifications/Notification.js'
4
+ import type { RelationManager, RelationManagerContext } from '../RelationManager.js'
5
+ import { buildImportSchema as buildImportModalSchema } from './importFactory.js'
6
+ import { callPredicate } from './factoryHelpers.js'
4
7
  import {
5
- safeManagerPolicy,
6
- type RelationManager,
7
- type RelationManagerContext,
8
- } from '../RelationManager.js'
8
+ createAction,
9
+ deleteAction,
10
+ editAction,
11
+ forceDeleteAction,
12
+ markAsReadAction,
13
+ replicateAction,
14
+ restoreAction,
15
+ viewAction,
16
+ } from './crudFactories.js'
9
17
  import {
10
- computeMorphPayload,
11
- getMorphRelationDescriptor,
12
- getParentRelationDescriptor,
13
- type ModelLike,
14
- } from '../orm/modelDefaults.js'
15
- import { resolveM2MAccessor } from '../orm/m2mAccessor.js'
16
- import { buildImportSchema as buildImportModalSchema } from './importFactory.js'
17
- import { buildAttachModalSchema } from './attachFactory.js'
18
+ bulkDeleteAction,
19
+ bulkForceDeleteAction,
20
+ bulkReplicateAction,
21
+ bulkRestoreAction,
22
+ } from './bulkFactories.js'
23
+ import {
24
+ relationBulkReplicateAction,
25
+ relationCreateAction,
26
+ relationDeleteAction,
27
+ relationEditAction,
28
+ relationForceDeleteAction,
29
+ relationReplicateAction,
30
+ relationRestoreAction,
31
+ } from './relationFactories.js'
32
+ import {
33
+ relationAttachAction,
34
+ relationBulkDetachAction,
35
+ relationDetachAction,
36
+ } from './m2mFactories.js'
18
37
 
19
38
  /**
20
39
  * Where an Action renders. `inline` is the default — appears wherever the
@@ -244,7 +263,7 @@ export interface ReplicateOptions {
244
263
  * cycle. The optional fields are the Plan #10 policy predicates; their
245
264
  * defaults (return `true`) mean missing methods are equivalent to
246
265
  * "always allowed." */
247
- interface ResourceLike {
266
+ export interface ResourceLike {
248
267
  labelSingular: string
249
268
  /** Plural label. When unset, factories fall back to
250
269
  * `${labelSingular}s` (naive). Used by bulk-action notification
@@ -281,185 +300,6 @@ interface ResourceLike {
281
300
  table?(t: any): any
282
301
  }
283
302
 
284
- /** Cluster-aware resource base path. Mirrors `clusterPaths.resourceBasePath`
285
- * but uses the structural `ResourceLike` shape so `Action.ts` stays
286
- * cycle-free against `Resource.ts`. */
287
- function resourceBase(basePath: string, R: ResourceLike): string {
288
- if (R.cluster) return `${basePath}/${R.cluster.getSlug()}/${R.getSlug()}`
289
- return `${basePath}/${R.getSlug()}`
290
- }
291
-
292
- /** Pick the right label form for a count — `labelSingular` for 1,
293
- * `label` (plural, lowercased) for any other count. Fall back to a
294
- * naive `${labelSingular}s` when no plural label is set. Used by bulk
295
- * notification copy so we don't ship "1 posts moved to trash". */
296
- function labelForCount(R: ResourceLike, n: number): string {
297
- if (n === 1) return R.labelSingular.toLowerCase()
298
- const plural = R.label?.toLowerCase()
299
- return plural ?? `${R.labelSingular.toLowerCase()}s`
300
- }
301
-
302
- /** True when a `RelationManagerContext.mode` denotes a pivot-mutation
303
- * shape — i.e. a many-to-many relation. All three modes share the
304
- * `attach` / `detach` / `sync` accessor surface (the rudder ORM stamps
305
- * + filters the polymorphic discriminator transparently for the morph
306
- * variants). The `relationCreate / Edit / Delete` factories auto-hide
307
- * under any of these modes because per-pivot-row create / edit / delete
308
- * is meaningless — users create the related record via its own Resource,
309
- * then attach via `relationAttach`. */
310
- function isM2MMode(mode: RelationManagerContext['mode']): boolean {
311
- return mode === 'belongsToMany' || mode === 'morphToMany' || mode === 'morphedByMany'
312
- }
313
-
314
- /**
315
- * Phase B — build the URL prefix for a relation factory action. Without
316
- * a `chain` (depth-1 manager), this is the familiar
317
- * `${base}/${parentSlug}/${parentId}/${relationship}`. With a chain
318
- * (depth-2 nested manager), it threads the outer record + relationship
319
- * between the parent slug and the leaf parent id:
320
- *
321
- * `${base}/${parentSlug}/${chain[0].recordId}/${chain[0].relationship}/${parentId}/${relationship}`
322
- *
323
- * Pure; takes a `RelationManagerContext` and emits a string. The leaf
324
- * record id (and trailing `/edit`, `/delete`, etc.) gets appended by
325
- * the caller.
326
- */
327
- function relationUrlPrefix(ctx: RelationManagerContext): string {
328
- const head = `${ctx.basePath}/${ctx.parentSlug}`
329
- const chain = ctx.chain ?? []
330
- let mid = ''
331
- for (const step of chain) {
332
- mid += `/${step.recordId}/${step.relationship}`
333
- }
334
- return `${head}${mid}/${ctx.parentId}/${ctx.relationship}`
335
- }
336
-
337
- /**
338
- * Compute the parent-attachment payload to force-pin onto a relation
339
- * replica. For `hasMany`, returns `{ [foreignKey]: parentId }` from the
340
- * parent's `static relations[name]` descriptor. For `morphMany` /
341
- * `morphOne`, returns `{ <morphName>Id, <morphName>Type }` via
342
- * `computeMorphPayload(parentRecord)`. Returns `{}` when no descriptor
343
- * matches — the route dispatcher already auto-hides under M2M / morphTo,
344
- * so missing descriptors there are a no-op rather than an error. Pure;
345
- * exported for tests and re-used by both factories.
346
- */
347
- function computeRelationPin(
348
- ctx: RelationManagerContext,
349
- ): Record<string, unknown> {
350
- const parentModel = (ctx.parentRecord as { constructor?: ModelLike } | null | undefined)?.constructor
351
- if (!parentModel) return {}
352
- const rel = ctx.relationship
353
- // Polymorphic owner side first — `morphMany` carries no foreignKey
354
- // and would fail the hasMany descriptor's gate.
355
- if (ctx.mode === 'morphMany') {
356
- const morph = getMorphRelationDescriptor(parentModel, rel)
357
- if (!morph) return {}
358
- try { return computeMorphPayload(ctx.parentRecord, morph) }
359
- catch { return {} }
360
- }
361
- const desc = getParentRelationDescriptor(parentModel, rel)
362
- if (!desc) return {}
363
- return { [desc.foreignKey]: ctx.parentId }
364
- }
365
-
366
- /**
367
- * Build + persist a single relation replica. Runs the strip set
368
- * (PK + soft-delete column on the **related** Resource +
369
- * `opts.excludeAttributes`), force-pins the parent attachment columns,
370
- * runs the optional `beforeReplicaSaved` hook, and calls
371
- * `Related.model.create(...)`. Returns the model's create result so
372
- * callers can read its primary key for redirect targeting.
373
- *
374
- * Throws when the related Resource has no model — caller (single-row
375
- * factory) catches and surfaces an error notification; bulk caller
376
- * checks the model presence ahead of the loop.
377
- */
378
- async function persistRelationReplica(
379
- _M: typeof RelationManager,
380
- ctx: RelationManagerContext,
381
- source: unknown,
382
- opts: ReplicateOptions,
383
- ): Promise<unknown> {
384
- const Related = ctx.related
385
- if (!Related?.model || typeof Related.model.create !== 'function') {
386
- throw new Error('Related Resource has no model.create')
387
- }
388
- const M2 = Related.model as ModelLike
389
- const pkCol = (M2 as { primaryKey?: string }).primaryKey ?? 'id'
390
- const trashedCol = Related.deletedAtColumn ?? 'deletedAt'
391
- const skip = new Set<string>([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])])
392
- let replica: Record<string, unknown> = {}
393
- for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
394
- if (skip.has(k)) continue
395
- replica[k] = v
396
- }
397
- // Force-pin the parent attachment AFTER the strip but BEFORE the
398
- // user mutator, so `beforeReplicaSaved` can read / override the FK
399
- // if it really wants to (rare). Tampered source rows can't slip a
400
- // different parent in by riding their own FK column — the pin
401
- // overwrites whatever value was there.
402
- Object.assign(replica, computeRelationPin(ctx))
403
- if (opts.beforeReplicaSaved) {
404
- replica = await opts.beforeReplicaSaved(replica, source)
405
- }
406
- return M2.create(replica)
407
- }
408
-
409
- /**
410
- * Single-row dispatch for `Action.relationReplicate`. Resolves
411
- * `ctx.record` (loaded by the route's resolveRecord hook), validates,
412
- * persists the replica, and shapes the success notification. Errors
413
- * are caught and surfaced as error toasts.
414
- */
415
- async function runRelationReplicateRow(
416
- M: typeof RelationManager,
417
- ctx: RelationManagerContext,
418
- hctx: ActionContext,
419
- opts: ReplicateOptions,
420
- ): Promise<ActionResult> {
421
- const source = hctx.record
422
- if (!source || typeof source !== 'object') {
423
- return { notify: { title: 'Replicate failed: source record missing', type: 'error' } as never }
424
- }
425
- const Related = ctx.related
426
- if (!Related?.model || typeof Related.model.create !== 'function') {
427
- return { notify: { title: 'Replicate not configured (related Resource has no model.create)', type: 'error' } as never }
428
- }
429
- let created: unknown
430
- try {
431
- created = await persistRelationReplica(M, ctx, source, opts)
432
- } catch (err) {
433
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } as never }
434
- }
435
- const overrideTitle = opts.getCreatedNotificationTitle
436
- ? await opts.getCreatedNotificationTitle({ replica: created, source })
437
- : undefined
438
- const title = overrideTitle !== undefined ? overrideTitle : `${M.getLabelSingular()} replicated`
439
- // The manager-scoped `_action/:actionName` route falls back to the
440
- // manager list URL when `result.redirect` is undefined, so we only
441
- // emit `redirect` when the user override returned a string. That
442
- // way default behavior (route owns the fallback) is unchanged.
443
- const overrideRedirect = opts.getRedirectUrl
444
- ? await opts.getRedirectUrl({ replica: created, source })
445
- : undefined
446
- return {
447
- ...(overrideRedirect !== undefined ? { redirect: overrideRedirect } : {}),
448
- notify: { title, type: 'success' } as never,
449
- }
450
- }
451
-
452
- /** Read `record[R.deletedAtColumn ?? 'deletedAt']` and return true when
453
- * the row is currently trashed (soft-deleted). Permissive on shape —
454
- * bare `null` / `undefined` count as live; any other truthy value is
455
- * trashed. */
456
- function isTrashed(record: unknown, R: ResourceLike): boolean {
457
- if (!record || typeof record !== 'object') return false
458
- const col = R.deletedAtColumn ?? 'deletedAt'
459
- const v = (record as Record<string, unknown>)[col]
460
- return v !== null && v !== undefined
461
- }
462
-
463
303
  /** Lazy-load the `Table` class for use inside Action handlers. Direct
464
304
  * module-level import would cycle (Table → Action → Table); dynamic
465
305
  * import inside a handler runs after both modules have finished
@@ -474,18 +314,6 @@ async function loadTableClass(): Promise<unknown> {
474
314
  return _TableClass.make()
475
315
  }
476
316
 
477
- /** Call a (possibly undefined) Resource predicate. When unset, the
478
- * predicate is treated as "allowed" (returns true) so the factory
479
- * doesn't hide actions on Resources that haven't opted into Plan #10. */
480
- function callPredicate(
481
- fn: ((user: unknown, record?: unknown) => boolean | Promise<boolean>) | undefined,
482
- user: unknown,
483
- record?: unknown,
484
- ): boolean | Promise<boolean> {
485
- if (!fn) return true
486
- return fn(user, record)
487
- }
488
-
489
317
  /** Render-time meta for an action that opens a modal (with or without a
490
318
  * form schema). When `meta.children` is also populated by the resolver,
491
319
  * the modal renders those Elements as a form whose values pass through
@@ -719,10 +547,7 @@ export class Action extends Element {
719
547
  /** Create-action factory — link to `${basePath}/${R.slug}/create`.
720
548
  * Auto-hides when `R.canCreate(user)` returns false. */
721
549
  static create(R: ResourceLike, basePath: string): Action {
722
- return Action.make('create')
723
- .label(`New ${R.labelSingular}`)
724
- .href(`${resourceBase(basePath, R)}/create`)
725
- .visible(({ user }) => callPredicate(R.canCreate, user))
550
+ return createAction(R, basePath)
726
551
  }
727
552
 
728
553
  /**
@@ -733,27 +558,16 @@ export class Action extends Element {
733
558
  * Omit `recordId` for row context (`Table.recordActions(...)`); the
734
559
  * URL keeps the `:id` template and the renderer substitutes per-row.
735
560
  *
736
- * Auto-hides when `R.canEdit(user, record)` returns false. For row
737
- * context the per-row record threads in via `loadTableRecords`'s
738
- * per-row eval; for view-page context, `resolveSchema` provides the
739
- * resolved record on the eval context.
561
+ * Auto-hides when `R.canEdit(user, record)` returns false.
740
562
  */
741
563
  static edit(R: ResourceLike, basePath: string, recordId?: string): Action {
742
- const id = recordId ?? ':id'
743
- return Action.make('edit')
744
- .label('Edit')
745
- .href(`${resourceBase(basePath, R)}/${id}/edit`)
746
- .visible(({ user, record }) => callPredicate(R.canEdit, user, record))
564
+ return editAction(R, basePath, recordId)
747
565
  }
748
566
 
749
567
  /** View-action factory — link to the resource's view page. See `Action.edit` for the `recordId` semantics.
750
568
  * Auto-hides when `R.canView(user, record)` returns false. */
751
569
  static view(R: ResourceLike, basePath: string, recordId?: string): Action {
752
- const id = recordId ?? ':id'
753
- return Action.make('view')
754
- .label('View')
755
- .href(`${resourceBase(basePath, R)}/${id}`)
756
- .visible(({ user, record }) => callPredicate(R.canView, user, record))
570
+ return viewAction(R, basePath, recordId)
757
571
  }
758
572
 
759
573
  /**
@@ -763,40 +577,18 @@ export class Action extends Element {
763
577
  * Auto-hides when `R.canDelete(user, record)` returns false.
764
578
  *
765
579
  * Plan #13 — when `R.softDeletes = true`, additionally hides on
766
- * rows whose `deletedAtColumn` is set (already-trashed rows get the
767
- * Restore + ForceDelete pair instead, surfaced via the matching
768
- * factories below).
580
+ * already-trashed rows (Restore + ForceDelete take over).
769
581
  */
770
582
  static delete(R: ResourceLike, basePath: string, recordId?: string): Action {
771
- const id = recordId ?? ':id'
772
- return Action.make('delete')
773
- .label('Delete')
774
- .destructive()
775
- .method('post')
776
- .action(`${resourceBase(basePath, R)}/${id}/delete`)
777
- .confirm(`Delete this ${R.labelSingular.toLowerCase()}?`)
778
- .visible(async ({ user, record }) => {
779
- if (R.softDeletes && isTrashed(record, R)) return false
780
- return callPredicate(R.canDelete, user, record)
781
- })
583
+ return deleteAction(R, basePath, recordId)
782
584
  }
783
585
 
784
586
  /**
785
- * Replicate-action factory — handler-style. Loads the source record
786
- * from `ctx.record` (the `_action/:actionName` route already resolves
787
- * it through `R.query(ctx)` for row + single-target placements),
788
- * strips PK + soft-delete column + any `opts.excludeAttributes`,
789
- * optionally runs `opts.beforeReplicaSaved`, and creates a new row
790
- * via `R.model.create(...)`. Redirects to the new record's edit page
791
- * on success so the user can review + tweak before saving again.
792
- *
793
- * `recordId` kept in the signature for parity with `delete / edit /
794
- * view` so users can swap factories without rewriting call sites; the
795
- * dispatcher resolves the source record from the URL and hands it to
796
- * the handler as `ctx.record`, so we don't reference `recordId` here.
797
- *
798
- * Auto-hides when `R.canCreate(user)` returns false — replicating
799
- * writes a new row, so the gate is `canCreate`, not `canView`.
587
+ * Replicate-action factory — handler-style. Strips PK + soft-delete
588
+ * column + `opts.excludeAttributes` from `ctx.record`, optionally
589
+ * runs `opts.beforeReplicaSaved`, and creates a new row via
590
+ * `R.model.create(...)`. Redirects to the new record's edit page
591
+ * on success. Auto-hides when `R.canCreate(user)` returns false.
800
592
  */
801
593
  static replicate(
802
594
  R: ResourceLike,
@@ -804,101 +596,25 @@ export class Action extends Element {
804
596
  recordId?: string,
805
597
  opts: ReplicateOptions = {},
806
598
  ): Action {
807
- void recordId
808
- return Action.make('replicate')
809
- .label('Replicate')
810
- .handler(async (ctx) => {
811
- const source = ctx.record
812
- if (!source || typeof source !== 'object') {
813
- return { notify: { title: 'Replicate failed: source record missing', type: 'error' } as never }
814
- }
815
- const M = R.model
816
- if (!M || typeof M.create !== 'function') {
817
- return { notify: { title: 'Replicate not configured (resource has no model.create)', type: 'error' } as never }
818
- }
819
-
820
- const pkCol = (M as { primaryKey?: string }).primaryKey ?? 'id'
821
- const trashedCol = R.deletedAtColumn ?? 'deletedAt'
822
- const skip = new Set<string>([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])])
823
- let replica: Record<string, unknown> = {}
824
- for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
825
- if (skip.has(k)) continue
826
- replica[k] = v
827
- }
828
- if (opts.beforeReplicaSaved) {
829
- try { replica = await opts.beforeReplicaSaved(replica, source) }
830
- catch (err) {
831
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } as never }
832
- }
833
- }
834
-
835
- let created: unknown
836
- try {
837
- created = await M.create(replica)
838
- } catch (err) {
839
- return { notify: { title: `Replicate failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } as never }
840
- }
841
-
842
- const newId = (created as Record<string, unknown> | null | undefined)?.[pkCol]
843
- const defaultRedirect = newId !== undefined && newId !== null
844
- ? `${resourceBase(basePath, R)}/${String(newId)}/edit`
845
- : `${resourceBase(basePath, R)}`
846
- // `!== undefined` rather than `??` so an override returning
847
- // `null`/empty-string isn't silently swallowed (see
848
- // feedback_nullish_swallows_explicit_null).
849
- const overrideRedirect = opts.getRedirectUrl
850
- ? await opts.getRedirectUrl({ replica: created, source })
851
- : undefined
852
- const redirect = overrideRedirect !== undefined ? overrideRedirect : defaultRedirect
853
- const overrideTitle = opts.getCreatedNotificationTitle
854
- ? await opts.getCreatedNotificationTitle({ replica: created, source })
855
- : undefined
856
- const title = overrideTitle !== undefined ? overrideTitle : `${R.labelSingular} replicated`
857
- return {
858
- redirect,
859
- notify: { title, type: 'success' } as never,
860
- }
861
- })
862
- .visible(({ user }) => callPredicate(R.canCreate, user))
599
+ return replicateAction(R, basePath, recordId, opts)
863
600
  }
864
601
 
865
602
  /**
866
603
  * Plan #13 — Restore factory. POSTs to the resource's restore route,
867
- * success-styled, no confirm prompt (restoration is reversible).
868
- * Auto-hides on live (non-trashed) rows AND when `R.canRestore(user,
869
- * record)` returns false. Same `recordId` semantics as `Action.edit`.
604
+ * success-styled, no confirm prompt. Auto-hides on live (non-trashed)
605
+ * rows AND when `R.canRestore(user, record)` returns false.
870
606
  */
871
607
  static restore(R: ResourceLike, basePath: string, recordId?: string): Action {
872
- const id = recordId ?? ':id'
873
- return Action.make('restore')
874
- .label('Restore')
875
- .color('success')
876
- .method('post')
877
- .action(`${resourceBase(basePath, R)}/${id}/restore`)
878
- .visible(async ({ user, record }) => {
879
- if (!isTrashed(record, R)) return false
880
- return callPredicate(R.canRestore, user, record)
881
- })
608
+ return restoreAction(R, basePath, recordId)
882
609
  }
883
610
 
884
611
  /**
885
612
  * Plan #13 — Force-delete factory. POSTs to the resource's
886
613
  * force-delete route, destructive-styled, with a stricter confirm
887
- * prompt referencing permanence. Auto-hides on live (non-trashed)
888
- * rows AND when `R.canForceDelete(user, record)` returns false.
614
+ * prompt. Auto-hides on live rows + when `R.canForceDelete` denies.
889
615
  */
890
616
  static forceDelete(R: ResourceLike, basePath: string, recordId?: string): Action {
891
- const id = recordId ?? ':id'
892
- return Action.make('forceDelete')
893
- .label('Delete forever')
894
- .destructive()
895
- .method('post')
896
- .action(`${resourceBase(basePath, R)}/${id}/force-delete`)
897
- .confirm(`Permanently delete this ${R.labelSingular.toLowerCase()}? This cannot be undone.`)
898
- .visible(async ({ user, record }) => {
899
- if (!isTrashed(record, R)) return false
900
- return callPredicate(R.canForceDelete, user, record)
901
- })
617
+ return forceDeleteAction(R, basePath, recordId)
902
618
  }
903
619
 
904
620
  // ─── Notification factories ───────────────────────────────────
@@ -930,11 +646,7 @@ export class Action extends Element {
930
646
  * to hide on already-read rows.
931
647
  */
932
648
  static markAsRead(basePath: string, notificationId?: string): Action {
933
- const id = notificationId ?? ':id'
934
- return Action.make('markAsRead')
935
- .label('Mark as read')
936
- .method('post')
937
- .action(`${basePath}/_notifications/${id}/read`)
649
+ return markAsReadAction(basePath, notificationId)
938
650
  }
939
651
 
940
652
  // ─── Bulk factories (Plan #13) ────────────────────────────────
@@ -951,141 +663,32 @@ export class Action extends Element {
951
663
 
952
664
  /** Bulk delete — calls `R.deleteRecord(id)` per row. On a
953
665
  * soft-delete resource that hits `Model.delete()` which writes
954
- * `deletedAt`. Notification: "N posts moved to trash" / "N posts
955
- * deleted" depending on `R.softDeletes`. */
666
+ * `deletedAt`. */
956
667
  static bulkDelete(R: ResourceLike, _basePath: string): Action {
957
- return Action.make('bulkDelete')
958
- .label('Delete selected')
959
- .destructive()
960
- .bulk()
961
- .confirm(`Delete the selected ${labelForCount(R, 0)}?`)
962
- .handler(async (ctx) => {
963
- const records = ctx.records ?? []
964
- const Rfull = R as ResourceLike & { deleteRecord(id: string): Promise<void> }
965
- let n = 0
966
- for (const record of records) {
967
- const id = String((record as { id?: unknown }).id ?? '')
968
- if (!id) continue
969
- const allowed = await callPredicate(R.canDelete, ctx.user, record)
970
- if (!allowed) continue
971
- try { await Rfull.deleteRecord(id); n++ } catch { /* skip — agg notify shows total */ }
972
- }
973
- const verb = R.softDeletes ? 'moved to trash' : 'deleted'
974
- return { notify: { title: `${n} ${labelForCount(R, n)} ${verb}`, type: 'success' } as never }
975
- })
668
+ return bulkDeleteAction(R, _basePath)
976
669
  }
977
670
 
978
- /** Bulk restore — calls `R.model.restore(id)` per row. Visible only
979
- * on soft-delete resources (the entire bulk-restore concept is
980
- * specific to them). */
671
+ /** Bulk restore — calls `R.model.restore(id)` per row. */
981
672
  static bulkRestore(R: ResourceLike, _basePath: string): Action {
982
- return Action.make('bulkRestore')
983
- .label('Restore selected')
984
- .color('success')
985
- .bulk()
986
- .confirm(`Restore the selected ${labelForCount(R, 0)}?`)
987
- .handler(async (ctx) => {
988
- const records = ctx.records ?? []
989
- const Rfull = R as ResourceLike & { model?: { restore?(id: string | number): Promise<unknown> } }
990
- const restore = Rfull.model?.restore
991
- if (!restore) {
992
- return { notify: { title: 'Restore not configured', type: 'error' } as never }
993
- }
994
- let n = 0
995
- for (const record of records) {
996
- const id = String((record as { id?: unknown }).id ?? '')
997
- if (!id) continue
998
- const allowed = await callPredicate(R.canRestore, ctx.user, record)
999
- if (!allowed) continue
1000
- try { await restore(id); n++ } catch { /* skip */ }
1001
- }
1002
- return { notify: { title: `${n} ${labelForCount(R, n)} restored`, type: 'success' } as never }
1003
- })
673
+ return bulkRestoreAction(R, _basePath)
1004
674
  }
1005
675
 
1006
- /** Bulk force-delete — calls `R.model.forceDelete(id)` per row. Same
1007
- * destructive confirm as the per-row variant. Visible only on
1008
- * soft-delete resources. */
676
+ /** Bulk force-delete — calls `R.model.forceDelete(id)` per row. */
1009
677
  static bulkForceDelete(R: ResourceLike, _basePath: string): Action {
1010
- return Action.make('bulkForceDelete')
1011
- .label('Delete forever')
1012
- .destructive()
1013
- .bulk()
1014
- .confirm(`Permanently delete the selected ${labelForCount(R, 0)}? This cannot be undone.`)
1015
- .handler(async (ctx) => {
1016
- const records = ctx.records ?? []
1017
- const Rfull = R as ResourceLike & { model?: { forceDelete?(id: string | number): Promise<void> } }
1018
- const forceDelete = Rfull.model?.forceDelete
1019
- if (!forceDelete) {
1020
- return { notify: { title: 'Force-delete not configured', type: 'error' } as never }
1021
- }
1022
- let n = 0
1023
- for (const record of records) {
1024
- const id = String((record as { id?: unknown }).id ?? '')
1025
- if (!id) continue
1026
- const allowed = await callPredicate(R.canForceDelete, ctx.user, record)
1027
- if (!allowed) continue
1028
- try { await forceDelete(id); n++ } catch { /* skip */ }
1029
- }
1030
- return { notify: { title: `${n} ${labelForCount(R, n)} permanently deleted`, type: 'success' } as never }
1031
- })
678
+ return bulkForceDeleteAction(R, _basePath)
1032
679
  }
1033
680
 
1034
681
  /**
1035
682
  * Bulk replicate — calls `R.model.create(...)` once per selected row
1036
683
  * with the source row's attributes minus PK / soft-delete column /
1037
- * `opts.excludeAttributes`. Optional `opts.beforeReplicaSaved(replica,
1038
- * source)` runs per-row. Rows that throw during create are skipped
1039
- * silently so a single bad row doesn't abort the batch (the user sees
1040
- * the success count on the toast). Visibility delegates to
1041
- * `R.canCreate(user)`.
1042
- *
1043
- * Sibling of `Action.replicate` — same options bag, same strip set,
1044
- * same authorization gate. Stays on the list page (no per-row
1045
- * redirect possible for N rows).
684
+ * `opts.excludeAttributes`. Sibling of `Action.replicate`.
1046
685
  */
1047
686
  static bulkReplicate(
1048
687
  R: ResourceLike,
1049
688
  _basePath: string,
1050
689
  opts: ReplicateOptions = {},
1051
690
  ): Action {
1052
- return Action.make('bulkReplicate')
1053
- .label('Replicate selected')
1054
- .bulk()
1055
- .confirm(`Replicate the selected ${labelForCount(R, 0)}?`)
1056
- .handler(async (ctx) => {
1057
- const M = R.model
1058
- if (!M || typeof M.create !== 'function') {
1059
- return { notify: { title: 'Replicate not configured (resource has no model.create)', type: 'error' } as never }
1060
- }
1061
- const records = ctx.records ?? []
1062
- const pkCol = (M as { primaryKey?: string }).primaryKey ?? 'id'
1063
- const trashedCol = R.deletedAtColumn ?? 'deletedAt'
1064
- const skip = new Set<string>([pkCol, trashedCol, ...(opts.excludeAttributes ?? [])])
1065
- let n = 0
1066
- for (const source of records) {
1067
- if (!source || typeof source !== 'object') continue
1068
- const allowed = await callPredicate(R.canCreate, ctx.user)
1069
- if (!allowed) continue
1070
- let replica: Record<string, unknown> = {}
1071
- for (const [k, v] of Object.entries(source as Record<string, unknown>)) {
1072
- if (skip.has(k)) continue
1073
- replica[k] = v
1074
- }
1075
- if (opts.beforeReplicaSaved) {
1076
- try { replica = await opts.beforeReplicaSaved(replica, source) }
1077
- catch { continue }
1078
- }
1079
- try { await M.create(replica); n++ } catch { /* skip — agg notify shows total */ }
1080
- }
1081
- const defaultTitle = `${n} ${labelForCount(R, n)} replicated`
1082
- const overrideTitle = opts.getCreatedNotificationTitle
1083
- ? await opts.getCreatedNotificationTitle({ count: n, records })
1084
- : undefined
1085
- const title = overrideTitle !== undefined ? overrideTitle : defaultTitle
1086
- return { notify: { title, type: 'success' } as never }
1087
- })
1088
- .visible(({ user }) => callPredicate(R.canCreate, user))
691
+ return bulkReplicateAction(R, _basePath, opts)
1089
692
  }
1090
693
 
1091
694
  // ─── Import / Export factories ────────────────────────────────
@@ -1263,138 +866,59 @@ export class Action extends Element {
1263
866
 
1264
867
  /** Relation create-action factory — link to
1265
868
  * `${base}/${parentSlug}/${parentId}/${relationship}/create`.
1266
- *
1267
- * Visibility delegates to `M.canCreate(user, parentRecord)` (or the
1268
- * related Resource's `canCreate(user)` when the manager hasn't
1269
- * overridden). Drop into `headerActions([...])` from inside
1270
- * `RelationManager.table(table, ctx)`.
869
+ * Visibility delegates to `M.canCreate(user, parentRecord)` with
870
+ * fall-through to the related Resource's `canCreate(user)`.
1271
871
  */
1272
872
  static relationCreate(
1273
873
  M: typeof RelationManager,
1274
874
  ctx: RelationManagerContext,
1275
875
  ): Action {
1276
- const labelSingular = M.getLabelSingular()
1277
- return Action.make('create')
1278
- .label(`New ${labelSingular}`)
1279
- .href(`${relationUrlPrefix(ctx)}/create`)
1280
- .visible(({ user }) => {
1281
- // M2M managers don't have a per-pivot-row create surface — the
1282
- // related record is created via its own Resource, then attached
1283
- // via `relationAttach`. Auto-hide so dropping this factory into
1284
- // any M2M manager (belongsToMany / morphToMany / morphedByMany)
1285
- // is a no-op (visible=false) instead of a 404-on-click foot-gun.
1286
- if (isM2MMode(ctx.mode)) return false
1287
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord)
1288
- })
876
+ return relationCreateAction(M, ctx)
1289
877
  }
1290
878
 
1291
879
  /** Relation edit-action factory — link to
1292
880
  * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/edit`.
1293
- *
1294
- * Same `recordId` semantics as `Action.edit`: omit for row context
1295
- * so the renderer substitutes `:id` per row; pass explicitly when
1296
- * building actions for a single-record context. Visibility delegates
1297
- * to `M.canEdit(user, child, parentRecord)` with fall-through to the
1298
- * related Resource's `canEdit(user, record)`.
1299
881
  */
1300
882
  static relationEdit(
1301
883
  M: typeof RelationManager,
1302
884
  ctx: RelationManagerContext,
1303
885
  recordId?: string,
1304
886
  ): Action {
1305
- const id = recordId ?? ':id'
1306
- return Action.make('edit')
1307
- .label('Edit')
1308
- .href(`${relationUrlPrefix(ctx)}/${id}/edit`)
1309
- .visible(({ user, record }) => {
1310
- // M2M: per-pivot-row "edit" doesn't exist; users edit the
1311
- // related record via its own Resource. Auto-hide for every M2M
1312
- // mode (belongsToMany / morphToMany / morphedByMany).
1313
- if (isM2MMode(ctx.mode)) return false
1314
- return safeManagerPolicy(M, 'canEdit', ctx.related, user, ctx.parentRecord, record)
1315
- })
887
+ return relationEditAction(M, ctx, recordId)
1316
888
  }
1317
889
 
1318
890
  /** Relation delete-action factory — POST to
1319
- * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/delete`,
1320
- * destructive style with a labeled confirmation. Visibility delegates
1321
- * to `M.canDelete(user, child, parentRecord)` with fall-through to the
1322
- * related Resource's `canDelete(user, record)`.
891
+ * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/delete`.
1323
892
  */
1324
893
  static relationDelete(
1325
894
  M: typeof RelationManager,
1326
895
  ctx: RelationManagerContext,
1327
896
  recordId?: string,
1328
897
  ): Action {
1329
- const id = recordId ?? ':id'
1330
- const singular = M.getLabelSingular().toLowerCase()
1331
- return Action.make('delete')
1332
- .label('Delete')
1333
- .destructive()
1334
- .method('post')
1335
- .action(`${relationUrlPrefix(ctx)}/${id}/delete`)
1336
- .confirm(`Delete this ${singular}?`)
1337
- .visible(async ({ user, record }) => {
1338
- // M2M: "delete" of the related record is destructive in a way
1339
- // that "detach" isn't — surface only `relationDetach` on every
1340
- // M2M manager (belongsToMany / morphToMany / morphedByMany).
1341
- // Users who genuinely want to delete the related record reach
1342
- // for `Action.delete(R)` on the related Resource instead.
1343
- if (isM2MMode(ctx.mode)) return false
1344
- if (ctx.related?.softDeletes && isTrashed(record, ctx.related as ResourceLike)) return false
1345
- return safeManagerPolicy(M, 'canDelete', ctx.related, user, ctx.parentRecord, record)
1346
- })
898
+ return relationDeleteAction(M, ctx, recordId)
1347
899
  }
1348
900
 
1349
901
  /**
1350
902
  * Plan #13 polish — Restore factory for relation managers. POSTs to
1351
- * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/restore`,
1352
- * success-styled, no confirm prompt. Auto-hides on live (non-trashed)
1353
- * rows AND when `M.canRestore` (or related Resource fall-through)
1354
- * denies. Drop into `recordActions([...])` from `RelationManager.table(table, ctx)`.
903
+ * the relation-restore route. Auto-hides on live rows + policy denies.
1355
904
  */
1356
905
  static relationRestore(
1357
906
  M: typeof RelationManager,
1358
907
  ctx: RelationManagerContext,
1359
908
  recordId?: string,
1360
909
  ): Action {
1361
- const id = recordId ?? ':id'
1362
- return Action.make('restore')
1363
- .label('Restore')
1364
- .color('success')
1365
- .method('post')
1366
- .action(`${relationUrlPrefix(ctx)}/${id}/restore`)
1367
- .visible(async ({ user, record }) => {
1368
- if (!ctx.related?.softDeletes) return false
1369
- if (!isTrashed(record, ctx.related as ResourceLike)) return false
1370
- return safeManagerPolicy(M, 'canRestore', ctx.related, user, ctx.parentRecord, record)
1371
- })
910
+ return relationRestoreAction(M, ctx, recordId)
1372
911
  }
1373
912
 
1374
913
  /**
1375
- * Plan #13 polish — Force-delete factory for relation managers. POSTs
1376
- * to `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/force-delete`,
1377
- * destructive style with a permanence-aware confirmation. Auto-hides on
1378
- * live (non-trashed) rows and when policy denies.
914
+ * Plan #13 polish — Force-delete factory for relation managers.
1379
915
  */
1380
916
  static relationForceDelete(
1381
917
  M: typeof RelationManager,
1382
918
  ctx: RelationManagerContext,
1383
919
  recordId?: string,
1384
920
  ): Action {
1385
- const id = recordId ?? ':id'
1386
- const singular = M.getLabelSingular().toLowerCase()
1387
- return Action.make('forceDelete')
1388
- .label('Delete forever')
1389
- .destructive()
1390
- .method('post')
1391
- .action(`${relationUrlPrefix(ctx)}/${id}/force-delete`)
1392
- .confirm(`Permanently delete this ${singular}? This cannot be undone.`)
1393
- .visible(async ({ user, record }) => {
1394
- if (!ctx.related?.softDeletes) return false
1395
- if (!isTrashed(record, ctx.related as ResourceLike)) return false
1396
- return safeManagerPolicy(M, 'canForceDelete', ctx.related, user, ctx.parentRecord, record)
1397
- })
921
+ return relationForceDeleteAction(M, ctx, recordId)
1398
922
  }
1399
923
 
1400
924
  // ─── Relation-manager replicate factories ─────────────────
@@ -1431,22 +955,9 @@ export class Action extends Element {
1431
955
 
1432
956
  /**
1433
957
  * Relation row-replicate factory. Clones the row's child record
1434
- * inside the manager's parent scope.
1435
- *
1436
- * Strips the related model's primary key, soft-delete column, and
1437
- * `opts.excludeAttributes`. Re-applies the parent attachment columns
1438
- * after the strip + before the optional `beforeReplicaSaved` hook,
1439
- * so user code can still mutate non-FK fields without accidentally
1440
- * unlinking the replica.
1441
- *
1442
- * On success the manager-scoped route falls back to the manager
1443
- * list URL (`${base}/${parentSlug}/${parentId}/${relationship}`)
1444
- * because no explicit `redirect` is returned — same default as the
1445
- * other handler-style relation factories.
1446
- *
1447
- * `recordId` kept in the signature for parity with the rest of the
1448
- * relation factory family. The dispatcher resolves the source row
1449
- * from the request body, so it isn't referenced here.
958
+ * inside the manager's parent scope. Strips PK + soft-delete column
959
+ * + `opts.excludeAttributes`, then force-pins the parent attachment
960
+ * columns before optional `beforeReplicaSaved` runs.
1450
961
  */
1451
962
  static relationReplicate(
1452
963
  M: typeof RelationManager,
@@ -1454,68 +965,19 @@ export class Action extends Element {
1454
965
  recordId?: string,
1455
966
  opts: ReplicateOptions = {},
1456
967
  ): Action {
1457
- void recordId
1458
- return Action.make('relationReplicate')
1459
- .label('Replicate')
1460
- .row()
1461
- .handler(async (hctx) => {
1462
- const result = await runRelationReplicateRow(M, ctx, hctx, opts)
1463
- return result
1464
- })
1465
- .visible(({ user }) => {
1466
- if (isM2MMode(ctx.mode) || ctx.mode === 'morphTo') return false
1467
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord)
1468
- })
968
+ return relationReplicateAction(M, ctx, recordId, opts)
1469
969
  }
1470
970
 
1471
971
  /**
1472
972
  * Bulk sibling — replicates every selected child row inside the
1473
- * manager's parent scope. Same strip + force-pin pipeline applied
1474
- * per row. Per-row `safeManagerPolicy(M, 'canCreate', …)` runs
1475
- * inside the loop so a partially-permitted selection still proceeds
1476
- * for the rows that pass. Rows that throw are skipped silently —
1477
- * the toast count reflects only successful creates.
973
+ * manager's parent scope. Same strip + force-pin pipeline per row.
1478
974
  */
1479
975
  static relationBulkReplicate(
1480
976
  M: typeof RelationManager,
1481
977
  ctx: RelationManagerContext,
1482
978
  opts: ReplicateOptions = {},
1483
979
  ): Action {
1484
- return Action.make('relationBulkReplicate')
1485
- .label('Replicate selected')
1486
- .bulk()
1487
- .confirm(`Replicate the selected ${M.getLabel().toLowerCase()}?`)
1488
- .handler(async (hctx) => {
1489
- const Related = ctx.related
1490
- if (!Related?.model || typeof Related.model.create !== 'function') {
1491
- return { notify: { title: 'Replicate not configured (related Resource has no model.create)', type: 'error' } as never }
1492
- }
1493
- const records = hctx.records ?? []
1494
- let n = 0
1495
- for (const source of records) {
1496
- if (!source || typeof source !== 'object') continue
1497
- const allowed = await safeManagerPolicy(M, 'canCreate', Related, hctx.user, ctx.parentRecord)
1498
- if (!allowed) continue
1499
- try {
1500
- await persistRelationReplica(M, ctx, source, opts)
1501
- n++
1502
- } catch { /* skip — agg notify shows total */ }
1503
- }
1504
- const labelPlural = M.getLabel().toLowerCase()
1505
- const labelSingular = M.getLabelSingular().toLowerCase()
1506
- const defaultTitle = `${n} ${n === 1 ? labelSingular : labelPlural} replicated`
1507
- const overrideTitle = opts.getCreatedNotificationTitle
1508
- ? await opts.getCreatedNotificationTitle({ count: n, records })
1509
- : undefined
1510
- const title = overrideTitle !== undefined ? overrideTitle : defaultTitle
1511
- return {
1512
- notify: { title, type: 'success' } as never,
1513
- }
1514
- })
1515
- .visible(({ user }) => {
1516
- if (isM2MMode(ctx.mode) || ctx.mode === 'morphTo') return false
1517
- return safeManagerPolicy(M, 'canCreate', ctx.related, user, ctx.parentRecord)
1518
- })
980
+ return relationBulkReplicateAction(M, ctx, opts)
1519
981
  }
1520
982
 
1521
983
  // ─── M2M relation factories ───────────────────────────────
@@ -1542,139 +1004,33 @@ export class Action extends Element {
1542
1004
  /** Header-placement attach factory — opens a modal with a SelectField
1543
1005
  * listing related records that aren't already attached, and POSTs the
1544
1006
  * selected id to the manager's `_action/relationAttach` endpoint.
1545
- *
1546
1007
  * Visibility delegates to `M.canAttach(user, parentRecord)` AND
1547
1008
  * guards against being dropped into a non-M2M manager. */
1548
1009
  static relationAttach(
1549
1010
  M: typeof RelationManager,
1550
1011
  ctx: RelationManagerContext,
1551
1012
  ): Action {
1552
- const labelSingular = M.getLabelSingular()
1553
- const a = Action.make('relationAttach')
1554
- .label(`Attach ${labelSingular}`)
1555
- .header()
1556
- .modalHeading(`Attach ${labelSingular}`)
1557
- .modalSubmitLabel('Attach')
1558
- .modalCancelLabel('Cancel')
1559
- .handler(async (hctx) => {
1560
- const rel = hctx.relation
1561
- if (!rel) {
1562
- return { notify: { title: 'Attach handler missing parent context — manager-scoped _action route not wired', type: 'error' } as never }
1563
- }
1564
- const Related = ctx.related
1565
- if (!Related?.model) {
1566
- return { notify: { title: 'Cannot attach: related Resource has no model', type: 'error' } as never }
1567
- }
1568
- const idStr = String((hctx.values?.['_attachId'] as unknown) ?? '')
1569
- if (idStr.length === 0) {
1570
- return { notify: { title: 'Pick a record to attach', type: 'error' } as never }
1571
- }
1572
- const accessor = resolveM2MAccessor(rel.parent, rel.relationship)
1573
- if (!accessor || typeof accessor.attach !== 'function') {
1574
- return { notify: { title: `Pivot accessor missing on ${rel.relationship} — wrong relation type or ORM version?`, type: 'error' } as never }
1575
- }
1576
- try {
1577
- await accessor.attach([idStr])
1578
- } catch (err) {
1579
- return { notify: { title: `Attach failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } as never }
1580
- }
1581
- return { notify: { title: `${labelSingular} attached`, type: 'success' } as never }
1582
- })
1583
- .visible(({ user }) => {
1584
- if (!isM2MMode(ctx.mode)) return false
1585
- return safeManagerPolicy(M, 'canAttach', ctx.related, user, ctx.parentRecord)
1586
- })
1587
-
1588
- // Build the modal-form schema only when this is actually an M2M
1589
- // manager — non-M2M drops keep the action hidden via the visibility
1590
- // predicate, but still need a schema-less Action so the meta walker
1591
- // doesn't blow up. Static import is fine: `attachFactory` only
1592
- // depends on `SelectField` + ORM helpers, no cycle back to Action.
1593
- if (isM2MMode(ctx.mode) && ctx.related?.model) {
1594
- a.schema(buildAttachModalSchema({
1595
- Related: ctx.related,
1596
- relationship: ctx.relationship,
1597
- recordTitleAttr: M.getRecordTitleAttribute() ?? ctx.related.recordTitleAttribute,
1598
- labelSingular,
1599
- }))
1600
- }
1601
- return a
1013
+ return relationAttachAction(M, ctx)
1602
1014
  }
1603
1015
 
1604
1016
  /** Row-placement detach factory — POSTs to
1605
1017
  * `${base}/${parentSlug}/${parentId}/${relationship}/${recordId ?? ':id'}/_detach`,
1606
- * destructive style with a confirmation prompt that says "Detach"
1607
- * (not "Delete") so users understand the target record stays.
1608
- * Visibility delegates to `M.canDetach`. */
1018
+ * destructive style with a "Detach" confirmation. */
1609
1019
  static relationDetach(
1610
1020
  M: typeof RelationManager,
1611
1021
  ctx: RelationManagerContext,
1612
1022
  recordId?: string,
1613
1023
  ): Action {
1614
- const id = recordId ?? ':id'
1615
- const singular = M.getLabelSingular().toLowerCase()
1616
- return Action.make('relationDetach')
1617
- .label('Detach')
1618
- .destructive()
1619
- .method('post')
1620
- .action(`${relationUrlPrefix(ctx)}/${id}/_detach`)
1621
- .confirm(`Detach this ${singular}? The ${singular} record stays in place; only the link is removed.`)
1622
- .visible(async ({ user, record }) => {
1623
- if (!isM2MMode(ctx.mode)) return false
1624
- return safeManagerPolicy(M, 'canDetach', ctx.related, user, ctx.parentRecord, record)
1625
- })
1024
+ return relationDetachAction(M, ctx, recordId)
1626
1025
  }
1627
1026
 
1628
1027
  /** Bulk-placement bulk-detach factory — handler-dispatched. Calls
1629
- * `parent.related(rel).detach(ids)` for the selected rows. Visibility
1630
- * delegates to `M.canAttach` (acts like a "manager admin" gate; we
1631
- * intentionally don't enforce per-row `canDetach` on the visibility
1632
- * side because the bulk button needs to be visible before the user
1633
- * has selected anything — per-row gating happens inside the handler). */
1028
+ * `parent.related(rel).detach(ids)` for the selected rows. */
1634
1029
  static relationBulkDetach(
1635
1030
  M: typeof RelationManager,
1636
1031
  ctx: RelationManagerContext,
1637
1032
  ): Action {
1638
- const labelPlural = M.getLabel().toLowerCase()
1639
- return Action.make('relationBulkDetach')
1640
- .label('Detach selected')
1641
- .destructive()
1642
- .bulk()
1643
- .confirm(`Detach the selected ${labelPlural}? The records stay in place; only the links are removed.`)
1644
- .handler(async (hctx) => {
1645
- const rel = hctx.relation
1646
- if (!rel) {
1647
- return { notify: { title: 'Bulk-detach handler missing parent context — manager-scoped _action route not wired', type: 'error' } as never }
1648
- }
1649
- const records = hctx.records ?? []
1650
- const ids: string[] = []
1651
- for (const r of records) {
1652
- const id = String((r as { id?: unknown }).id ?? '')
1653
- if (!id) continue
1654
- const allowed = await safeManagerPolicy(M, 'canDetach', ctx.related, hctx.user, ctx.parentRecord, r)
1655
- if (!allowed) continue
1656
- ids.push(id)
1657
- }
1658
- if (ids.length === 0) {
1659
- return { notify: { title: 'Nothing to detach (no permitted rows)', type: 'warning' } as never }
1660
- }
1661
- const accessor = resolveM2MAccessor(rel.parent, rel.relationship)
1662
- if (!accessor || typeof accessor.detach !== 'function') {
1663
- return { notify: { title: `Pivot accessor missing on ${rel.relationship} — wrong relation type or ORM version?`, type: 'error' } as never }
1664
- }
1665
- try {
1666
- await accessor.detach(ids)
1667
- } catch (err) {
1668
- return { notify: { title: `Bulk detach failed: ${err instanceof Error ? err.message : String(err)}`, type: 'error' } as never }
1669
- }
1670
- return { notify: { title: `${ids.length} ${labelPlural} detached`, type: 'success' } as never }
1671
- })
1672
- .visible(({ user }) => {
1673
- if (!isM2MMode(ctx.mode)) return false
1674
- // Bulk gate uses canAttach as a stand-in for "manager admin" —
1675
- // per-row canDetach is enforced inside the handler.
1676
- return safeManagerPolicy(M, 'canAttach', ctx.related, user, ctx.parentRecord)
1677
- })
1033
+ return relationBulkDetachAction(M, ctx)
1678
1034
  }
1679
1035
 
1680
1036
  label(l: string): this { this._label = l; return this }