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