@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.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +142 -0
- package/CLAUDE.md +59 -3
- package/dist/Pilotiq.d.ts +83 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +39 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/actions/Action.d.ts +27 -99
- package/dist/actions/Action.d.ts.map +1 -1
- package/dist/actions/Action.js +52 -754
- package/dist/actions/Action.js.map +1 -1
- package/dist/actions/bulkFactories.d.ts +46 -0
- package/dist/actions/bulkFactories.d.ts.map +1 -0
- package/dist/actions/bulkFactories.js +144 -0
- package/dist/actions/bulkFactories.js.map +1 -0
- package/dist/actions/crudFactories.d.ts +94 -0
- package/dist/actions/crudFactories.d.ts.map +1 -0
- package/dist/actions/crudFactories.js +209 -0
- package/dist/actions/crudFactories.js.map +1 -0
- package/dist/actions/factoryHelpers.d.ts +108 -0
- package/dist/actions/factoryHelpers.d.ts.map +1 -0
- package/dist/actions/factoryHelpers.js +138 -0
- package/dist/actions/factoryHelpers.js.map +1 -0
- package/dist/actions/m2mFactories.d.ts +47 -0
- package/dist/actions/m2mFactories.d.ts.map +1 -0
- package/dist/actions/m2mFactories.js +173 -0
- package/dist/actions/m2mFactories.js.map +1 -0
- package/dist/actions/relationFactories.d.ts +93 -0
- package/dist/actions/relationFactories.d.ts.map +1 -0
- package/dist/actions/relationFactories.js +321 -0
- package/dist/actions/relationFactories.js.map +1 -0
- package/dist/elements/dispatchForm.js +1 -1
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.js +1 -1
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +31 -0
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +25 -0
- package/dist/fields/Field.js.map +1 -1
- package/dist/pageData/breadcrumbs.d.ts +42 -0
- package/dist/pageData/breadcrumbs.d.ts.map +1 -0
- package/dist/pageData/breadcrumbs.js +172 -0
- package/dist/pageData/breadcrumbs.js.map +1 -0
- package/dist/pageData/forms.d.ts +137 -0
- package/dist/pageData/forms.d.ts.map +1 -0
- package/dist/pageData/forms.js +427 -0
- package/dist/pageData/forms.js.map +1 -0
- package/dist/pageData/helpers.d.ts +239 -0
- package/dist/pageData/helpers.d.ts.map +1 -0
- package/dist/pageData/helpers.js +703 -0
- package/dist/pageData/helpers.js.map +1 -0
- package/dist/pageData/misc.d.ts +76 -0
- package/dist/pageData/misc.d.ts.map +1 -0
- package/dist/pageData/misc.js +263 -0
- package/dist/pageData/misc.js.map +1 -0
- package/dist/pageData/navigation.d.ts +292 -0
- package/dist/pageData/navigation.d.ts.map +1 -0
- package/dist/pageData/navigation.js +591 -0
- package/dist/pageData/navigation.js.map +1 -0
- package/dist/pageData/relationPages.d.ts +172 -0
- package/dist/pageData/relationPages.d.ts.map +1 -0
- package/dist/pageData/relationPages.js +867 -0
- package/dist/pageData/relationPages.js.map +1 -0
- package/dist/pageData/relationTabs.d.ts +65 -0
- package/dist/pageData/relationTabs.d.ts.map +1 -0
- package/dist/pageData/relationTabs.js +258 -0
- package/dist/pageData/relationTabs.js.map +1 -0
- package/dist/pageData/resourcePages.d.ts +48 -0
- package/dist/pageData/resourcePages.d.ts.map +1 -0
- package/dist/pageData/resourcePages.js +504 -0
- package/dist/pageData/resourcePages.js.map +1 -0
- package/dist/pageData.d.ts +12 -792
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +24 -3797
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +8 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +11 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
- package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
- package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
- package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
- package/dist/react/CollabRoomContext.d.ts +37 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -0
- package/dist/react/CollabRoomContext.js +12 -0
- package/dist/react/CollabRoomContext.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
- package/dist/react/FormCollabBindingRegistry.js +14 -0
- package/dist/react/FormCollabBindingRegistry.js.map +1 -0
- package/dist/react/RecordWrapperGate.d.ts +25 -0
- package/dist/react/RecordWrapperGate.d.ts.map +1 -0
- package/dist/react/RecordWrapperGate.js +30 -0
- package/dist/react/RecordWrapperGate.js.map +1 -0
- package/dist/react/RecordWrapperRegistry.d.ts +31 -0
- package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
- package/dist/react/RecordWrapperRegistry.js +15 -0
- package/dist/react/RecordWrapperRegistry.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts +17 -23
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +71 -3647
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/component-slots.d.ts +103 -0
- package/dist/react/component-slots.d.ts.map +1 -0
- package/dist/react/component-slots.js +18 -0
- package/dist/react/component-slots.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +21 -117
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +1 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +22 -127
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/rowState.d.ts +40 -0
- package/dist/react/fields/rowState.d.ts.map +1 -0
- package/dist/react/fields/rowState.js +60 -0
- package/dist/react/fields/rowState.js.map +1 -0
- package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
- package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
- package/dist/react/fields/useRowReorderDnd.js +51 -0
- package/dist/react/fields/useRowReorderDnd.js.map +1 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +8 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
- package/dist/react/layouts/SidebarLayout.js +10 -2
- package/dist/react/layouts/SidebarLayout.js.map +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
- package/dist/react/layouts/TopbarLayout.js +19 -11
- package/dist/react/layouts/TopbarLayout.js.map +1 -1
- package/dist/react/parseRecordEditUrl.d.ts +29 -0
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
- package/dist/react/parseRecordEditUrl.js +25 -0
- package/dist/react/parseRecordEditUrl.js.map +1 -0
- package/dist/react/persistedState.d.ts +19 -0
- package/dist/react/persistedState.d.ts.map +1 -0
- package/dist/react/persistedState.js +51 -0
- package/dist/react/persistedState.js.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
- package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
- package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
- package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.js +147 -0
- package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
- package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
- package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.js +74 -0
- package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.js +126 -0
- package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.js +102 -0
- package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.js +76 -0
- package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
- package/dist/react/schemaRenderer/constants.d.ts +8 -0
- package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
- package/dist/react/schemaRenderer/constants.js +45 -0
- package/dist/react/schemaRenderer/constants.js.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.js +239 -0
- package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
- package/dist/react/schemaRenderer/helpers.d.ts +32 -0
- package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/helpers.js +52 -0
- package/dist/react/schemaRenderer/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
- package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/filters.js +497 -0
- package/dist/react/schemaRenderer/table/filters.js.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.js +172 -0
- package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
- package/dist/react/schemaRenderer/table/links.d.ts +42 -0
- package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/links.js +55 -0
- package/dist/react/schemaRenderer/table/links.js.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
- package/dist/react/schemaRenderer/table/url.d.ts +41 -0
- package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/url.js +114 -0
- package/dist/react/schemaRenderer/table/url.js.map +1 -0
- package/dist/routes/globals.d.ts +13 -0
- package/dist/routes/globals.d.ts.map +1 -0
- package/dist/routes/globals.js +131 -0
- package/dist/routes/globals.js.map +1 -0
- package/dist/routes/helpers.d.ts +217 -0
- package/dist/routes/helpers.d.ts.map +1 -0
- package/dist/routes/helpers.js +498 -0
- package/dist/routes/helpers.js.map +1 -0
- package/dist/routes/pages.d.ts +15 -0
- package/dist/routes/pages.d.ts.map +1 -0
- package/dist/routes/pages.js +145 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/routes/panel.d.ts +19 -0
- package/dist/routes/panel.d.ts.map +1 -0
- package/dist/routes/panel.js +191 -0
- package/dist/routes/panel.js.map +1 -0
- package/dist/routes/relations.d.ts +21 -0
- package/dist/routes/relations.d.ts.map +1 -0
- package/dist/routes/relations.js +1239 -0
- package/dist/routes/relations.js.map +1 -0
- package/dist/routes/resources.d.ts +28 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +741 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/theme.d.ts +12 -0
- package/dist/routes/theme.d.ts.map +1 -0
- package/dist/routes/theme.js +82 -0
- package/dist/routes/theme.js.map +1 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +64 -3078
- package/dist/routes.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -5
- package/dist/vite.js.map +1 -1
- package/package.json +2 -1
- package/src/Pilotiq.ts +95 -0
- package/src/actions/Action.ts +79 -723
- package/src/actions/bulkFactories.ts +168 -0
- package/src/actions/crudFactories.ts +220 -0
- package/src/actions/factoryHelpers.ts +177 -0
- package/src/actions/m2mFactories.ts +193 -0
- package/src/actions/relationFactories.ts +372 -0
- package/src/elements/dispatchForm.ts +1 -1
- package/src/elements/dispatchTable.ts +1 -1
- package/src/fields/Field.ts +39 -0
- package/src/pageData/breadcrumbs.ts +288 -0
- package/src/pageData/forms.ts +578 -0
- package/src/pageData/helpers.ts +764 -0
- package/src/pageData/misc.ts +347 -0
- package/src/pageData/navigation.ts +779 -0
- package/src/pageData/relationPages.ts +1246 -0
- package/src/pageData/relationTabs.ts +286 -0
- package/src/pageData/resourcePages.ts +593 -0
- package/src/pageData.ts +122 -4731
- package/src/react/AppShell.tsx +27 -1
- package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
- package/src/react/CollabRoomContext.ts +42 -0
- package/src/react/FormCollabBindingRegistry.ts +72 -0
- package/src/react/RecordWrapperGate.tsx +40 -0
- package/src/react/RecordWrapperRegistry.ts +39 -0
- package/src/react/SchemaRenderer.tsx +230 -6479
- package/src/react/component-slots.test.ts +103 -0
- package/src/react/component-slots.ts +116 -0
- package/src/react/fields/BuilderInput.tsx +29 -117
- package/src/react/fields/MarkdownInput.tsx +0 -1
- package/src/react/fields/RepeaterInput.tsx +29 -130
- package/src/react/fields/rowState.ts +106 -0
- package/src/react/fields/useRowReorderDnd.ts +78 -0
- package/src/react/index.ts +38 -0
- package/src/react/layouts/SidebarLayout.tsx +39 -28
- package/src/react/layouts/TopbarLayout.tsx +70 -57
- package/src/react/parseRecordEditUrl.test.ts +75 -0
- package/src/react/parseRecordEditUrl.ts +55 -0
- package/src/react/persistedState.ts +40 -0
- package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
- package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
- package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
- package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
- package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
- package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
- package/src/react/schemaRenderer/action/buttons.tsx +99 -0
- package/src/react/schemaRenderer/action/helpers.ts +140 -0
- package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
- package/src/react/schemaRenderer/columnFormat.ts +65 -0
- package/src/react/schemaRenderer/constants.ts +50 -0
- package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
- package/src/react/schemaRenderer/form/renderField.tsx +511 -0
- package/src/react/schemaRenderer/helpers.tsx +81 -0
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
- package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
- package/src/react/schemaRenderer/table/filters.tsx +1233 -0
- package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
- package/src/react/schemaRenderer/table/links.tsx +112 -0
- package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
- package/src/react/schemaRenderer/table/url.tsx +143 -0
- package/src/routes/globals.ts +154 -0
- package/src/routes/helpers.ts +668 -0
- package/src/routes/pages.ts +173 -0
- package/src/routes/panel.ts +204 -0
- package/src/routes/relations.ts +1219 -0
- package/src/routes/resources.ts +786 -0
- package/src/routes/theme.ts +109 -0
- package/src/routes.test.ts +1 -1
- package/src/routes.ts +64 -3176
- package/src/schema/TableWidget.test.ts +2 -2
- package/src/theme/migrate.test.ts +178 -0
- package/src/vite.test.ts +184 -0
- package/src/vite.ts +26 -4
package/src/actions/Action.ts
CHANGED
|
@@ -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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
createAction,
|
|
9
|
+
deleteAction,
|
|
10
|
+
editAction,
|
|
11
|
+
forceDeleteAction,
|
|
12
|
+
markAsReadAction,
|
|
13
|
+
replicateAction,
|
|
14
|
+
restoreAction,
|
|
15
|
+
viewAction,
|
|
16
|
+
} from './crudFactories.js'
|
|
9
17
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from '
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
786
|
-
*
|
|
787
|
-
*
|
|
788
|
-
*
|
|
789
|
-
*
|
|
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
|
-
|
|
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
|
|
868
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
955
|
-
* deleted" depending on `R.softDeletes`. */
|
|
666
|
+
* `deletedAt`. */
|
|
956
667
|
static bulkDelete(R: ResourceLike, _basePath: string): Action {
|
|
957
|
-
return
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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`.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 }
|