@pilotiq/pilotiq 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +154 -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 +31 -10
- 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 +31 -9
package/src/pageData.ts
CHANGED
|
@@ -9,4742 +9,133 @@
|
|
|
9
9
|
* data needs to come from the same builder. Routing both paths through a
|
|
10
10
|
* single builder keeps them in sync.
|
|
11
11
|
*/
|
|
12
|
-
import type { Pilotiq, PilotiqConfig } from './Pilotiq.js'
|
|
13
12
|
import { PilotiqRegistry } from './PilotiqRegistry.js'
|
|
14
|
-
import type { Page } from './Page.js'
|
|
15
|
-
import type { ResourceClass, NavigationBadgeColor } from './Resource.js'
|
|
16
|
-
import type { GlobalClass } from './Global.js'
|
|
17
|
-
import { resourceBasePath, globalBasePath, pageBasePath, clusterBasePath } from './clusterPaths.js'
|
|
18
|
-
import type { ClusterClass } from './Cluster.js'
|
|
19
|
-
import { Element, type ElementMeta } from './schema/Element.js'
|
|
20
|
-
import { Field } from './fields/Field.js'
|
|
21
|
-
import { resolveSchema, type RenderContext, type SchemaContext } from './schema/resolveSchema.js'
|
|
22
|
-
import { isServerDataElement, type ServerDataElement } from './schema/ServerDataElement.js'
|
|
23
|
-
import { Form } from './elements/Form.js'
|
|
24
|
-
import { Table } from './elements/Table.js'
|
|
25
|
-
import { Column } from './Column.js'
|
|
26
|
-
import { applyStateUpdate, coerceFormValues, findForms, findWizardStep, loadRelationRows, selectFormById } from './elements/dispatchForm.js'
|
|
27
|
-
import { isRepeaterField, RepeaterField } from './fields/RepeaterField.js'
|
|
28
|
-
import { isBuilderField, BuilderField } from './fields/BuilderField.js'
|
|
29
|
-
import { SelectField } from './fields/SelectField.js'
|
|
30
|
-
import { validateSchema } from './validation/index.js'
|
|
31
|
-
import { searchAllResources, type GlobalSearchResult } from './search.js'
|
|
32
|
-
import { loadTableRecords, findTables, type QueryParams } from './elements/dispatchTable.js'
|
|
33
|
-
import { findActions, findRowExtraActions } from './elements/dispatchAction.js'
|
|
34
|
-
import { Filter } from './filters/Filter.js'
|
|
35
|
-
import { TrashedFilter } from './filters/TrashedFilter.js'
|
|
36
|
-
import { ListTabs } from './elements/ListTabs.js'
|
|
37
|
-
import { ListTab } from './Tab.js'
|
|
38
|
-
import { resolveTheme } from './theme/resolve.js'
|
|
39
|
-
import type { ThemeMeta } from './theme/types.js'
|
|
40
|
-
import { consumeFlashedNotifications } from './notifications/flash.js'
|
|
41
|
-
import {
|
|
42
|
-
notificationChannel,
|
|
43
|
-
NOTIFICATION_CREATED_EVENT,
|
|
44
|
-
} from './notifications/broadcast.js'
|
|
45
|
-
import { serializeIcon, type SerializedIcon, type IconValue } from './icons/types.js'
|
|
46
|
-
import {
|
|
47
|
-
RIGHT_PANEL_DEFAULT_WIDTH,
|
|
48
|
-
RIGHT_PANEL_MIN_WIDTH,
|
|
49
|
-
RIGHT_PANEL_MAX_WIDTH,
|
|
50
|
-
} from './RightPanel.js'
|
|
51
|
-
import type { UserMenuItemMeta } from './UserMenuItem.js'
|
|
52
|
-
import {
|
|
53
|
-
RelationManager,
|
|
54
|
-
safeManagerPolicy as safeManagerPolicyImpl,
|
|
55
|
-
type ManagerCanMethod as ManagerCanMethodType,
|
|
56
|
-
type RelationManagerContext,
|
|
57
|
-
} from './RelationManager.js'
|
|
58
|
-
import { RelationTabs, relationTab, type RelationTabMeta } from './schema/RelationTabs.js'
|
|
59
|
-
import { Breadcrumbs, type BreadcrumbItem } from './schema/Breadcrumbs.js'
|
|
60
|
-
import {
|
|
61
|
-
resolveRenderHooks,
|
|
62
|
-
CHROME_HOOK_NAMES,
|
|
63
|
-
type RenderHookContext,
|
|
64
|
-
type RenderHookMap,
|
|
65
|
-
type RenderHookName,
|
|
66
|
-
} from './RenderHook.js'
|
|
67
|
-
import { applyPageHooks, pageHooksFor, type PageRole } from './applyPageHooks.js'
|
|
68
|
-
import {
|
|
69
|
-
modelSave, modelLoadRecord, modelRelationTableRecords, findRecord, getPrimaryKey,
|
|
70
|
-
getRelationType,
|
|
71
|
-
getMorphRelationDescriptor,
|
|
72
|
-
type ModelLike, type ModelQuery,
|
|
73
|
-
} from './orm/modelDefaults.js'
|
|
74
|
-
import { normalizeRelationMode, type RelationMode } from './RelationManager.js'
|
|
75
|
-
|
|
76
|
-
// ─── Shared helpers ──────────────────────────────────────────
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Top-right user dropdown shipped to the renderer in `viewProps.panel`.
|
|
80
|
-
* `null` when no `Pilotiq.user(req => …)` resolver is configured or the
|
|
81
|
-
* resolver returns `null` (no logged-in user) — the renderer suppresses
|
|
82
|
-
* the dropdown entirely in that case.
|
|
83
|
-
*
|
|
84
|
-
* `user.name / user.email / user.avatar` are duck-typed off the
|
|
85
|
-
* resolver's return value; whichever fields are present round-trip into
|
|
86
|
-
* the dropdown trigger (initials fall back to the first two letters of
|
|
87
|
-
* `name` when no avatar URL is set).
|
|
88
|
-
*/
|
|
89
|
-
export interface UserMenuMeta {
|
|
90
|
-
user: { name?: string; email?: string; avatar?: string }
|
|
91
|
-
items: UserMenuItemMeta[]
|
|
92
|
-
signOut?: { url: string; label: string; method: 'POST' | 'GET' }
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Bell-icon dropdown configuration shipped under `viewProps.panel`. Sparse —
|
|
97
|
-
* absent when `Pilotiq.databaseNotifications()` wasn't called OR when no
|
|
98
|
-
* user resolves (anonymous request → no inbox to surface). Renderer mounts
|
|
99
|
-
* the bell only when this is set.
|
|
100
|
-
*
|
|
101
|
-
* Routes are absolute URLs (panel `basePath` already applied). Client
|
|
102
|
-
* substitutes `:id` per row when calling read / unread; `_widget`-style
|
|
103
|
-
* params aren't used here because the bell only ever issues these four
|
|
104
|
-
* fetch shapes.
|
|
105
|
-
*
|
|
106
|
-
* `polling` mirrors `DatabaseNotificationsConfig.polling` — `null` ships
|
|
107
|
-
* over the wire to disable client-side polling. The bell still fetches on
|
|
108
|
-
* mount + after every mark-read mutation.
|
|
109
|
-
*/
|
|
110
|
-
export interface DatabaseNotificationsMeta {
|
|
111
|
-
position: 'topbar' | 'sidebar'
|
|
112
|
-
polling: number | null
|
|
113
|
-
pageSize: number
|
|
114
|
-
badgeColor: NavigationBadgeColor
|
|
115
|
-
trigger?: { icon?: string; label?: string }
|
|
116
|
-
listUrl: string
|
|
117
|
-
readAllUrl: string
|
|
118
|
-
/** Template URL with literal `:id` placeholder. Client replaces. */
|
|
119
|
-
readUrl: string
|
|
120
|
-
/** Template URL with literal `:id` placeholder. Client replaces. */
|
|
121
|
-
unreadUrl: string
|
|
122
|
-
/**
|
|
123
|
-
* Template URL for the notification-action dispatch endpoint with
|
|
124
|
-
* literal `:id` and `:actionName` placeholders. Bell client builds
|
|
125
|
-
* per-action URLs by substituting both at render time. Used only by
|
|
126
|
-
* `handler`-mode actions; `url` / `post` actions ride their own URL
|
|
127
|
-
* verbatim.
|
|
128
|
-
*/
|
|
129
|
-
actionUrl: string
|
|
130
|
-
/**
|
|
131
|
-
* Phase 2 — broadcast hint. Sparse — absent when
|
|
132
|
-
* `databaseNotifications({ broadcast: true })` wasn't set OR when no
|
|
133
|
-
* resolved user has an `id` to scope the channel to.
|
|
134
|
-
*
|
|
135
|
-
* Client connects to `wsUrl` via `@rudderjs/broadcast`'s
|
|
136
|
-
* `RudderSocket`, subscribes to the `channel` (already includes the
|
|
137
|
-
* `private-` prefix), and listens for `event` to trigger refetches.
|
|
138
|
-
*/
|
|
139
|
-
broadcast?: {
|
|
140
|
-
wsUrl: string
|
|
141
|
-
channel: string
|
|
142
|
-
event: string
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Right-sidebar shipped under `viewProps.panel.rightSidebar`. Sparse —
|
|
148
|
-
* absent from `panelInfo()` when no contributions are registered, every
|
|
149
|
-
* registered contribution failed `canAccess(user)`, or every visible
|
|
150
|
-
* contribution is `hidden: true`. Renderer mounts the chrome only when
|
|
151
|
-
* this is set.
|
|
152
|
-
*
|
|
153
|
-
* The React component reference for each contribution does NOT travel
|
|
154
|
-
* here — only its tab-strip metadata. The actual body component is
|
|
155
|
-
* resolved client-side from the Vite plugin's `_components.ts` manifest
|
|
156
|
-
* keyed by contribution `id`, mirroring the icon-class round-trip.
|
|
157
|
-
*
|
|
158
|
-
* `defaultWidth` rolls up: contribution-level value when one
|
|
159
|
-
* contribution was registered with one, otherwise the panel-level
|
|
160
|
-
* baseline (`RIGHT_PANEL_DEFAULT_WIDTH`). Client also clamps
|
|
161
|
-
* localStorage values to `[minWidth, maxWidth]`.
|
|
162
|
-
*/
|
|
163
|
-
export interface RightPanelMeta {
|
|
164
|
-
id: string
|
|
165
|
-
label: string
|
|
166
|
-
icon?: SerializedIcon
|
|
167
|
-
defaultWidth: number
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export interface RightSidebarMeta {
|
|
171
|
-
panels: RightPanelMeta[]
|
|
172
|
-
defaultWidth: number
|
|
173
|
-
minWidth: number
|
|
174
|
-
maxWidth: number
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
/**
|
|
178
|
-
* Single nav-tree entry. `name` is the JS class name (`R.name` /
|
|
179
|
-
* `G.name` / `P.name`) — also the lookup key into the build-time
|
|
180
|
-
* `_components.ts` manifest the Vite plugin emits, so component-typed
|
|
181
|
-
* icons resolve from the same identifier.
|
|
182
|
-
*/
|
|
183
|
-
export interface NavItem {
|
|
184
|
-
name: string
|
|
185
|
-
label: string
|
|
186
|
-
url: string
|
|
187
|
-
icon?: SerializedIcon
|
|
188
|
-
group?: string
|
|
189
|
-
sort?: number
|
|
190
|
-
badge?: string
|
|
191
|
-
badgeColor?: NavigationBadgeColor
|
|
192
|
-
children?: NavItem[]
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Build the panel header summary + the unified navigation tree.
|
|
197
|
-
*
|
|
198
|
-
* Pipeline:
|
|
199
|
-
* 1. flatten resources + globals + pages into raw NavItem records
|
|
200
|
-
* 2. drop items whose `canAccess(user)` (Plan #10) returns false
|
|
201
|
-
* 3. resolve `navigationParentItem` references → nest under parents
|
|
202
|
-
* (cycles broken with a console warn; dangling parents render at top level)
|
|
203
|
-
* 4. sort within each grouping (top-level *and* every parent's children)
|
|
204
|
-
* by `navigationSort` ascending → registration order
|
|
205
|
-
* 5. resolve every `navigationBadge()` in parallel via `Promise.all`;
|
|
206
|
-
* handler errors are swallowed (badge omitted) so a flaky count
|
|
207
|
-
* never blanks the page
|
|
208
|
-
*
|
|
209
|
-
* `req` is the active request; pilotiq calls `pilotiq.resolveUser(req)`
|
|
210
|
-
* once and threads the user into every Resource/Global/Page `canAccess`
|
|
211
|
-
* check. When `Pilotiq.user(fn)` isn't configured, user is `null` and the
|
|
212
|
-
* default `canAccess` returns true → no items dropped.
|
|
213
|
-
*/
|
|
214
|
-
/**
|
|
215
|
-
* Optional route-context for `panelInfo()`. When set, render-hook
|
|
216
|
-
* `scope: { resource | page | global }` filters fire correctly for the
|
|
217
|
-
* active route. Missing keys mean the slot has no scope-able identifier
|
|
218
|
-
* (chrome-only routes); scope-less hooks still fire either way.
|
|
219
|
-
*
|
|
220
|
-
* `url` defaults to `cfg.path` when unset. `recordId` rides through to
|
|
221
|
-
* `RenderHookContext.recordId` for hooks that need it.
|
|
222
|
-
*/
|
|
223
|
-
export interface PanelInfoRoute {
|
|
224
|
-
resource?: ResourceClass
|
|
225
|
-
page?: typeof Page
|
|
226
|
-
global?: GlobalClass
|
|
227
|
-
recordId?: string
|
|
228
|
-
url?: string
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export async function panelInfo(
|
|
232
|
-
pilotiq: Pilotiq,
|
|
233
|
-
req?: unknown,
|
|
234
|
-
route: PanelInfoRoute = {},
|
|
235
|
-
) {
|
|
236
|
-
const cfg = pilotiq.getConfig()
|
|
237
|
-
const merged = pilotiq.getMergedTheme()
|
|
238
|
-
const theme: ThemeMeta | undefined = merged ? resolveTheme(merged) : undefined
|
|
239
|
-
const user = await pilotiq.resolveUser(req)
|
|
240
|
-
const [navigation, userMenu, renderHooks, rightSidebar] = await Promise.all([
|
|
241
|
-
buildNavigation(pilotiq, user),
|
|
242
|
-
buildUserMenu(pilotiq, user),
|
|
243
|
-
resolveChromeHooks(pilotiq, user, route),
|
|
244
|
-
buildRightSidebarMeta(cfg, user),
|
|
245
|
-
])
|
|
246
|
-
const databaseNotifications = buildDatabaseNotificationsMeta(cfg, user)
|
|
247
|
-
// AI suggestion mode — sparse: omit when 'auto' (the default) so the
|
|
248
|
-
// wire shape stays minimal for panels that don't opt into review mode.
|
|
249
|
-
// Plugin clients (e.g. @pilotiq-pro/ai's `AiClientToolBindings`) read
|
|
250
|
-
// this to decide whether to apply writes immediately or stage them as
|
|
251
|
-
// PendingSuggestions for user approval.
|
|
252
|
-
const aiSuggestionsMode = pilotiq.getAiSuggestionsMode()
|
|
253
|
-
return {
|
|
254
|
-
name: cfg.name,
|
|
255
|
-
branding: cfg.branding,
|
|
256
|
-
navigation,
|
|
257
|
-
theme,
|
|
258
|
-
themeEditor: cfg.themeEditor ?? false,
|
|
259
|
-
...(userMenu ? { userMenu } : {}),
|
|
260
|
-
...(databaseNotifications ? { databaseNotifications } : {}),
|
|
261
|
-
...(rightSidebar ? { rightSidebar } : {}),
|
|
262
|
-
...(Object.keys(renderHooks).length > 0 ? { renderHooks } : {}),
|
|
263
|
-
...(aiSuggestionsMode !== 'auto' ? { aiSuggestionsMode } : {}),
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
/**
|
|
268
|
-
* Build the bell-icon meta. Returns `null` when:
|
|
269
|
-
* - `Pilotiq.databaseNotifications()` was never called, OR
|
|
270
|
-
* - no user resolves (no inbox to surface).
|
|
271
|
-
*
|
|
272
|
-
* Defaults follow Filament: 30s polling, 25 rows per page, primary
|
|
273
|
-
* badge color, topbar position.
|
|
274
|
-
*/
|
|
275
|
-
function buildDatabaseNotificationsMeta(
|
|
276
|
-
cfg: Readonly<PilotiqConfig>,
|
|
277
|
-
user: unknown,
|
|
278
|
-
): DatabaseNotificationsMeta | null {
|
|
279
|
-
if (!cfg.databaseNotifications?.enabled) return null
|
|
280
|
-
if (user === null || user === undefined) return null
|
|
281
|
-
|
|
282
|
-
const dn = cfg.databaseNotifications
|
|
283
|
-
const base = cfg.path
|
|
284
|
-
const meta: DatabaseNotificationsMeta = {
|
|
285
|
-
position: dn.position ?? 'topbar',
|
|
286
|
-
polling: dn.polling === null ? null : (dn.polling ?? 30),
|
|
287
|
-
pageSize: dn.pageSize ?? 25,
|
|
288
|
-
badgeColor: dn.badgeColor ?? 'primary',
|
|
289
|
-
listUrl: `${base}/_notifications`,
|
|
290
|
-
readAllUrl: `${base}/_notifications/read-all`,
|
|
291
|
-
readUrl: `${base}/_notifications/:id/read`,
|
|
292
|
-
unreadUrl: `${base}/_notifications/:id/unread`,
|
|
293
|
-
actionUrl: `${base}/_notifications/:id/_action/:actionName`,
|
|
294
|
-
}
|
|
295
|
-
if (dn.trigger) meta.trigger = { ...dn.trigger }
|
|
296
|
-
// Phase 2 broadcast hint — only ship when broadcast is enabled AND the
|
|
297
|
-
// resolved user has an `id` to scope the channel to. The client uses
|
|
298
|
-
// `wsUrl` for the WebSocket connection and `channel` for the subscribe
|
|
299
|
-
// call (the private- prefix is already baked in).
|
|
300
|
-
if (dn.broadcast) {
|
|
301
|
-
const userId = (user as { id?: unknown } | null | undefined)?.id
|
|
302
|
-
if (userId !== undefined && userId !== null) {
|
|
303
|
-
const wsUrl = typeof dn.broadcast === 'object' && dn.broadcast.wsUrl
|
|
304
|
-
? dn.broadcast.wsUrl
|
|
305
|
-
: '' // empty = client falls back to same-origin /ws
|
|
306
|
-
meta.broadcast = {
|
|
307
|
-
wsUrl,
|
|
308
|
-
channel: notificationChannel(String(userId)),
|
|
309
|
-
event: NOTIFICATION_CREATED_EVENT,
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
return meta
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Build the right-sidebar meta from registered contributions. Returns
|
|
318
|
-
* `null` when:
|
|
319
|
-
*
|
|
320
|
-
* - no contributions were registered, OR
|
|
321
|
-
* - every contribution failed `canAccess(user)` (or its predicate
|
|
322
|
-
* threw — fail-closed), OR
|
|
323
|
-
* - every passing contribution is `hidden: true` (no tab-strip
|
|
324
|
-
* surface to mount; programmatic-open consumers should ship at
|
|
325
|
-
* least one visible tab).
|
|
326
|
-
*
|
|
327
|
-
* Visible contributions are sorted by `sort` ascending (default 100),
|
|
328
|
-
* with registration order as a stable tiebreaker. Each entry's icon is
|
|
329
|
-
* serialized through `serializeIcon` keyed on the contribution `id`
|
|
330
|
-
* (Phase B's Vite plugin extends `_components.ts` to round-trip
|
|
331
|
-
* component-typed icons under that key). `defaultWidth` rolls up:
|
|
332
|
-
* panel-level baseline is `RIGHT_PANEL_DEFAULT_WIDTH`; per-contribution
|
|
333
|
-
* overrides ride on `RightPanelMeta.defaultWidth`.
|
|
334
|
-
*
|
|
335
|
-
* Errors thrown by `canAccess` are swallowed (the contribution is
|
|
336
|
-
* dropped + a single console warn is emitted) so a flaky predicate on
|
|
337
|
-
* one pane never blanks the whole sidebar.
|
|
338
|
-
*/
|
|
339
|
-
async function buildRightSidebarMeta(
|
|
340
|
-
cfg: Readonly<PilotiqConfig>,
|
|
341
|
-
user: unknown,
|
|
342
|
-
): Promise<RightSidebarMeta | null> {
|
|
343
|
-
const list = cfg.rightPanels ?? []
|
|
344
|
-
if (list.length === 0) return null
|
|
345
|
-
|
|
346
|
-
const indexed = list.map((c, idx) => ({ c, idx }))
|
|
347
|
-
const gated = await Promise.all(
|
|
348
|
-
indexed.map(async ({ c, idx }) => {
|
|
349
|
-
if (c.canAccess) {
|
|
350
|
-
try {
|
|
351
|
-
const ok = await c.canAccess(user)
|
|
352
|
-
if (!ok) return null
|
|
353
|
-
} catch (err) {
|
|
354
|
-
// eslint-disable-next-line no-console
|
|
355
|
-
console.warn(`[Pilotiq] rightPanel "${c.id}" canAccess threw — dropping`, err)
|
|
356
|
-
return null
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return { c, idx }
|
|
360
|
-
}),
|
|
361
|
-
)
|
|
362
|
-
|
|
363
|
-
const visible = gated
|
|
364
|
-
.filter((x): x is { c: typeof list[number]; idx: number } => x !== null)
|
|
365
|
-
.filter((x) => !x.c.hidden)
|
|
366
|
-
.sort((a, b) => {
|
|
367
|
-
const sa = a.c.sort ?? 100
|
|
368
|
-
const sb = b.c.sort ?? 100
|
|
369
|
-
if (sa !== sb) return sa - sb
|
|
370
|
-
return a.idx - b.idx
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
if (visible.length === 0) return null
|
|
374
|
-
|
|
375
|
-
const panels: RightPanelMeta[] = visible.map(({ c }) => {
|
|
376
|
-
const meta: RightPanelMeta = {
|
|
377
|
-
id: c.id,
|
|
378
|
-
label: c.label ?? c.id,
|
|
379
|
-
defaultWidth: c.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
|
|
380
|
-
}
|
|
381
|
-
if (c.icon !== undefined) {
|
|
382
|
-
meta.icon = serializeIcon(c.icon, c.id)
|
|
383
|
-
}
|
|
384
|
-
return meta
|
|
385
|
-
})
|
|
386
|
-
|
|
387
|
-
return {
|
|
388
|
-
panels,
|
|
389
|
-
defaultWidth: panels[0]?.defaultWidth ?? RIGHT_PANEL_DEFAULT_WIDTH,
|
|
390
|
-
minWidth: RIGHT_PANEL_MIN_WIDTH,
|
|
391
|
-
maxWidth: RIGHT_PANEL_MAX_WIDTH,
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Resolve every chrome render hook (body / topbar / sidebar / user-menu
|
|
397
|
-
* / footer / head). Returns a sparse map — slots with no matching
|
|
398
|
-
* registered entries are omitted so the wire payload stays minimal on
|
|
399
|
-
* panels that don't use render hooks at all.
|
|
400
|
-
*/
|
|
401
|
-
async function resolveChromeHooks(
|
|
402
|
-
pilotiq: Pilotiq,
|
|
403
|
-
user: unknown,
|
|
404
|
-
route: PanelInfoRoute,
|
|
405
|
-
): Promise<RenderHookMap> {
|
|
406
|
-
const cfg = pilotiq.getConfig()
|
|
407
|
-
const entries = cfg.renderHooks ?? []
|
|
408
|
-
if (entries.length === 0) return {}
|
|
409
|
-
const ctx: RenderHookContext = {
|
|
410
|
-
user,
|
|
411
|
-
basePath: cfg.path,
|
|
412
|
-
url: route.url ?? cfg.path,
|
|
413
|
-
}
|
|
414
|
-
if (route.resource !== undefined) ctx.resource = route.resource
|
|
415
|
-
if (route.page !== undefined) ctx.page = route.page
|
|
416
|
-
if (route.global !== undefined) ctx.global = route.global
|
|
417
|
-
if (route.recordId !== undefined) ctx.recordId = route.recordId
|
|
418
|
-
return resolveRenderHooks(entries, CHROME_HOOK_NAMES, ctx)
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Resolve a subset of page-role render hooks (e.g. `panels::page.start`
|
|
423
|
-
* + the list-records / create-record / view-record / edit-record /
|
|
424
|
-
* global-search slot families). Per-page-role data builders call this
|
|
425
|
-
* after schema resolution and stamp the result on `viewProps.renderHooks`.
|
|
426
|
-
*
|
|
427
|
-
* `names` lets each builder declare exactly which slots it serves so a
|
|
428
|
-
* list-page builder doesn't ship slots that only fire on the edit page.
|
|
429
|
-
*/
|
|
430
|
-
/**
|
|
431
|
-
* Per-builder one-shot — resolve the role's slot set + splice the
|
|
432
|
-
* results into the resolved schema. Wraps the two steps a per-builder
|
|
433
|
-
* data fn always does in lockstep:
|
|
434
|
-
*
|
|
435
|
-
* 1. `resolvePageHooks(pilotiq, user, pageHooksFor(role), route)`
|
|
436
|
-
* 2. `applyPageHooks(schemaData, hooks, role)`
|
|
437
|
-
*
|
|
438
|
-
* Returns the wrapped `ElementMeta[]`. No-op when the panel has no
|
|
439
|
-
* registered hooks. Pass through what you'd pass to `panelInfo()`'s
|
|
440
|
-
* route arg — same shape.
|
|
441
|
-
*/
|
|
442
|
-
export async function applyRoleHooks(
|
|
443
|
-
pilotiq: Pilotiq,
|
|
444
|
-
user: unknown,
|
|
445
|
-
role: PageRole,
|
|
446
|
-
schemaData: ElementMeta[],
|
|
447
|
-
route: PanelInfoRoute = {},
|
|
448
|
-
): Promise<ElementMeta[]> {
|
|
449
|
-
const cfg = pilotiq.getConfig()
|
|
450
|
-
if (!cfg.renderHooks || cfg.renderHooks.length === 0) return schemaData
|
|
451
|
-
const hooks = await resolvePageHooks(pilotiq, user, pageHooksFor(role), route)
|
|
452
|
-
return applyPageHooks(schemaData, hooks, role)
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
export async function resolvePageHooks(
|
|
456
|
-
pilotiq: Pilotiq,
|
|
457
|
-
user: unknown,
|
|
458
|
-
names: readonly RenderHookName[],
|
|
459
|
-
route: PanelInfoRoute,
|
|
460
|
-
): Promise<RenderHookMap> {
|
|
461
|
-
const cfg = pilotiq.getConfig()
|
|
462
|
-
const entries = cfg.renderHooks ?? []
|
|
463
|
-
if (entries.length === 0 || names.length === 0) return {}
|
|
464
|
-
const ctx: RenderHookContext = {
|
|
465
|
-
user,
|
|
466
|
-
basePath: cfg.path,
|
|
467
|
-
url: route.url ?? cfg.path,
|
|
468
|
-
}
|
|
469
|
-
if (route.resource !== undefined) ctx.resource = route.resource
|
|
470
|
-
if (route.page !== undefined) ctx.page = route.page
|
|
471
|
-
if (route.global !== undefined) ctx.global = route.global
|
|
472
|
-
if (route.recordId !== undefined) ctx.recordId = route.recordId
|
|
473
|
-
return resolveRenderHooks(entries, names, ctx)
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
/**
|
|
478
|
-
* Build the top-right user-menu meta. Returns `null` when:
|
|
479
|
-
* - `Pilotiq.user()` isn't configured, or
|
|
480
|
-
* - the resolver returned `null` (anonymous request), or
|
|
481
|
-
* - the user object has no extractable identity AND the panel
|
|
482
|
-
* configured no items / no sign-out (nothing to render).
|
|
483
|
-
*
|
|
484
|
-
* Items resolve in parallel with their visibility predicates
|
|
485
|
-
* (`UserMenuItem.visible`). Throwing predicates fail closed (item
|
|
486
|
-
* dropped). Sort by `.sort(n)` ascending → registration order.
|
|
487
|
-
*/
|
|
488
|
-
async function buildUserMenu(pilotiq: Pilotiq, user: unknown): Promise<UserMenuMeta | null> {
|
|
489
|
-
if (user === null || user === undefined) return null
|
|
490
|
-
|
|
491
|
-
const cfg = pilotiq.getConfig()
|
|
492
|
-
const items = cfg.userMenuItems ?? []
|
|
493
|
-
const ctx = { user }
|
|
494
|
-
|
|
495
|
-
// Resolve every item in parallel. `null` returns mean "filtered by
|
|
496
|
-
// visibility predicate" — drop them. Indexed pre-sort so stable ties
|
|
497
|
-
// resolve to registration order.
|
|
498
|
-
const resolved = await Promise.all(
|
|
499
|
-
items.map(async (item, idx) => {
|
|
500
|
-
try {
|
|
501
|
-
const meta = await item.resolve(ctx)
|
|
502
|
-
return meta ? { meta, idx, sort: item.getSort() } : null
|
|
503
|
-
} catch {
|
|
504
|
-
return null
|
|
505
|
-
}
|
|
506
|
-
}),
|
|
507
|
-
)
|
|
508
|
-
const visibleItems = resolved
|
|
509
|
-
.filter((x): x is { meta: UserMenuItemMeta; idx: number; sort: number | undefined } => x !== null)
|
|
510
|
-
.sort((a, b) => {
|
|
511
|
-
const aHas = a.sort !== undefined, bHas = b.sort !== undefined
|
|
512
|
-
if (aHas && bHas) return a.sort! - b.sort! || a.idx - b.idx
|
|
513
|
-
if (aHas) return -1
|
|
514
|
-
if (bHas) return 1
|
|
515
|
-
return a.idx - b.idx
|
|
516
|
-
})
|
|
517
|
-
.map(x => x.meta)
|
|
518
|
-
|
|
519
|
-
// Auto-inject the profile entry from `cfg.profilePage` when set.
|
|
520
|
-
// Prepended (Filament-style) so it always sits at the top of the
|
|
521
|
-
// dropdown regardless of user-authored item ordering. Falls through
|
|
522
|
-
// its own `canAccess(user)` so per-user gating works without the
|
|
523
|
-
// user repeating the predicate at the menu level.
|
|
524
|
-
const profileItem = await buildProfileMenuItem(cfg, user)
|
|
525
|
-
const finalItems = profileItem ? [profileItem, ...visibleItems] : visibleItems
|
|
526
|
-
|
|
527
|
-
const meta: UserMenuMeta = {
|
|
528
|
-
user: extractUserIdentity(user),
|
|
529
|
-
items: finalItems,
|
|
530
|
-
}
|
|
531
|
-
if (cfg.signOut) {
|
|
532
|
-
meta.signOut = {
|
|
533
|
-
url: cfg.signOut.url,
|
|
534
|
-
label: cfg.signOut.label ?? 'Sign out',
|
|
535
|
-
method: cfg.signOut.method ?? 'POST',
|
|
536
|
-
}
|
|
537
|
-
}
|
|
538
|
-
return meta
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
/** Build the auto-injected profile entry from `cfg.profilePage`. The
|
|
542
|
-
* Page's `static label` / `static icon` win; defaults `'Edit profile'`
|
|
543
|
-
* + `'user-circle'` (registry-resolved). Returns `null` when no
|
|
544
|
-
* profile page is configured or `Page.canAccess(user)` denies. */
|
|
545
|
-
async function buildProfileMenuItem(
|
|
546
|
-
cfg: Readonly<PilotiqConfig>,
|
|
547
|
-
user: unknown,
|
|
548
|
-
): Promise<UserMenuItemMeta | null> {
|
|
549
|
-
const P = cfg.profilePage
|
|
550
|
-
if (!P) return null
|
|
551
|
-
if (!(await safeAccess(() => P.canAccess(user)))) return null
|
|
552
|
-
const url = pageBasePath(cfg.path, P)
|
|
553
|
-
const icon = serializeIcon(P.icon ?? 'user-circle', P.name)
|
|
554
|
-
const meta: UserMenuItemMeta = {
|
|
555
|
-
name: '__profile',
|
|
556
|
-
label: P.label ?? 'Edit profile',
|
|
557
|
-
url,
|
|
558
|
-
}
|
|
559
|
-
if (icon !== undefined) meta.icon = icon
|
|
560
|
-
return meta
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
/** Duck-type the user object for display fields. We never throw — a
|
|
564
|
-
* user resolver might return literally anything (a primitive, a class
|
|
565
|
-
* instance with getters, a plain object) and the dropdown should
|
|
566
|
-
* degrade gracefully (initials fallback to '?' when no name found). */
|
|
567
|
-
function extractUserIdentity(user: unknown): { name?: string; email?: string; avatar?: string } {
|
|
568
|
-
if (user === null || user === undefined) return {}
|
|
569
|
-
if (typeof user !== 'object') return { name: String(user) }
|
|
570
|
-
const obj = user as Record<string, unknown>
|
|
571
|
-
const out: { name?: string; email?: string; avatar?: string } = {}
|
|
572
|
-
const name = obj.name ?? obj.fullName ?? obj.displayName ?? obj.username
|
|
573
|
-
if (typeof name === 'string' && name) out.name = name
|
|
574
|
-
if (typeof obj.email === 'string' && obj.email) out.email = obj.email
|
|
575
|
-
const avatar = obj.avatar ?? obj.avatarUrl ?? obj.image
|
|
576
|
-
if (typeof avatar === 'string' && avatar) out.avatar = avatar
|
|
577
|
-
return out
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
/** @internal Internal node before nesting; carries the registration index
|
|
581
|
-
* so we can stable-sort by it as the tie-breaker. */
|
|
582
|
-
interface RawNavItem extends NavItem {
|
|
583
|
-
parent?: string
|
|
584
|
-
/** Registration index across resources → globals → pages (in that order),
|
|
585
|
-
* so resources beat globals on a sort tie within the same group. */
|
|
586
|
-
_idx: number
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
/** Run a `canAccess` check, swallowing throws as `false`. Used by
|
|
590
|
-
* `buildNavigation` to fail-closed on flaky auth predicates without
|
|
591
|
-
* blanking the page. */
|
|
592
|
-
async function safeAccess(fn: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
593
|
-
try {
|
|
594
|
-
return Boolean(await fn())
|
|
595
|
-
} catch {
|
|
596
|
-
return false
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
/** Plan #10 — stamp the resolved user onto a SchemaContext so action
|
|
601
|
-
* visibility predicates can see it during `resolveSchema`. The `user`
|
|
602
|
-
* field is opaque (whatever `Pilotiq.user(req => …)` returns); skipped
|
|
603
|
-
* when null/undefined to keep ctx tidy. */
|
|
604
|
-
function userCtx<C extends SchemaContext>(ctx: C, user: unknown): C {
|
|
605
|
-
if (user === null || user === undefined) return ctx
|
|
606
|
-
return { ...ctx, user: user as NonNullable<SchemaContext['user']> }
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
/** Plan #6 — stamp the panel-wide upload URL so `FileUpload` fields
|
|
610
|
-
* emit it on their meta. Single URL for the whole panel; no per-field
|
|
611
|
-
* variation. The route is always registered (see `_uploads` in
|
|
612
|
-
* `routes.ts`) — meta is stamped regardless of whether an adapter is
|
|
613
|
-
* configured so the renderer can show a clear error rather than
|
|
614
|
-
* silently breaking. The companion `hasUploadAdapter` flag distinguishes
|
|
615
|
-
* "URL exists but adapter missing" so fields with optional upload
|
|
616
|
-
* affordances (e.g. `MarkdownField`'s `attachFiles` button) can hide
|
|
617
|
-
* themselves rather than render a broken control. */
|
|
618
|
-
function uploadCtx<C extends SchemaContext>(ctx: C, cfg: PilotiqConfig): C {
|
|
619
|
-
return {
|
|
620
|
-
...ctx,
|
|
621
|
-
uploadUrl: `${cfg.path}/_uploads`,
|
|
622
|
-
...(cfg.uploads ? { hasUploadAdapter: true } : {}),
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
async function buildNavigation(pilotiq: Pilotiq, user: unknown): Promise<NavItem[]> {
|
|
627
|
-
const cfg = pilotiq.getConfig()
|
|
628
|
-
const base = cfg.path
|
|
629
|
-
|
|
630
|
-
// Flatten + resolve badges in parallel. We build the raw list first so
|
|
631
|
-
// every entry has its identity (`name`) and parent set; badges resolve
|
|
632
|
-
// alongside.
|
|
633
|
-
const raw: RawNavItem[] = []
|
|
634
|
-
let idx = 0
|
|
635
|
-
|
|
636
|
-
const pushBadge: Array<{ item: RawNavItem; handler: () => unknown }> = []
|
|
637
|
-
|
|
638
|
-
// Plan #10 — pre-evaluate canAccess for every owner in parallel so we
|
|
639
|
-
// can drop forbidden items before flattening. Failed predicates fail
|
|
640
|
-
// closed (treated as `false`) so a thrown auth check doesn't accidentally
|
|
641
|
-
// expose nav items. Clusters compose: a child gated through its
|
|
642
|
-
// cluster's `canAccess` returning false drops the child even when the
|
|
643
|
-
// child's own predicate would have passed.
|
|
644
|
-
const [resourceAccess, globalAccess, pageAccess, clusterAccess] = await Promise.all([
|
|
645
|
-
Promise.all(cfg.resources.map(R => safeAccess(() => R.canAccess(user)))),
|
|
646
|
-
Promise.all(cfg.globals.map(G => safeAccess(() => G.canAccess(user)))),
|
|
647
|
-
Promise.all(cfg.pages.map(P => safeAccess(() => P.canAccess(user)))),
|
|
648
|
-
Promise.all(cfg.clusters.map(C => safeAccess(() => C.canAccess(user)))),
|
|
649
|
-
])
|
|
650
|
-
|
|
651
|
-
// Identity-keyed so two clusters that happen to share a `.name`
|
|
652
|
-
// (minifier collisions, hot-reload duplicate imports) don't clobber.
|
|
653
|
-
const clusterAccessByClass = new Map<ClusterClass, boolean>()
|
|
654
|
-
cfg.clusters.forEach((C, i) => clusterAccessByClass.set(C, !!clusterAccess[i]))
|
|
655
|
-
|
|
656
|
-
const firstChildUrlByCluster = new Map<ClusterClass, string>()
|
|
657
|
-
const recordChildUrl = (cluster: ClusterClass, url: string) => {
|
|
658
|
-
if (!firstChildUrlByCluster.has(cluster)) firstChildUrlByCluster.set(cluster, url)
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
for (let i = 0; i < cfg.resources.length; i++) {
|
|
662
|
-
const R = cfg.resources[i]!
|
|
663
|
-
if (!resourceAccess[i]) continue
|
|
664
|
-
if (R.cluster && !clusterAccessByClass.get(R.cluster)) continue
|
|
665
|
-
const url = resourceBasePath(base, R)
|
|
666
|
-
if (R.cluster) recordChildUrl(R.cluster, url)
|
|
667
|
-
const item: RawNavItem = {
|
|
668
|
-
name: R.name,
|
|
669
|
-
label: R.getNavigationLabel(),
|
|
670
|
-
url,
|
|
671
|
-
icon: serializeIcon(R.getNavigationIcon(), R.name),
|
|
672
|
-
_idx: idx++,
|
|
673
|
-
}
|
|
674
|
-
if (R.navigationGroup !== undefined) item.group = R.navigationGroup
|
|
675
|
-
if (R.navigationSort !== undefined) item.sort = R.navigationSort
|
|
676
|
-
// Cluster nesting wins over `navigationParentItem`. Both being set
|
|
677
|
-
// is a misconfiguration; cluster placement is the structural one.
|
|
678
|
-
if (R.cluster) item.parent = R.cluster.name
|
|
679
|
-
else if (R.navigationParentItem !== undefined) item.parent = R.navigationParentItem
|
|
680
|
-
if (R.navigationBadgeColor !== 'default') item.badgeColor = R.navigationBadgeColor
|
|
681
|
-
if (R.navigationBadge) pushBadge.push({ item, handler: R.navigationBadge })
|
|
682
|
-
raw.push(item)
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
for (let i = 0; i < cfg.globals.length; i++) {
|
|
686
|
-
if (!globalAccess[i]) continue
|
|
687
|
-
const G = cfg.globals[i]!
|
|
688
|
-
if (G.cluster && !clusterAccessByClass.get(G.cluster)) continue
|
|
689
|
-
// Globals default `navigationGroup` to `'Settings'`. Allow `null` as
|
|
690
|
-
// an explicit opt-out → render at top level.
|
|
691
|
-
const group = G.navigationGroup === null ? undefined : G.navigationGroup
|
|
692
|
-
const url = globalBasePath(base, G)
|
|
693
|
-
if (G.cluster) recordChildUrl(G.cluster, url)
|
|
694
|
-
const item: RawNavItem = {
|
|
695
|
-
name: G.name,
|
|
696
|
-
label: G.getNavigationLabel(),
|
|
697
|
-
url,
|
|
698
|
-
icon: serializeIcon(G.getNavigationIcon(), G.name),
|
|
699
|
-
_idx: idx++,
|
|
700
|
-
}
|
|
701
|
-
if (group !== undefined) item.group = group
|
|
702
|
-
if (G.navigationSort !== undefined) item.sort = G.navigationSort
|
|
703
|
-
if (G.cluster) item.parent = G.cluster.name
|
|
704
|
-
else if (G.navigationParentItem !== undefined) item.parent = G.navigationParentItem
|
|
705
|
-
if (G.navigationBadgeColor !== 'default') item.badgeColor = G.navigationBadgeColor
|
|
706
|
-
if (G.navigationBadge) pushBadge.push({ item, handler: G.navigationBadge })
|
|
707
|
-
raw.push(item)
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
for (let i = 0; i < cfg.pages.length; i++) {
|
|
711
|
-
if (!pageAccess[i]) continue
|
|
712
|
-
const P = cfg.pages[i]!
|
|
713
|
-
if (P.cluster && !clusterAccessByClass.get(P.cluster)) continue
|
|
714
|
-
// The dashboard page collapses its nav URL to `${base}` so the
|
|
715
|
-
// sidebar entry deep-links to the panel root rather than
|
|
716
|
-
// `${base}/${P.getSlug()}` (which would 404 — the slug route skips
|
|
717
|
-
// the dashboard page at boot).
|
|
718
|
-
const isDashboard = cfg.dashboardPage === P
|
|
719
|
-
const url = isDashboard ? base : pageBasePath(base, P)
|
|
720
|
-
if (P.cluster && !isDashboard) recordChildUrl(P.cluster, url)
|
|
721
|
-
const item: RawNavItem = {
|
|
722
|
-
name: P.name,
|
|
723
|
-
label: P.getNavigationLabel(),
|
|
724
|
-
url,
|
|
725
|
-
icon: serializeIcon(P.getNavigationIcon(), P.name),
|
|
726
|
-
_idx: idx++,
|
|
727
|
-
}
|
|
728
|
-
if (P.navigationGroup !== undefined) item.group = P.navigationGroup
|
|
729
|
-
if (P.navigationSort !== undefined) item.sort = P.navigationSort
|
|
730
|
-
if (P.cluster && !isDashboard) item.parent = P.cluster.name
|
|
731
|
-
else if (P.navigationParentItem !== undefined) item.parent = P.navigationParentItem
|
|
732
|
-
if (P.navigationBadgeColor !== 'default') item.badgeColor = P.navigationBadgeColor
|
|
733
|
-
if (P.navigationBadge) pushBadge.push({ item, handler: P.navigationBadge })
|
|
734
|
-
raw.push(item)
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
// Clusters render as first-class nav items. Each gets a URL pointing
|
|
738
|
-
// at its `landingPage` (when set + accessible) or its first accessible
|
|
739
|
-
// child. Clusters whose every child was gated out are dropped silently
|
|
740
|
-
// — same posture as `navigationParentItem` with no resolvable parent.
|
|
741
|
-
for (let i = 0; i < cfg.clusters.length; i++) {
|
|
742
|
-
if (!clusterAccess[i]) continue
|
|
743
|
-
const C = cfg.clusters[i]!
|
|
744
|
-
let url: string | undefined
|
|
745
|
-
if (C.landingPage) {
|
|
746
|
-
const lpIdx = cfg.pages.indexOf(C.landingPage)
|
|
747
|
-
if (lpIdx !== -1 && pageAccess[lpIdx]) {
|
|
748
|
-
url = cfg.dashboardPage === C.landingPage ? base : pageBasePath(base, C.landingPage)
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
if (url === undefined) url = firstChildUrlByCluster.get(C)
|
|
752
|
-
if (url === undefined) continue // empty cluster — drop entirely
|
|
753
|
-
const item: RawNavItem = {
|
|
754
|
-
name: C.name,
|
|
755
|
-
label: C.getNavigationLabel(),
|
|
756
|
-
url,
|
|
757
|
-
icon: serializeIcon(C.getNavigationIcon(), C.name),
|
|
758
|
-
_idx: idx++,
|
|
759
|
-
}
|
|
760
|
-
if (C.navigationGroup !== undefined) item.group = C.navigationGroup
|
|
761
|
-
if (C.navigationSort !== undefined) item.sort = C.navigationSort
|
|
762
|
-
if (C.navigationParentItem !== undefined) item.parent = C.navigationParentItem
|
|
763
|
-
if (C.navigationBadgeColor !== 'default') item.badgeColor = C.navigationBadgeColor
|
|
764
|
-
if (C.navigationBadge) pushBadge.push({ item, handler: C.navigationBadge })
|
|
765
|
-
raw.push(item)
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
await Promise.all(pushBadge.map(async ({ item, handler }) => {
|
|
769
|
-
try {
|
|
770
|
-
const v = await handler()
|
|
771
|
-
if (v === undefined || v === null) return
|
|
772
|
-
item.badge = String(v)
|
|
773
|
-
} catch {
|
|
774
|
-
// Per-badge errors stay silent.
|
|
775
|
-
}
|
|
776
|
-
}))
|
|
777
|
-
|
|
778
|
-
return nestAndSort(raw)
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* Resolve `parent` references → nest, drop cycles, sort within each
|
|
783
|
-
* grouping, then strip internal scaffolding (`parent`, `_idx`).
|
|
784
|
-
*/
|
|
785
|
-
function nestAndSort(raw: RawNavItem[]): NavItem[] {
|
|
786
|
-
const byName = new Map<string, RawNavItem>()
|
|
787
|
-
for (const it of raw) byName.set(it.name, it)
|
|
788
|
-
|
|
789
|
-
// Detect parent cycles: walk upwards from each item; any name seen
|
|
790
|
-
// twice → cycle. Items in a cycle get treated as top-level.
|
|
791
|
-
const inCycle = new Set<string>()
|
|
792
|
-
for (const it of raw) {
|
|
793
|
-
if (it.parent === undefined) continue
|
|
794
|
-
const seen = new Set<string>([it.name])
|
|
795
|
-
let cur: string | undefined = it.parent
|
|
796
|
-
while (cur !== undefined) {
|
|
797
|
-
if (seen.has(cur)) {
|
|
798
|
-
if (typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
799
|
-
console.warn(`[Pilotiq] navigationParentItem cycle detected at "${it.name}" — rendering at top level.`)
|
|
800
|
-
}
|
|
801
|
-
inCycle.add(it.name)
|
|
802
|
-
break
|
|
803
|
-
}
|
|
804
|
-
seen.add(cur)
|
|
805
|
-
const parent = byName.get(cur)
|
|
806
|
-
if (!parent) break
|
|
807
|
-
cur = parent.parent
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
|
|
811
|
-
const childrenOf = new Map<string, RawNavItem[]>()
|
|
812
|
-
const top: RawNavItem[] = []
|
|
813
|
-
for (const it of raw) {
|
|
814
|
-
const parent = it.parent
|
|
815
|
-
if (parent && byName.has(parent) && !inCycle.has(it.name)) {
|
|
816
|
-
const list = childrenOf.get(parent) ?? []
|
|
817
|
-
list.push(it)
|
|
818
|
-
childrenOf.set(parent, list)
|
|
819
|
-
} else {
|
|
820
|
-
top.push(it)
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
// Sort items in a sibling group by sort (asc), ties → registration order.
|
|
825
|
-
const sortItems = (items: RawNavItem[]): RawNavItem[] => {
|
|
826
|
-
return [...items].sort((a, b) => {
|
|
827
|
-
const aHas = a.sort !== undefined, bHas = b.sort !== undefined
|
|
828
|
-
if (aHas && bHas) return a.sort! - b.sort! || a._idx - b._idx
|
|
829
|
-
if (aHas) return -1 // sorted items come before unsorted
|
|
830
|
-
if (bHas) return 1
|
|
831
|
-
return a._idx - b._idx
|
|
832
|
-
})
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
// Strip internals + recurse into children.
|
|
836
|
-
const finalize = (items: RawNavItem[]): NavItem[] =>
|
|
837
|
-
sortItems(items).map(it => {
|
|
838
|
-
const kids = childrenOf.get(it.name)
|
|
839
|
-
const { parent, _idx, ...rest } = it
|
|
840
|
-
const out: NavItem = { ...rest }
|
|
841
|
-
if (kids && kids.length > 0) out.children = finalize(kids)
|
|
842
|
-
return out
|
|
843
|
-
})
|
|
844
|
-
|
|
845
|
-
return finalize(top)
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
export async function callPageSchema(PageClass: typeof Page, ctx: SchemaContext): Promise<Element[]> {
|
|
849
|
-
return Promise.resolve(PageClass.schema(ctx))
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/** Mark every Form on the page with its action URL so the rendered <form> posts to itself. */
|
|
853
|
-
export function tagFormActions(elements: ReadonlyArray<Element>, action: string): void {
|
|
854
|
-
for (const form of findForms(elements)) {
|
|
855
|
-
if (!form.getAction()) form.action(action)
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
|
|
859
|
-
/**
|
|
860
|
-
* Plan #5 — stamp the partial-resolve endpoint URL on every form whose
|
|
861
|
-
* descendants include at least one `live()` field. The client uses
|
|
862
|
-
* `FormMeta.stateUrl` to flip into controlled-state mode; forms without
|
|
863
|
-
* any live fields stay uncontrolled (zero-cost legacy path).
|
|
864
|
-
*
|
|
865
|
-
* `urlBuilder(formId)` lets the caller compose a per-form URL — the
|
|
866
|
-
* endpoint shape is `${base}/${slug}/_form/${formId}/state` so each
|
|
867
|
-
* form on a multi-form page gets its own route segment.
|
|
868
|
-
*/
|
|
869
|
-
export function tagFormStateUrls(
|
|
870
|
-
elements: ReadonlyArray<Element>,
|
|
871
|
-
urlBuilder: (formId: string) => string,
|
|
872
|
-
): void {
|
|
873
|
-
for (const form of findForms(elements)) {
|
|
874
|
-
if (formHasLiveField(form)) {
|
|
875
|
-
form.withStateUrl(urlBuilder(form.getFormId()))
|
|
876
|
-
}
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
/**
|
|
881
|
-
* Reorderable rows — stamp the POST-reorder URL on every `Table` that
|
|
882
|
-
* has `Table.reorderable()` set. The renderer reads `TableMeta.reorderUrl`
|
|
883
|
-
* to wire the drop handler; tables that aren't reorderable skip wiring
|
|
884
|
-
* entirely. Same shape as `tagFormStateUrls` so the call site stays
|
|
885
|
-
* consistent.
|
|
886
|
-
*/
|
|
887
|
-
export function tagTableReorderUrls(
|
|
888
|
-
elements: ReadonlyArray<Element>,
|
|
889
|
-
url: string,
|
|
890
|
-
): void {
|
|
891
|
-
for (const table of findTables(elements)) {
|
|
892
|
-
if (table.isReorderable() && !table.getReorderUrl()) {
|
|
893
|
-
table.withReorderUrl(url)
|
|
894
|
-
}
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
// Marks every Table on the page deferred and stamps the URL the
|
|
899
|
-
// renderer will fetch from after mount. Must run BEFORE `loadTableRecords`
|
|
900
|
-
// so the records handler short-circuits.
|
|
901
|
-
export function tagTableDeferred(
|
|
902
|
-
elements: ReadonlyArray<Element>,
|
|
903
|
-
url: string,
|
|
904
|
-
): void {
|
|
905
|
-
for (const table of findTables(elements)) {
|
|
906
|
-
table.withDeferred(true)
|
|
907
|
-
table.withTableUrl(url)
|
|
908
|
-
}
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
/**
|
|
912
|
-
* Editable cell columns — walk every table on the page and stamp
|
|
913
|
-
* `_cellEditUrls[colName]` per row, but only on rows that already
|
|
914
|
-
* carry a `_cellEditable[colName]` marker (set by `loadTableRecords`
|
|
915
|
-
* after `R.canEdit(user, row)` passed). The dispatcher stays
|
|
916
|
-
* URL-shape-agnostic; URL building lives here parallel to
|
|
917
|
-
* `tagFormStateUrls / tagTableReorderUrls`.
|
|
918
|
-
*
|
|
919
|
-
* `idOf` extracts the per-row primary key. Defaults to reading `id` —
|
|
920
|
-
* works for the rudder ORM convention. Resources with a different
|
|
921
|
-
* primary-key column should pass an override (none in v1).
|
|
922
|
-
*/
|
|
923
|
-
export function tagCellEditUrls(
|
|
924
|
-
elements: ReadonlyArray<Element>,
|
|
925
|
-
resourceUrl: string,
|
|
926
|
-
idOf: (row: Record<string, unknown>) => unknown = row => row['id'],
|
|
927
|
-
): void {
|
|
928
|
-
for (const table of findTables(elements)) {
|
|
929
|
-
const rows = table.getRows() as ReadonlyArray<Record<string, unknown>> | undefined
|
|
930
|
-
if (!rows || rows.length === 0) continue
|
|
931
|
-
// Optimisation: skip the table when none of its columns are editable.
|
|
932
|
-
const editable = (table.getChildren() ?? []).some(c => c instanceof Column && c.isEditable())
|
|
933
|
-
if (!editable) continue
|
|
934
|
-
for (const row of rows) {
|
|
935
|
-
const editableMap = row['_cellEditable'] as Record<string, true> | undefined
|
|
936
|
-
if (!editableMap) continue
|
|
937
|
-
const id = idOf(row)
|
|
938
|
-
if (id === undefined || id === null || id === '') continue
|
|
939
|
-
const urls: Record<string, string> = {}
|
|
940
|
-
for (const colName of Object.keys(editableMap)) {
|
|
941
|
-
urls[colName] = `${resourceUrl}/${encodeURIComponent(String(id))}/_cell/${encodeURIComponent(colName)}`
|
|
942
|
-
}
|
|
943
|
-
;(row as Record<string, unknown>)['_cellEditUrls'] = urls
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* Plan #8 — stamp the wizard step-validate endpoint URL on every form
|
|
950
|
-
* whose descendants include a `Wizard` element. `FormMeta.wizardUrl` is
|
|
951
|
-
* what the client posts to on Next-button clicks; forms without a wizard
|
|
952
|
-
* descendant skip wiring.
|
|
953
|
-
*/
|
|
954
|
-
export function tagFormWizardUrls(
|
|
955
|
-
elements: ReadonlyArray<Element>,
|
|
956
|
-
urlBuilder: (formId: string) => string,
|
|
957
|
-
): void {
|
|
958
|
-
for (const form of findForms(elements)) {
|
|
959
|
-
if (formHasWizard(form)) {
|
|
960
|
-
form.withWizardUrl(urlBuilder(form.getFormId()))
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
/**
|
|
966
|
-
* Stamp `_agentRunBase` on every field element in the resolved
|
|
967
|
-
* `ElementMeta[]` tree that carries `aiActions`. Operates on the
|
|
968
|
-
* post-`resolveSchema` wire shape (plain objects) rather than on
|
|
969
|
-
* `Element` instances — `aiActions` is added by the `field-ai.ts`
|
|
970
|
-
* wrapper during `toMeta()`, so it isn't visible to pre-resolve walkers.
|
|
971
|
-
*
|
|
972
|
-
* Only called on edit pages where a `recordId` is known. Create pages
|
|
973
|
-
* deliberately skip it — field AI actions target existing content.
|
|
974
|
-
*/
|
|
975
|
-
export function tagFieldAiUrls(
|
|
976
|
-
elements: ReadonlyArray<Record<string, unknown>>,
|
|
977
|
-
agentBase: string,
|
|
978
|
-
): void {
|
|
979
|
-
for (const el of elements) {
|
|
980
|
-
if (Array.isArray(el['aiActions']) && (el['aiActions'] as unknown[]).length > 0) {
|
|
981
|
-
;(el as Record<string, unknown>)['_agentRunBase'] = agentBase
|
|
982
|
-
}
|
|
983
|
-
const children = el['children']
|
|
984
|
-
if (Array.isArray(children)) tagFieldAiUrls(children as Record<string, unknown>[], agentBase)
|
|
985
|
-
// Repeater rows
|
|
986
|
-
const rows = el['rows']
|
|
987
|
-
if (Array.isArray(rows)) {
|
|
988
|
-
for (const row of rows as Record<string, unknown>[]) {
|
|
989
|
-
const rowChildren = row['children']
|
|
990
|
-
if (Array.isArray(rowChildren)) tagFieldAiUrls(rowChildren as Record<string, unknown>[], agentBase)
|
|
991
|
-
}
|
|
992
|
-
}
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
|
-
|
|
996
|
-
/**
|
|
997
|
-
* Audit row 2026-05-07 cont'd⁸ — stamp the inline-create-option endpoint
|
|
998
|
-
* URL on every `SelectField` that has called `createOptionForm()`. Walks
|
|
999
|
-
* every form on the page so the URL carries the parent form's id; URL
|
|
1000
|
-
* shape `${formScopeUrl}/_form/${formId}/create-option/${fieldName}` so
|
|
1001
|
-
* the route handler can pick the form by id and the field by name.
|
|
1002
|
-
*
|
|
1003
|
-
* Mirrors `tagFormStateUrls / tagFormWizardUrls` — operates on the
|
|
1004
|
-
* un-resolved Element tree, mutates field-instance state via
|
|
1005
|
-
* `field.withCreateOptionUrl(url)`, and the field's `toMeta()` reads it
|
|
1006
|
-
* back to emit `createOption.url`.
|
|
1007
|
-
*
|
|
1008
|
-
* Stops at Repeater / Builder boundaries (parallel to the form-state /
|
|
1009
|
-
* wizard walkers): inside-row schemas are dispatched per-row and the
|
|
1010
|
-
* createOption shape doesn't compose with row body coercion in v1.
|
|
1011
|
-
*/
|
|
1012
|
-
export function tagSelectCreateOptionUrls(
|
|
1013
|
-
elements: ReadonlyArray<Element>,
|
|
1014
|
-
urlBuilder: (formId: string, fieldName: string) => string,
|
|
1015
|
-
): void {
|
|
1016
|
-
for (const form of findForms(elements)) {
|
|
1017
|
-
const formId = form.getFormId()
|
|
1018
|
-
walkSelectFields(form.getChildren() as Element[] ?? [], (field) => {
|
|
1019
|
-
if (field.hasCreateOption() && !field.getCreateOptionUrl()) {
|
|
1020
|
-
field.withCreateOptionUrl(urlBuilder(formId, field.name))
|
|
1021
|
-
}
|
|
1022
|
-
})
|
|
1023
|
-
}
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
function walkSelectFields(elements: Element[], visit: (f: SelectField) => void): void {
|
|
1027
|
-
for (const el of elements) {
|
|
1028
|
-
if (el instanceof SelectField) {
|
|
1029
|
-
visit(el)
|
|
1030
|
-
// SelectField has no children of its own — no recursion needed.
|
|
1031
|
-
continue
|
|
1032
|
-
}
|
|
1033
|
-
// Stop at row-array boundaries — see comment on `tagSelectCreateOptionUrls`.
|
|
1034
|
-
if (el instanceof RepeaterField) continue
|
|
1035
|
-
if (el instanceof BuilderField) continue
|
|
1036
|
-
const children = el.getChildren()
|
|
1037
|
-
if (children && children.length > 0) walkSelectFields(children as Element[], visit)
|
|
1038
|
-
}
|
|
1039
|
-
}
|
|
1040
|
-
|
|
1041
|
-
/**
|
|
1042
|
-
* Adapter-package async-resolve walker. Stamps the per-form mentions URL
|
|
1043
|
-
* on every field that ducks like a "rich text with at least one async
|
|
1044
|
-
* mention provider". The duck-typed contract lives here (as opposed to
|
|
1045
|
-
* importing from `@pilotiq/tiptap`) so pilotiq core stays adapter-free —
|
|
1046
|
-
* any future field type with an async-resolve trigger can satisfy the
|
|
1047
|
-
* same shape and pick up URL stamping for free.
|
|
1048
|
-
*
|
|
1049
|
-
* Contract:
|
|
1050
|
-
* - `getType() === 'richtext'` (fast filter)
|
|
1051
|
-
* - `hasAsyncMentions(): boolean`
|
|
1052
|
-
* - `withMentionsUrl(url: string): unknown`
|
|
1053
|
-
*
|
|
1054
|
-
* Walks every form on the page so the URL builder can mint a per-form
|
|
1055
|
-
* URL (mirrors `tagFormStateUrls / tagFormWizardUrls`). The route handler
|
|
1056
|
-
* uses formId in the URL to select the form; the body carries `field`
|
|
1057
|
-
* + `trigger` + `query`. One URL per (form, scope), reused across every
|
|
1058
|
-
* async-mention field on that form.
|
|
1059
|
-
*/
|
|
1060
|
-
interface AsyncMentionFieldLike {
|
|
1061
|
-
hasAsyncMentions(): boolean
|
|
1062
|
-
withMentionsUrl(url: string): unknown
|
|
1063
|
-
}
|
|
1064
|
-
|
|
1065
|
-
function isAsyncMentionField(el: Element): el is Element & AsyncMentionFieldLike {
|
|
1066
|
-
if (el.getType() !== 'richtext') return false
|
|
1067
|
-
const candidate = el as unknown as Partial<AsyncMentionFieldLike>
|
|
1068
|
-
return typeof candidate.hasAsyncMentions === 'function'
|
|
1069
|
-
&& typeof candidate.withMentionsUrl === 'function'
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
export function tagRichTextMentionUrls(
|
|
1073
|
-
elements: ReadonlyArray<Element>,
|
|
1074
|
-
urlBuilder: (formId: string) => string,
|
|
1075
|
-
): void {
|
|
1076
|
-
for (const form of findForms(elements)) {
|
|
1077
|
-
const url = urlBuilder(form.getFormId())
|
|
1078
|
-
let stampedAny = false
|
|
1079
|
-
const visit = (els: ReadonlyArray<Element>): void => {
|
|
1080
|
-
for (const el of els) {
|
|
1081
|
-
// Don't cross into nested forms — each form gets its own URL.
|
|
1082
|
-
if (el !== form && el.getType() === 'form') continue
|
|
1083
|
-
if (isAsyncMentionField(el) && el.hasAsyncMentions()) {
|
|
1084
|
-
el.withMentionsUrl(url)
|
|
1085
|
-
stampedAny = true
|
|
1086
|
-
}
|
|
1087
|
-
// Builder.getChildren() returns undefined to keep the field-level
|
|
1088
|
-
// walkers from treating heterogeneous rows as flat children. Manual
|
|
1089
|
-
// descent into each block's schema covers the URL-stamping path
|
|
1090
|
-
// without changing the no-cross posture for save/coerce.
|
|
1091
|
-
if (isBuilderField(el)) {
|
|
1092
|
-
for (const block of (el as BuilderField).getBlocks()) visit(block.getSchema())
|
|
1093
|
-
continue
|
|
1094
|
-
}
|
|
1095
|
-
const children = el.getChildren()
|
|
1096
|
-
if (children) visit(children)
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
const children = form.getChildren()
|
|
1100
|
-
if (children) visit(children)
|
|
1101
|
-
void stampedAny // silence unused — kept locally for readability
|
|
1102
|
-
}
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
function formHasLiveField(form: Form): boolean {
|
|
1106
|
-
let found = false
|
|
1107
|
-
const visit = (els: ReadonlyArray<Element>): void => {
|
|
1108
|
-
for (const el of els) {
|
|
1109
|
-
if (found) return
|
|
1110
|
-
// Either a server-side `live()` (drives a roundtrip) OR a
|
|
1111
|
-
// client-side `afterStateUpdatedJs(body)` (JS-only) is enough to
|
|
1112
|
-
// mount the controlled-form path: the FormStateProvider holds the
|
|
1113
|
-
// values map either path needs, and the client gates the actual
|
|
1114
|
-
// network POST on `live` separately. Cost of the over-stamp for
|
|
1115
|
-
// JS-only forms is one unused endpoint URL per form — endpoint
|
|
1116
|
-
// never gets hit because the client only POSTs on `live`.
|
|
1117
|
-
if (el instanceof Field && (el.isLive() || el.getAfterStateUpdatedJs() !== undefined)) {
|
|
1118
|
-
found = true
|
|
1119
|
-
return
|
|
1120
|
-
}
|
|
1121
|
-
const children = el.getChildren()
|
|
1122
|
-
if (children) visit(children)
|
|
1123
|
-
}
|
|
1124
|
-
}
|
|
1125
|
-
const children = form.getChildren()
|
|
1126
|
-
if (children) visit(children)
|
|
1127
|
-
return found
|
|
1128
|
-
}
|
|
1129
|
-
|
|
1130
|
-
function formHasWizard(form: Form): boolean {
|
|
1131
|
-
let found = false
|
|
1132
|
-
const visit = (els: ReadonlyArray<Element>): void => {
|
|
1133
|
-
for (const el of els) {
|
|
1134
|
-
if (found) return
|
|
1135
|
-
if (el.getType() === 'wizard') { found = true; return }
|
|
1136
|
-
const children = el.getChildren()
|
|
1137
|
-
if (children) visit(children)
|
|
1138
|
-
}
|
|
1139
|
-
}
|
|
1140
|
-
const children = form.getChildren()
|
|
1141
|
-
if (children) visit(children)
|
|
1142
|
-
return found
|
|
1143
|
-
}
|
|
1144
|
-
|
|
1145
|
-
/**
|
|
1146
|
-
* Run the edit-mode fill pipeline on a loaded record:
|
|
1147
|
-
* mutateFormDataBeforeFill → fillFromRecord → mutateFormDataAfterFill
|
|
1148
|
-
*
|
|
1149
|
-
* `fillFromRecord` defaults to `{ ...record }` when not configured. Both
|
|
1150
|
-
* mutators are optional and may be async. `ctx.record` is the loaded
|
|
1151
|
-
* record so mutators can read from fields the form doesn't surface.
|
|
1152
|
-
*/
|
|
1153
|
-
export async function applyFillPipeline<R>(
|
|
1154
|
-
form: Form<R>,
|
|
1155
|
-
record: R,
|
|
1156
|
-
): Promise<Record<string, unknown>> {
|
|
1157
|
-
const recordObj = record as unknown as Record<string, unknown>
|
|
1158
|
-
let values: Record<string, unknown> = { ...recordObj }
|
|
1159
|
-
|
|
1160
|
-
const before = form.getMutateFormDataBeforeFill()
|
|
1161
|
-
if (before) values = await before(values, { values, record })
|
|
1162
|
-
|
|
1163
|
-
const fill = form.getFillFromRecord()
|
|
1164
|
-
if (fill) values = fill(record)
|
|
1165
|
-
|
|
1166
|
-
const after = form.getMutateFormDataAfterFill()
|
|
1167
|
-
if (after) values = await after(values, { values, record })
|
|
1168
|
-
|
|
1169
|
-
return values
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
/**
|
|
1173
|
-
* Walk the form's top-level Repeaters and replace `values[fieldName]`
|
|
1174
|
-
* with rows fetched from `parent.related(name)` for any
|
|
1175
|
-
* relationship-backed Repeater. Each loaded row stamps `__id` to the
|
|
1176
|
-
* child's primary key so the renderer can round-trip identity through
|
|
1177
|
-
* a hidden input and the save-side diff can match submitted rows back
|
|
1178
|
-
* to existing records.
|
|
1179
|
-
*
|
|
1180
|
-
* No-op when the parent record is null (create mode), when no
|
|
1181
|
-
* relationship-backed Repeaters exist on the form, or when the
|
|
1182
|
-
* resource has no `R.model` (relation queries need it).
|
|
1183
|
-
*
|
|
1184
|
-
* Mutates and returns a fresh values object — never the input.
|
|
1185
|
-
*/
|
|
1186
|
-
export async function applyRelationshipRepeaterFill(
|
|
1187
|
-
form: Form,
|
|
1188
|
-
values: Record<string, unknown>,
|
|
1189
|
-
record: unknown,
|
|
1190
|
-
parentModel: ModelLike | undefined,
|
|
1191
|
-
): Promise<Record<string, unknown>> {
|
|
1192
|
-
if (record == null) return values
|
|
1193
|
-
if (!parentModel) return values
|
|
1194
|
-
const repeaters = findRelationshipRepeaters(form.getChildren() ?? [])
|
|
1195
|
-
if (repeaters.length === 0) return values
|
|
1196
|
-
|
|
1197
|
-
const out: Record<string, unknown> = { ...values }
|
|
1198
|
-
for (const repeater of repeaters) {
|
|
1199
|
-
const cfg = repeater.getRelationship()!
|
|
1200
|
-
const pivotColumns = cfg.pivotColumns
|
|
1201
|
-
let rows: unknown[]
|
|
1202
|
-
try {
|
|
1203
|
-
rows = await loadRelationRows(parentModel, record, cfg.name, pivotColumns)
|
|
1204
|
-
} catch {
|
|
1205
|
-
// Failed lookup (e.g. missing `relations` map on a test stub)
|
|
1206
|
-
// — fall back to whatever value applyFillPipeline produced
|
|
1207
|
-
// rather than wiping the field. Better to render stale data
|
|
1208
|
-
// than to silently empty the row list.
|
|
1209
|
-
continue
|
|
1210
|
-
}
|
|
1211
|
-
|
|
1212
|
-
// The child model is opaque here — we don't have the full
|
|
1213
|
-
// descriptor at this seam, so use the configured override or
|
|
1214
|
-
// peek the parent's relations map for the FK column. Strip it
|
|
1215
|
-
// (and the PK) from each row's payload so the inner schema
|
|
1216
|
-
// doesn't surface them as form values. For morphMany the
|
|
1217
|
-
// attachment is two columns instead of one — strip both.
|
|
1218
|
-
const pkColumn = pickChildPrimaryKey(parentModel, cfg.name) ?? 'id'
|
|
1219
|
-
const fkColumn = cfg.foreignKey ?? pickChildForeignKey(parentModel, cfg.name)
|
|
1220
|
-
const morph = getMorphRelationDescriptor(parentModel, cfg.name)
|
|
1221
|
-
const morphIdCol = morph ? `${morph.morphName}Id` : undefined
|
|
1222
|
-
const morphTyCol = morph ? `${morph.morphName}Type` : undefined
|
|
1223
|
-
|
|
1224
|
-
out[repeater.name] = rows.map(row => {
|
|
1225
|
-
const r = (row && typeof row === 'object') ? { ...(row as Record<string, unknown>) } : {}
|
|
1226
|
-
const pkValue = r[pkColumn]
|
|
1227
|
-
delete r[pkColumn]
|
|
1228
|
-
if (fkColumn) delete r[fkColumn]
|
|
1229
|
-
if (morphIdCol) delete r[morphIdCol]
|
|
1230
|
-
if (morphTyCol) delete r[morphTyCol]
|
|
1231
|
-
// M2M pivot extras — flatten `row.pivot[col]` onto the row's data
|
|
1232
|
-
// so each pivot column round-trips through the inner schema as a
|
|
1233
|
-
// regular form field. The pivot envelope itself is dropped from
|
|
1234
|
-
// the values shape — the persist side splits pivot vs child
|
|
1235
|
-
// columns by name lookup against `cfg.pivotColumns`.
|
|
1236
|
-
const pivotEnvelope = r['pivot']
|
|
1237
|
-
delete r['pivot']
|
|
1238
|
-
const stamped: Record<string, unknown> = { ...r }
|
|
1239
|
-
if (pivotColumns && pivotColumns.length > 0
|
|
1240
|
-
&& pivotEnvelope && typeof pivotEnvelope === 'object'
|
|
1241
|
-
) {
|
|
1242
|
-
const pe = pivotEnvelope as Record<string, unknown>
|
|
1243
|
-
for (const col of pivotColumns) {
|
|
1244
|
-
if (col in pe) stamped[col] = pe[col]
|
|
1245
|
-
}
|
|
1246
|
-
}
|
|
1247
|
-
if (pkValue !== undefined && pkValue !== null) {
|
|
1248
|
-
stamped['__id'] = String(pkValue)
|
|
1249
|
-
}
|
|
1250
|
-
return stamped
|
|
1251
|
-
})
|
|
1252
|
-
}
|
|
1253
|
-
return out
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
/** Walk the form's children for top-level relationship-backed Repeaters. */
|
|
1257
|
-
function findRelationshipRepeaters(elements: ReadonlyArray<Element>): RepeaterField[] {
|
|
1258
|
-
const out: RepeaterField[] = []
|
|
1259
|
-
const walk = (els: ReadonlyArray<Element>): void => {
|
|
1260
|
-
for (const el of els) {
|
|
1261
|
-
if (isRepeaterField(el)) {
|
|
1262
|
-
const r = el as RepeaterField
|
|
1263
|
-
if (r.getRelationship()) out.push(r)
|
|
1264
|
-
// Don't dive into Repeater children — relationship-on-relationship
|
|
1265
|
-
// isn't supported in v1.
|
|
1266
|
-
continue
|
|
1267
|
-
}
|
|
1268
|
-
// Don't dive into Builder children either — relationship-backed
|
|
1269
|
-
// Builders are resolved separately by `findRelationshipBuilders`.
|
|
1270
|
-
if (isBuilderField(el)) continue
|
|
1271
|
-
const children = el.getChildren()
|
|
1272
|
-
if (children && children.length > 0) walk(children)
|
|
1273
|
-
}
|
|
1274
|
-
}
|
|
1275
|
-
walk(elements)
|
|
1276
|
-
return out
|
|
1277
|
-
}
|
|
1278
|
-
|
|
1279
|
-
/**
|
|
1280
|
-
* Walk the form's top-level Builders and replace `values[fieldName]` with
|
|
1281
|
-
* rows fetched from `parent.related(name)` for any relationship-backed
|
|
1282
|
-
* Builder. Each loaded row stamps `__id` (child PK) + `type` (block
|
|
1283
|
-
* discriminator) + `data` (per-block JSON payload) so the renderer can
|
|
1284
|
-
* round-trip the heterogeneous envelope.
|
|
1285
|
-
*
|
|
1286
|
-
* Mirrors `applyRelationshipRepeaterFill`. No-op when the parent record
|
|
1287
|
-
* is null (create mode), the resource has no `R.model`, or no
|
|
1288
|
-
* relationship-backed Builders exist on the form.
|
|
1289
|
-
*/
|
|
1290
|
-
export async function applyRelationshipBuilderFill(
|
|
1291
|
-
form: Form,
|
|
1292
|
-
values: Record<string, unknown>,
|
|
1293
|
-
record: unknown,
|
|
1294
|
-
parentModel: ModelLike | undefined,
|
|
1295
|
-
): Promise<Record<string, unknown>> {
|
|
1296
|
-
if (record == null) return values
|
|
1297
|
-
if (!parentModel) return values
|
|
1298
|
-
const builders = findRelationshipBuilders(form.getChildren() ?? [])
|
|
1299
|
-
if (builders.length === 0) return values
|
|
1300
|
-
|
|
1301
|
-
const out: Record<string, unknown> = { ...values }
|
|
1302
|
-
for (const builder of builders) {
|
|
1303
|
-
const cfg = builder.getRelationship()!
|
|
1304
|
-
let rows: unknown[]
|
|
1305
|
-
try {
|
|
1306
|
-
rows = await loadRelationRows(parentModel, record, cfg.name)
|
|
1307
|
-
} catch {
|
|
1308
|
-
// Failed lookup (e.g. missing `relations` map on a test stub) —
|
|
1309
|
-
// fall back to whatever value applyFillPipeline produced rather
|
|
1310
|
-
// than wiping the field. Better stale than silently empty.
|
|
1311
|
-
continue
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
const pkColumn = pickChildPrimaryKey(parentModel, cfg.name) ?? 'id'
|
|
1315
|
-
const fkColumn = cfg.foreignKey ?? pickChildForeignKey(parentModel, cfg.name)
|
|
1316
|
-
const typeColumn = cfg.typeColumn ?? 'type'
|
|
1317
|
-
const dataColumn = cfg.dataColumn ?? 'data'
|
|
1318
|
-
|
|
1319
|
-
out[builder.name] = rows.map(row => {
|
|
1320
|
-
const r = (row && typeof row === 'object') ? { ...(row as Record<string, unknown>) } : {}
|
|
1321
|
-
const pkValue = r[pkColumn]
|
|
1322
|
-
const blockType = typeof r[typeColumn] === 'string' ? (r[typeColumn] as string) : ''
|
|
1323
|
-
const dataRaw = r[dataColumn]
|
|
1324
|
-
const blockData = parseBuilderDataPayload(dataRaw)
|
|
1325
|
-
|
|
1326
|
-
const stamped: Record<string, unknown> = {
|
|
1327
|
-
type: blockType,
|
|
1328
|
-
data: blockData,
|
|
1329
|
-
}
|
|
1330
|
-
if (pkValue !== undefined && pkValue !== null) {
|
|
1331
|
-
stamped['__id'] = String(pkValue)
|
|
1332
|
-
}
|
|
1333
|
-
// Non-`type` / `data` / FK / PK columns aren't surfaced — the
|
|
1334
|
-
// JSON envelope is the source of truth for per-block fields. If
|
|
1335
|
-
// a user denormalizes a column, they handle it via per-block
|
|
1336
|
-
// mutate hooks, not by leaking the column into row values.
|
|
1337
|
-
void fkColumn
|
|
1338
|
-
return stamped
|
|
1339
|
-
})
|
|
1340
|
-
}
|
|
1341
|
-
return out
|
|
1342
|
-
}
|
|
1343
|
-
|
|
1344
|
-
/**
|
|
1345
|
-
* Normalize the JSON payload column into a plain object. Prisma
|
|
1346
|
-
* hydrates `Json` columns to objects; some adapters return strings.
|
|
1347
|
-
* Anything that isn't a parseable object falls back to `{}` so the
|
|
1348
|
-
* inner schema renders fresh defaults.
|
|
1349
|
-
*/
|
|
1350
|
-
function parseBuilderDataPayload(raw: unknown): Record<string, unknown> {
|
|
1351
|
-
if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
1352
|
-
return raw as Record<string, unknown>
|
|
1353
|
-
}
|
|
1354
|
-
if (typeof raw === 'string') {
|
|
1355
|
-
try {
|
|
1356
|
-
const parsed: unknown = JSON.parse(raw)
|
|
1357
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1358
|
-
return parsed as Record<string, unknown>
|
|
1359
|
-
}
|
|
1360
|
-
} catch {
|
|
1361
|
-
// fall through to {}
|
|
1362
|
-
}
|
|
1363
|
-
}
|
|
1364
|
-
return {}
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
/** Walk the form's children for top-level relationship-backed Builders. */
|
|
1368
|
-
function findRelationshipBuilders(elements: ReadonlyArray<Element>): BuilderField[] {
|
|
1369
|
-
const out: BuilderField[] = []
|
|
1370
|
-
const walk = (els: ReadonlyArray<Element>): void => {
|
|
1371
|
-
for (const el of els) {
|
|
1372
|
-
if (isBuilderField(el)) {
|
|
1373
|
-
const b = el as BuilderField
|
|
1374
|
-
if (b.getRelationship()) out.push(b)
|
|
1375
|
-
continue
|
|
1376
|
-
}
|
|
1377
|
-
// Don't dive into Repeater children either — both array-row
|
|
1378
|
-
// boundaries are walker stops here.
|
|
1379
|
-
if (isRepeaterField(el)) continue
|
|
1380
|
-
const children = el.getChildren()
|
|
1381
|
-
if (children && children.length > 0) walk(children)
|
|
1382
|
-
}
|
|
1383
|
-
}
|
|
1384
|
-
walk(elements)
|
|
1385
|
-
return out
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
/** Read the child model's PK column from the parent's relations map, when present. */
|
|
1389
|
-
function pickChildPrimaryKey(parentModel: ModelLike, name: string): string | undefined {
|
|
1390
|
-
const relations = (parentModel as unknown as Record<string, unknown>)['relations']
|
|
1391
|
-
if (!relations || typeof relations !== 'object') return undefined
|
|
1392
|
-
const entry = (relations as Record<string, unknown>)[name]
|
|
1393
|
-
if (!entry || typeof entry !== 'object') return undefined
|
|
1394
|
-
const e = entry as Record<string, unknown>
|
|
1395
|
-
if (typeof e['model'] !== 'function') return undefined
|
|
1396
|
-
try {
|
|
1397
|
-
const child = (e['model'] as () => ModelLike)()
|
|
1398
|
-
return getPrimaryKey(child)
|
|
1399
|
-
} catch {
|
|
1400
|
-
return undefined
|
|
1401
|
-
}
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
/** Read the FK column from the parent's relations map, when present. */
|
|
1405
|
-
function pickChildForeignKey(parentModel: ModelLike, name: string): string | undefined {
|
|
1406
|
-
const relations = (parentModel as unknown as Record<string, unknown>)['relations']
|
|
1407
|
-
if (!relations || typeof relations !== 'object') return undefined
|
|
1408
|
-
const entry = (relations as Record<string, unknown>)[name]
|
|
1409
|
-
if (!entry || typeof entry !== 'object') return undefined
|
|
1410
|
-
const e = entry as Record<string, unknown>
|
|
1411
|
-
return typeof e['foreignKey'] === 'string' ? (e['foreignKey'] as string) : undefined
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
// ─── Plan #15 server-data widgets ─────────────────────────────
|
|
1415
|
-
|
|
1416
|
-
/** Wire-shape of the per-widget data map shipped to the client.
|
|
1417
|
-
* Lazy elements stamp `null` (renderer paints skeleton + fetches);
|
|
1418
|
-
* eager elements stamp their resolved payload. Errors stamp
|
|
1419
|
-
* `{ error: '<message>' }` so the renderer can surface a per-widget
|
|
1420
|
-
* failure without blanking the page. */
|
|
1421
|
-
export type ServerDataMap = Record<string, unknown>
|
|
1422
|
-
|
|
1423
|
-
/**
|
|
1424
|
-
* Plan #15 — collect every `ServerDataElement` in the schema tree and
|
|
1425
|
-
* resolve their `getServerData(ctx)` payloads in parallel. Returns a
|
|
1426
|
-
* map keyed by element id, ready to ship as `viewProps._widgetData`.
|
|
1427
|
-
*
|
|
1428
|
-
* Lazy elements (default — `lazy(false)` opts out) skip the hook and
|
|
1429
|
-
* stamp `null` so the renderer paints a skeleton and fetches the
|
|
1430
|
-
* payload via `POST {base}/_widget/:id` on mount. Eager elements
|
|
1431
|
-
* resolve synchronously and ship the data with the page.
|
|
1432
|
-
*
|
|
1433
|
-
* Per-widget errors are caught and surfaced as `{ error: '...' }` —
|
|
1434
|
-
* one flaky `getStats()` shouldn't 500 the entire dashboard.
|
|
1435
|
-
*
|
|
1436
|
-
* Visibility is **not** re-evaluated here. The schema resolver
|
|
1437
|
-
* (`resolveSchema → evaluateVisibility`) drops hidden layout elements
|
|
1438
|
-
* before any widget code runs. Widgets inside still-rendered branches
|
|
1439
|
-
* always resolve (or stamp lazy null).
|
|
1440
|
-
*/
|
|
1441
|
-
export async function resolveServerDataElements(
|
|
1442
|
-
elements: ReadonlyArray<Element>,
|
|
1443
|
-
ctx: RenderContext,
|
|
1444
|
-
): Promise<ServerDataMap> {
|
|
1445
|
-
const widgets = collectServerDataElements(elements)
|
|
1446
|
-
if (widgets.length === 0) return {}
|
|
1447
|
-
const out: ServerDataMap = {}
|
|
1448
|
-
await Promise.all(widgets.map(async (el) => {
|
|
1449
|
-
const id = el.getId()
|
|
1450
|
-
if (el.isLazy()) {
|
|
1451
|
-
out[id] = null // sentinel — renderer paints skeleton, fetches on mount
|
|
1452
|
-
return
|
|
1453
|
-
}
|
|
1454
|
-
try {
|
|
1455
|
-
out[id] = await el.resolveServerData(ctx)
|
|
1456
|
-
} catch (err) {
|
|
1457
|
-
out[id] = { error: err instanceof Error ? err.message : 'Widget failed to load' }
|
|
1458
|
-
}
|
|
1459
|
-
}))
|
|
1460
|
-
return out
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
/** Walk the tree collecting every `ServerDataElement`. Walks into
|
|
1464
|
-
* containers but stops at Form/Repeater/Builder boundaries — widgets
|
|
1465
|
-
* inside an editable form don't make sense in v1. */
|
|
1466
|
-
function collectServerDataElements(elements: ReadonlyArray<Element>): ServerDataElement[] {
|
|
1467
|
-
const out: ServerDataElement[] = []
|
|
1468
|
-
const walk = (els: ReadonlyArray<Element>): void => {
|
|
1469
|
-
for (const el of els) {
|
|
1470
|
-
if (isServerDataElement(el)) {
|
|
1471
|
-
out.push(el)
|
|
1472
|
-
// Don't recurse into a widget's children — `View` etc. are leaves
|
|
1473
|
-
// for v1 (no nested widgets inside widgets).
|
|
1474
|
-
continue
|
|
1475
|
-
}
|
|
1476
|
-
// Skip walkers that imply per-row resolution — widgets inside
|
|
1477
|
-
// Repeater/Builder rows don't have a stable id space.
|
|
1478
|
-
const type = el.getType()
|
|
1479
|
-
if (type === 'form' || type === 'repeater' || type === 'builder' || type === 'table' || type === 'tableWidget') continue
|
|
1480
|
-
const children = el.getChildren()
|
|
1481
|
-
if (children) walk(children)
|
|
1482
|
-
}
|
|
1483
|
-
}
|
|
1484
|
-
walk(elements)
|
|
1485
|
-
return out
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/**
|
|
1489
|
-
* Plan #15 — stamp the polling-endpoint URL on every `ServerDataElement`
|
|
1490
|
-
* in the tree. Mirrors `tagFormStateUrls / tagTableReorderUrls`. Walks
|
|
1491
|
-
* with the same boundaries as `collectServerDataElements` so the wire
|
|
1492
|
-
* stays in sync (no orphan widgets without URLs and vice versa).
|
|
1493
|
-
*
|
|
1494
|
-
* `urlBuilder(id)` typically produces `${base}/_widget/${id}` for
|
|
1495
|
-
* dashboard widgets and `${base}/${pageSlug}/_widget/${id}` for
|
|
1496
|
-
* custom-page widgets — the route handlers for both shapes are wired up
|
|
1497
|
-
* in `routes.ts` (see Phase A.4).
|
|
1498
|
-
*/
|
|
1499
|
-
export function tagWidgetUrls(
|
|
1500
|
-
elements: ReadonlyArray<Element>,
|
|
1501
|
-
urlBuilder: (id: string) => string,
|
|
1502
|
-
): void {
|
|
1503
|
-
for (const widget of collectServerDataElements(elements)) {
|
|
1504
|
-
if (widget.getWidgetUrl()) continue // user-set wins
|
|
1505
|
-
widget.withWidgetUrl(urlBuilder(widget.getId()))
|
|
1506
|
-
}
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
/** Stamp dispatchUrl on every handler-style Action so the client knows where to POST. */
|
|
1510
|
-
export function tagActionDispatch(elements: ReadonlyArray<Element>, baseUrl: string): void {
|
|
1511
|
-
for (const action of findActions(elements)) {
|
|
1512
|
-
if (!action.getHandler()) continue
|
|
1513
|
-
if (action.getHref() || action.getMethod()) continue
|
|
1514
|
-
if (action.getDispatchUrl()) continue
|
|
1515
|
-
action.dispatchUrl(`${baseUrl}/_action/${action.name}`)
|
|
1516
|
-
}
|
|
1517
|
-
// Row-scoped extraItemActions (Repeater/Builder). Stamped here too so
|
|
1518
|
-
// the client can POST to the same `_action/:name` route — the renderer
|
|
1519
|
-
// attaches `_rowPath=<fieldName>.<index>` per click; the server's
|
|
1520
|
-
// dispatcher uses that to walk into the right row when building
|
|
1521
|
-
// `ctx.row`. See `findRowExtraActions` in `dispatchAction.ts`.
|
|
1522
|
-
for (const { action } of findRowExtraActions(elements)) {
|
|
1523
|
-
if (!action.getHandler()) continue
|
|
1524
|
-
if (action.getDispatchUrl()) continue
|
|
1525
|
-
action.dispatchUrl(`${baseUrl}/_action/${action.name}`)
|
|
1526
|
-
}
|
|
1527
|
-
}
|
|
1528
|
-
|
|
1529
|
-
// ─── Per-role data builders ──────────────────────────────────
|
|
1530
|
-
|
|
1531
|
-
export async function dashboardData(pilotiq: Pilotiq, req?: unknown): Promise<Record<string, unknown>> {
|
|
1532
|
-
const cfg = pilotiq.getConfig()
|
|
1533
|
-
const user = await pilotiq.resolveUser(req)
|
|
1534
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ basePath: cfg.path }, user), cfg)
|
|
1535
|
-
|
|
1536
|
-
// Plan #15 — when `panel.dashboard(P)` was called, resolve P's
|
|
1537
|
-
// schema instead of the builder-level `cfg.schema`. Page-scoped
|
|
1538
|
-
// schema means widget elements read like a regular custom page —
|
|
1539
|
-
// including action dispatch, form-state, and `_widget/:id` polling.
|
|
1540
|
-
let elements: Element[]
|
|
1541
|
-
if (cfg.dashboardPage) {
|
|
1542
|
-
elements = await callPageSchema(cfg.dashboardPage, ctx)
|
|
1543
|
-
tagFormActions(elements, cfg.path)
|
|
1544
|
-
tagFormStateUrls(elements, formId => `${cfg.path}/_form/${formId}/state`)
|
|
1545
|
-
tagFormWizardUrls(elements, formId => `${cfg.path}/_form/${formId}/wizard`)
|
|
1546
|
-
tagRichTextMentionUrls(elements, formId => `${cfg.path}/_form/${formId}/mentions`)
|
|
1547
|
-
tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${cfg.path}/_form/${formId}/create-option/${fieldName}`)
|
|
1548
|
-
tagActionDispatch(elements, cfg.path)
|
|
1549
|
-
} else {
|
|
1550
|
-
elements = []
|
|
1551
|
-
if (cfg.schema) {
|
|
1552
|
-
const def = cfg.schema
|
|
1553
|
-
elements = typeof def === 'function' ? await def(ctx) : def
|
|
1554
|
-
}
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
// Stamp polling URLs on every widget — panel-scope (no pageSlug
|
|
1558
|
-
// segment) for the dashboard. Done before schema resolve so the URL
|
|
1559
|
-
// rides on each widget's stamped meta.
|
|
1560
|
-
tagWidgetUrls(elements, id => `${cfg.path}/_widget/${id}`)
|
|
1561
|
-
|
|
1562
|
-
const widgetData = await resolveServerDataElements(elements, ctx)
|
|
1563
|
-
const dashRoute: PanelInfoRoute = cfg.dashboardPage ? { page: cfg.dashboardPage } : {}
|
|
1564
|
-
const schemaData = await applyRoleHooks(
|
|
1565
|
-
pilotiq, user, 'dashboard',
|
|
1566
|
-
await resolveSchema(elements, ctx),
|
|
1567
|
-
dashRoute,
|
|
1568
|
-
)
|
|
1569
|
-
|
|
1570
|
-
return {
|
|
1571
|
-
panel: await panelInfo(pilotiq, req, dashRoute),
|
|
1572
|
-
page: cfg.dashboardPage ? cfg.dashboardPage.toMeta() : undefined,
|
|
1573
|
-
basePath: cfg.path,
|
|
1574
|
-
layout: cfg.layout,
|
|
1575
|
-
schemaData,
|
|
1576
|
-
_widgetData: widgetData,
|
|
1577
|
-
notifications: consumeFlashedNotifications(req),
|
|
1578
|
-
}
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
export async function resourceIndexData(
|
|
1582
|
-
pilotiq: Pilotiq,
|
|
1583
|
-
slug: string,
|
|
1584
|
-
query: Record<string, string> = {},
|
|
1585
|
-
req?: unknown,
|
|
1586
|
-
): Promise<Record<string, unknown> | null> {
|
|
1587
|
-
const cfg = pilotiq.getConfig()
|
|
1588
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
1589
|
-
if (!R) return null
|
|
1590
|
-
|
|
1591
|
-
const pages = R.resolvePages()
|
|
1592
|
-
if (!pages.index) return null
|
|
1593
|
-
const PageClass = pages.index
|
|
1594
|
-
|
|
1595
|
-
const indexUrl = resourceBasePath(cfg.path, R)
|
|
1596
|
-
const user = await pilotiq.resolveUser(req)
|
|
1597
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'table', basePath: cfg.path }, user), cfg)
|
|
1598
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1599
|
-
tagActionDispatch(elements, indexUrl)
|
|
1600
|
-
// Plan #15 — resource-scope widget polling URL. Stamped before the
|
|
1601
|
-
// schema resolves so each widget's meta carries its endpoint.
|
|
1602
|
-
tagWidgetUrls(elements, id => `${indexUrl}/_widget/${id}`)
|
|
1603
|
-
// Mark the active tab + parallel-eval badges + stamp per-tab URLs
|
|
1604
|
-
// before the table records run — `loadTableRecords` walks the schema
|
|
1605
|
-
// for the active tab and splices its `modifyQuery` predicate into the
|
|
1606
|
-
// ORM chain alongside filters.
|
|
1607
|
-
await resolveActiveTab(elements, query, indexUrl)
|
|
1608
|
-
if (R.deferLoading) tagTableDeferred(elements, `${indexUrl}/_table`)
|
|
1609
|
-
await loadTableRecords(elements, query, indexUrl, user, {
|
|
1610
|
-
canEdit: (u, record) => R.canEdit(u, record),
|
|
1611
|
-
})
|
|
1612
|
-
tagTableReorderUrls(elements, `${indexUrl}/_reorder`)
|
|
1613
|
-
tagCellEditUrls(elements, indexUrl)
|
|
1614
|
-
const widgetData = await resolveServerDataElements(elements, ctx)
|
|
1615
|
-
|
|
1616
|
-
const breadcrumbs = resourceListBreadcrumbs(cfg, R)
|
|
1617
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
1618
|
-
|
|
1619
|
-
const listRoute: PanelInfoRoute = { resource: R, page: PageClass }
|
|
1620
|
-
const schemaData = await applyRoleHooks(
|
|
1621
|
-
pilotiq, user, 'list',
|
|
1622
|
-
await resolveSchema(elements, ctx),
|
|
1623
|
-
listRoute,
|
|
1624
|
-
)
|
|
1625
|
-
|
|
1626
|
-
return {
|
|
1627
|
-
pageType: 'resource',
|
|
1628
|
-
panel: await panelInfo(pilotiq, req, listRoute),
|
|
1629
|
-
page: PageClass.toMeta(),
|
|
1630
|
-
resource: { name: R.name, label: R.label, labelSingular: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
1631
|
-
basePath: cfg.path,
|
|
1632
|
-
layout: cfg.layout,
|
|
1633
|
-
schemaData,
|
|
1634
|
-
_widgetData: widgetData,
|
|
1635
|
-
notifications: consumeFlashedNotifications(req),
|
|
1636
|
-
}
|
|
1637
|
-
}
|
|
1638
|
-
|
|
1639
|
-
// Deferred-load JSON endpoint payload — `GET {base}/{slug}/_table`
|
|
1640
|
-
// re-runs the list-page builder without the deferred flag, then returns
|
|
1641
|
-
// every resolved `TableMeta` as a flat array. Returns null on missing
|
|
1642
|
-
// resource / index page (route 404s).
|
|
1643
|
-
export async function resourceTableData(
|
|
1644
|
-
pilotiq: Pilotiq,
|
|
1645
|
-
slug: string,
|
|
1646
|
-
query: Record<string, string> = {},
|
|
1647
|
-
req?: unknown,
|
|
1648
|
-
): Promise<{ tables: Record<string, unknown>[] } | null> {
|
|
1649
|
-
const cfg = pilotiq.getConfig()
|
|
1650
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
1651
|
-
if (!R) return null
|
|
1652
|
-
|
|
1653
|
-
const pages = R.resolvePages()
|
|
1654
|
-
if (!pages.index) return null
|
|
1655
|
-
const PageClass = pages.index
|
|
1656
|
-
|
|
1657
|
-
const indexUrl = resourceBasePath(cfg.path, R)
|
|
1658
|
-
const user = await pilotiq.resolveUser(req)
|
|
1659
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'table', basePath: cfg.path }, user), cfg)
|
|
1660
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1661
|
-
tagActionDispatch(elements, indexUrl)
|
|
1662
|
-
await resolveActiveTab(elements, query, indexUrl)
|
|
1663
|
-
await loadTableRecords(elements, query, indexUrl, user, {
|
|
1664
|
-
canEdit: (u, record) => R.canEdit(u, record),
|
|
1665
|
-
})
|
|
1666
|
-
tagTableReorderUrls(elements, `${indexUrl}/_reorder`)
|
|
1667
|
-
tagCellEditUrls(elements, indexUrl)
|
|
1668
|
-
const schemaData = await resolveSchema(elements, ctx)
|
|
1669
|
-
|
|
1670
|
-
const tables = collectTableMetas(schemaData)
|
|
1671
|
-
return { tables }
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
function collectTableMetas(
|
|
1675
|
-
metas: ReadonlyArray<Record<string, unknown>>,
|
|
1676
|
-
): Record<string, unknown>[] {
|
|
1677
|
-
const out: Record<string, unknown>[] = []
|
|
1678
|
-
const walk = (nodes: ReadonlyArray<Record<string, unknown>>): void => {
|
|
1679
|
-
for (const node of nodes) {
|
|
1680
|
-
if (node['type'] === 'table') out.push(node)
|
|
1681
|
-
const children = node['children']
|
|
1682
|
-
if (Array.isArray(children)) walk(children as Record<string, unknown>[])
|
|
1683
|
-
}
|
|
1684
|
-
}
|
|
1685
|
-
walk(metas)
|
|
1686
|
-
return out
|
|
1687
|
-
}
|
|
1688
|
-
|
|
1689
|
-
/**
|
|
1690
|
-
* Walk the schema for `ListTabs` containers, pick the active tab from
|
|
1691
|
-
* `?tab=…` (defaulting to the tab marked `.default()` or the first one),
|
|
1692
|
-
* stamp render-time state (`active` flag, per-tab `?tab=` URL, and
|
|
1693
|
-
* resolved badge counts) onto each tab. The active tab's query/context
|
|
1694
|
-
* modifier is NOT applied here — `loadTableRecords` walks for the active
|
|
1695
|
-
* tab and splices in its modifier when it builds the records-handler
|
|
1696
|
-
* `TableContext`.
|
|
1697
|
-
*
|
|
1698
|
-
* No-op when the page has no `ListTabs`.
|
|
1699
|
-
*/
|
|
1700
|
-
export async function resolveActiveTab(
|
|
1701
|
-
elements: ReadonlyArray<Element>,
|
|
1702
|
-
query: Record<string, string>,
|
|
1703
|
-
currentPath: string,
|
|
1704
|
-
): Promise<void> {
|
|
1705
|
-
const listTabs = findListTabs(elements)
|
|
1706
|
-
if (listTabs.length === 0) return
|
|
1707
|
-
|
|
1708
|
-
for (const container of listTabs) {
|
|
1709
|
-
const children = (container.getChildren() ?? []).filter((c): c is ListTab => c.getType() === 'listTab')
|
|
1710
|
-
if (children.length === 0) continue
|
|
1711
|
-
|
|
1712
|
-
// Default tab (used both for `?tab=` fallback and to omit the param
|
|
1713
|
-
// from the canonical URL of that tab — see `buildTabUrl`).
|
|
1714
|
-
const defaultTab = children.find(t => t.isDefault()) ?? children[0]!
|
|
1715
|
-
|
|
1716
|
-
// Active tab: explicit `?tab=name` → default tab.
|
|
1717
|
-
const wanted = typeof query['tab'] === 'string' ? query['tab'] : undefined
|
|
1718
|
-
const active = (wanted && children.find(t => t.name === wanted)) || defaultTab
|
|
1719
|
-
|
|
1720
|
-
// Stamp render-time state on each tab.
|
|
1721
|
-
children.forEach(t => {
|
|
1722
|
-
t.withActive(t === active)
|
|
1723
|
-
t.withUrl(buildTabUrl(currentPath, query, t.name, defaultTab.name))
|
|
1724
|
-
})
|
|
1725
|
-
|
|
1726
|
-
// Resolve every tab's badge in parallel — failed handlers swallow
|
|
1727
|
-
// silently (badge omitted) so a flaky count never blanks the page.
|
|
1728
|
-
await Promise.all(children.map(async (tab) => {
|
|
1729
|
-
const handler = tab.getBadgeHandler()
|
|
1730
|
-
if (!handler) return
|
|
1731
|
-
try {
|
|
1732
|
-
const v = await handler()
|
|
1733
|
-
if (v === undefined || v === null) return
|
|
1734
|
-
tab.withResolvedBadge(String(v))
|
|
1735
|
-
} catch {
|
|
1736
|
-
// Per-tab badge errors stay silent.
|
|
1737
|
-
}
|
|
1738
|
-
}))
|
|
1739
|
-
}
|
|
1740
|
-
}
|
|
1741
|
-
|
|
1742
|
-
function findListTabs(elements: ReadonlyArray<Element>): ListTabs[] {
|
|
1743
|
-
const out: ListTabs[] = []
|
|
1744
|
-
const walk = (els: ReadonlyArray<Element>): void => {
|
|
1745
|
-
for (const el of els) {
|
|
1746
|
-
if (el.getType() === 'listTabs') out.push(el as ListTabs)
|
|
1747
|
-
const children = el.getChildren()
|
|
1748
|
-
if (children) walk(children)
|
|
1749
|
-
}
|
|
1750
|
-
}
|
|
1751
|
-
walk(elements)
|
|
1752
|
-
return out
|
|
1753
|
-
}
|
|
1754
|
-
|
|
1755
|
-
function buildTabUrl(
|
|
1756
|
-
pathname: string,
|
|
1757
|
-
query: Record<string, string>,
|
|
1758
|
-
tabName: string,
|
|
1759
|
-
defaultTabName: string,
|
|
1760
|
-
): string {
|
|
1761
|
-
// Carry forward search/sort/perPage + any filter values; reset page to 1
|
|
1762
|
-
// (tab change reshapes the result set, page numbers don't translate).
|
|
1763
|
-
// The default tab gets the canonical, paramless URL — visiting that URL
|
|
1764
|
-
// already lands on the default, so emitting `?tab=default` would just be
|
|
1765
|
-
// noise that bookmarks/share-links pick up.
|
|
1766
|
-
const params = new URLSearchParams()
|
|
1767
|
-
for (const [k, v] of Object.entries(query)) {
|
|
1768
|
-
if (v === undefined || v === '' || v === null) continue
|
|
1769
|
-
if (k === 'tab' || k === 'page') continue
|
|
1770
|
-
params.set(k, String(v))
|
|
1771
|
-
}
|
|
1772
|
-
if (tabName !== defaultTabName) params.set('tab', tabName)
|
|
1773
|
-
const qs = params.toString()
|
|
1774
|
-
return qs ? `${pathname}?${qs}` : pathname
|
|
1775
|
-
}
|
|
1776
|
-
|
|
1777
|
-
export async function resourceCreateData(
|
|
1778
|
-
pilotiq: Pilotiq,
|
|
1779
|
-
slug: string,
|
|
1780
|
-
prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
|
|
1781
|
-
req?: unknown,
|
|
1782
|
-
): Promise<Record<string, unknown> | null> {
|
|
1783
|
-
const cfg = pilotiq.getConfig()
|
|
1784
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
1785
|
-
if (!R) return null
|
|
1786
|
-
const pages = R.resolvePages()
|
|
1787
|
-
if (!pages.create) return null
|
|
1788
|
-
const PageClass = pages.create
|
|
1789
|
-
|
|
1790
|
-
const resourceBase = resourceBasePath(cfg.path, R)
|
|
1791
|
-
const createUrl = `${resourceBase}/create`
|
|
1792
|
-
const user = await pilotiq.resolveUser(req)
|
|
1793
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'create', basePath: cfg.path }, user), cfg)
|
|
1794
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1795
|
-
tagFormActions(elements, createUrl)
|
|
1796
|
-
tagActionDispatch(elements, createUrl)
|
|
1797
|
-
tagFormStateUrls(elements, formId => `${resourceBase}/_form/${formId}/state`)
|
|
1798
|
-
tagFormWizardUrls(elements, formId => `${resourceBase}/_form/${formId}/wizard`)
|
|
1799
|
-
tagRichTextMentionUrls(elements, formId => `${resourceBase}/_form/${formId}/mentions`)
|
|
1800
|
-
tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${resourceBase}/_form/${formId}/create-option/${fieldName}`)
|
|
1801
|
-
if (prefill) {
|
|
1802
|
-
const form = findForms(elements)[0]
|
|
1803
|
-
if (form) {
|
|
1804
|
-
if (prefill.values) form.withValues(prefill.values)
|
|
1805
|
-
if (prefill.errors) form.withErrors(prefill.errors)
|
|
1806
|
-
}
|
|
1807
|
-
}
|
|
1808
|
-
|
|
1809
|
-
const breadcrumbs = resourceCreateBreadcrumbs(cfg, R)
|
|
1810
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
1811
|
-
|
|
1812
|
-
const createRoute: PanelInfoRoute = { resource: R, page: PageClass }
|
|
1813
|
-
const schemaData = await applyRoleHooks(
|
|
1814
|
-
pilotiq, user, 'create',
|
|
1815
|
-
await resolveSchema(elements, ctx),
|
|
1816
|
-
createRoute,
|
|
1817
|
-
)
|
|
1818
|
-
|
|
1819
|
-
return {
|
|
1820
|
-
panel: await panelInfo(pilotiq, req, createRoute),
|
|
1821
|
-
page: PageClass.toMeta(),
|
|
1822
|
-
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
1823
|
-
mode: 'create' as const,
|
|
1824
|
-
basePath: cfg.path,
|
|
1825
|
-
layout: cfg.layout,
|
|
1826
|
-
schemaData,
|
|
1827
|
-
notifications: consumeFlashedNotifications(req),
|
|
1828
|
-
...(prefill?.errors ? { hasErrors: true } : {}),
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
export async function resourceEditData(
|
|
1833
|
-
pilotiq: Pilotiq,
|
|
1834
|
-
slug: string,
|
|
1835
|
-
recordId: string,
|
|
1836
|
-
prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
|
|
1837
|
-
req?: unknown,
|
|
1838
|
-
): Promise<Record<string, unknown> | null> {
|
|
1839
|
-
const cfg = pilotiq.getConfig()
|
|
1840
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
1841
|
-
if (!R) return null
|
|
1842
|
-
const pages = R.resolvePages()
|
|
1843
|
-
if (!pages.edit) return null
|
|
1844
|
-
const PageClass = pages.edit
|
|
1845
|
-
|
|
1846
|
-
const resourceBase = resourceBasePath(cfg.path, R)
|
|
1847
|
-
const editUrl = `${resourceBase}/${recordId}/edit`
|
|
1848
|
-
const user = await pilotiq.resolveUser(req)
|
|
1849
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'edit', recordId, basePath: cfg.path }, user), cfg)
|
|
1850
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
1851
|
-
tagFormActions(elements, editUrl)
|
|
1852
|
-
tagActionDispatch(elements, editUrl)
|
|
1853
|
-
tagFormStateUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/state`)
|
|
1854
|
-
tagFormWizardUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/wizard`)
|
|
1855
|
-
tagRichTextMentionUrls(elements, formId => `${resourceBase}/${recordId}/_form/${formId}/mentions`)
|
|
1856
|
-
tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${resourceBase}/${recordId}/_form/${formId}/create-option/${fieldName}`)
|
|
1857
|
-
|
|
1858
|
-
// Locate the primary form, load the record, fill values.
|
|
1859
|
-
const form = findForms(elements)[0]
|
|
1860
|
-
let record: unknown = undefined
|
|
1861
|
-
if (form?.getLoadRecord()) {
|
|
1862
|
-
try {
|
|
1863
|
-
record = await form.getLoadRecord()!(recordId, { values: prefill?.values ?? {} })
|
|
1864
|
-
} catch {
|
|
1865
|
-
// sentinel/missing record — fall through
|
|
1866
|
-
}
|
|
1867
|
-
if (!prefill?.values && record != null) {
|
|
1868
|
-
const values = await applyFillPipeline(form, record)
|
|
1869
|
-
const withRelations = await applyRelationshipRepeaterFill(form, values, record, R.model)
|
|
1870
|
-
const withBuilders = await applyRelationshipBuilderFill(form, withRelations, record, R.model)
|
|
1871
|
-
form.withValues(withBuilders)
|
|
1872
|
-
} else if (prefill?.values) {
|
|
1873
|
-
form.withValues(prefill.values)
|
|
1874
|
-
}
|
|
1875
|
-
if (prefill?.errors) form.withErrors(prefill.errors)
|
|
1876
|
-
}
|
|
1877
|
-
|
|
1878
|
-
// Plan #11 — when the resource has relation managers, prepend a
|
|
1879
|
-
// navigation strip so users can drill into each manager's table
|
|
1880
|
-
// without leaving the parent record context. The "Edit" tab is
|
|
1881
|
-
// active here.
|
|
1882
|
-
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__edit', user, record)
|
|
1883
|
-
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
1884
|
-
|
|
1885
|
-
const recordTitle = record !== undefined && record !== null
|
|
1886
|
-
? deriveParentTitle(R, record)
|
|
1887
|
-
: recordId
|
|
1888
|
-
const breadcrumbs = resourceEditBreadcrumbs(cfg, R, recordId, recordTitle)
|
|
1889
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
1890
|
-
|
|
1891
|
-
const editRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
|
|
1892
|
-
const schemaData = await applyRoleHooks(
|
|
1893
|
-
pilotiq, user, 'edit',
|
|
1894
|
-
await resolveSchema(
|
|
1895
|
-
elements,
|
|
1896
|
-
record !== undefined ? { ...ctx, record } : ctx,
|
|
1897
|
-
),
|
|
1898
|
-
editRoute,
|
|
1899
|
-
)
|
|
1900
|
-
|
|
1901
|
-
tagFieldAiUrls(schemaData as Record<string, unknown>[], `${resourceBase}/${recordId}/_agents`)
|
|
1902
|
-
|
|
1903
|
-
return {
|
|
1904
|
-
panel: await panelInfo(pilotiq, req, editRoute),
|
|
1905
|
-
page: PageClass.toMeta(),
|
|
1906
|
-
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
1907
|
-
mode: 'edit' as const,
|
|
1908
|
-
recordId,
|
|
1909
|
-
basePath: cfg.path,
|
|
1910
|
-
layout: cfg.layout,
|
|
1911
|
-
schemaData,
|
|
1912
|
-
notifications: consumeFlashedNotifications(req),
|
|
1913
|
-
...(prefill?.errors ? { hasErrors: true } : {}),
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
|
|
1917
|
-
// ─── Plan #11 relation-manager data builder ─────────────────
|
|
1918
|
-
|
|
1919
|
-
/**
|
|
1920
|
-
* Plan #11 — three scopes a single relation-manager URL space resolves to:
|
|
1921
|
-
*
|
|
1922
|
-
* list: GET {base}/{slug}/:id/{rel}
|
|
1923
|
-
* create: GET {base}/{slug}/:id/{rel}/create
|
|
1924
|
-
* edit: GET {base}/{slug}/:id/{rel}/{childId}/edit
|
|
1925
|
-
*
|
|
1926
|
-
* Each carries enough state for `relationManagerData` to load the right
|
|
1927
|
-
* parent + (for edit) child + form/table context. Submit-side handlers
|
|
1928
|
-
* live in `routes.ts` and reuse `dispatchFormSubmit`.
|
|
1929
|
-
*/
|
|
1930
|
-
export type RelationManagerScope =
|
|
1931
|
-
| { kind: 'relation-list'; slug: string; recordId: string; relationship: string; query?: Record<string, string> }
|
|
1932
|
-
| { kind: 'relation-create'; slug: string; recordId: string; relationship: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
|
|
1933
|
-
| { kind: 'relation-view'; slug: string; recordId: string; relationship: string; childId: string }
|
|
1934
|
-
| { kind: 'relation-edit'; slug: string; recordId: string; relationship: string; childId: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
|
|
1935
|
-
// Phase B nested resources — the leaf is one manager deeper than the
|
|
1936
|
-
// depth-1 variants. The two-step `chain` carries the (recordId,
|
|
1937
|
-
// relationship) for each layer; the trailing `childId` (when present)
|
|
1938
|
-
// is the leaf record's id under chain[1].
|
|
1939
|
-
| { kind: 'nested-relation-list'; slug: string; chain: [RelationChainStep, RelationChainStep]; query?: Record<string, string> }
|
|
1940
|
-
| { kind: 'nested-relation-create'; slug: string; chain: [RelationChainStep, RelationChainStep]; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
|
|
1941
|
-
| { kind: 'nested-relation-view'; slug: string; chain: [RelationChainStep, RelationChainStep]; childId: string }
|
|
1942
|
-
| { kind: 'nested-relation-edit'; slug: string; chain: [RelationChainStep, RelationChainStep]; childId: string; prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> } }
|
|
1943
|
-
|
|
1944
|
-
/** Phase B — one parent layer in a nested-resources URL chain. The list
|
|
1945
|
-
* of these identifies a path through the manager tree:
|
|
1946
|
-
* `[ { recordId: '123', relationship: 'comments' } ]` picks comment
|
|
1947
|
-
* "456 under post 123" when paired with `childId: '456'`. */
|
|
1948
|
-
export interface RelationChainStep {
|
|
1949
|
-
recordId: string
|
|
1950
|
-
relationship: string
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
|
-
/**
|
|
1954
|
-
* Failure outcomes the data builder discriminates back to the route
|
|
1955
|
-
* handler, which decides between 403 / 404 / HTML / JSON shapes.
|
|
1956
|
-
*
|
|
1957
|
-
* `null` — unknown panel / parent / manager / child;
|
|
1958
|
-
* route returns 404
|
|
1959
|
-
* `{ ok: false, status: 403 }` — policy denied; route returns 403
|
|
1960
|
-
*
|
|
1961
|
-
* Success returns the schemaData payload directly (a record, not
|
|
1962
|
-
* tagged) for parity with `resourceIndexData / resourceCreateData`.
|
|
1963
|
-
*/
|
|
1964
|
-
export type RelationManagerResult =
|
|
1965
|
-
| Record<string, unknown>
|
|
1966
|
-
| { ok: false; status: 403 }
|
|
1967
|
-
| null
|
|
1968
|
-
|
|
1969
|
-
/**
|
|
1970
|
-
* Discover the related Resource for a manager. Order:
|
|
1971
|
-
* 1. `M.relatedResource` explicit override (skip discovery).
|
|
1972
|
-
* 2. Rudder ORM convention: walk
|
|
1973
|
-
* `R.model.relations[manager.relationship].model()` and find
|
|
1974
|
-
* `cfg.resources[i].model === relatedModel`.
|
|
1975
|
-
* 3. Otherwise undefined — caller must error or fall back.
|
|
1976
|
-
*
|
|
1977
|
-
* A returned Resource is the one whose `model` backs the related
|
|
1978
|
-
* table. Callers use it for `Related.model.find(childId)`,
|
|
1979
|
-
* `Related.canEdit(user, child)`, and the auto-wired form save handler.
|
|
1980
|
-
*/
|
|
1981
|
-
export function findRelatedResource(
|
|
1982
|
-
M: typeof RelationManager,
|
|
1983
|
-
R: ResourceClass,
|
|
1984
|
-
cfg: ReturnType<Pilotiq['getConfig']>,
|
|
1985
|
-
): ResourceClass | undefined {
|
|
1986
|
-
if (M.relatedResource) return M.relatedResource
|
|
1987
|
-
const ParentModel = R.model as unknown as { relations?: Record<string, { model?: () => unknown }> } | undefined
|
|
1988
|
-
if (!ParentModel) return undefined
|
|
1989
|
-
const def = ParentModel.relations?.[M.getRelationship()]
|
|
1990
|
-
const RelatedModel = typeof def?.model === 'function' ? def.model() : undefined
|
|
1991
|
-
if (!RelatedModel) return undefined
|
|
1992
|
-
return cfg.resources.find(r => (r.model as unknown) === RelatedModel)
|
|
1993
|
-
}
|
|
1994
|
-
|
|
1995
|
-
/** Find a registered manager on a Resource by its relationship key.
|
|
1996
|
-
* Throws on unknown manager — so the route can 404 cleanly. */
|
|
1997
|
-
function findManager(
|
|
1998
|
-
R: ResourceClass,
|
|
1999
|
-
relationship: string,
|
|
2000
|
-
): typeof RelationManager | undefined {
|
|
2001
|
-
return R.relations().find(M => {
|
|
2002
|
-
try { return M.getRelationship() === relationship } catch { return false }
|
|
2003
|
-
})
|
|
2004
|
-
}
|
|
2005
|
-
|
|
2006
|
-
/**
|
|
2007
|
-
* Verify a child record actually belongs to the given parent under the
|
|
2008
|
-
* declared relationship. Anti-IDOR — without this an attacker can swap
|
|
2009
|
-
* the `:childId` segment to load any related-model row regardless of
|
|
2010
|
-
* whether it's actually owned by the parent.
|
|
2011
|
-
*
|
|
2012
|
-
* Strategy: re-resolve the parent's relation query and check whether
|
|
2013
|
-
* the child's primary key shows up in `where(pk, '=', childId).paginate(1, 1)`.
|
|
2014
|
-
* Yes, it's a second round-trip — but it's the single point of trust
|
|
2015
|
-
* for IDOR safety, and it fits naturally into the same query path
|
|
2016
|
-
* `modelRelationTableRecords` uses.
|
|
2017
|
-
*/
|
|
2018
|
-
async function childBelongsToParent(
|
|
2019
|
-
parentModel: ModelLike,
|
|
2020
|
-
parent: unknown,
|
|
2021
|
-
relationship: string,
|
|
2022
|
-
childPk: string,
|
|
2023
|
-
childId: string,
|
|
2024
|
-
): Promise<boolean> {
|
|
2025
|
-
try {
|
|
2026
|
-
const q: ModelQuery = (parentModel.relatedQuery
|
|
2027
|
-
? parentModel.relatedQuery(parent, relationship)
|
|
2028
|
-
: (parent as { related: (n: string) => ModelQuery }).related(relationship))
|
|
2029
|
-
const result = await q.where(childPk, '=', childId).paginate(1, 1)
|
|
2030
|
-
return result.total > 0
|
|
2031
|
-
} catch {
|
|
2032
|
-
return false
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
|
|
2036
|
-
/**
|
|
2037
|
-
* Auto-wire the manager's table records loader against the parent's
|
|
2038
|
-
* relation query when the user didn't set `Table.records()` themselves.
|
|
2039
|
-
* Mirrors `defaultPages`'s wiring of `Table.records()` from `R.model`
|
|
2040
|
-
* for the resource list page.
|
|
2041
|
-
*/
|
|
2042
|
-
function autoWireManagerTable(
|
|
2043
|
-
table: Table,
|
|
2044
|
-
parentModel: ModelLike,
|
|
2045
|
-
parent: unknown,
|
|
2046
|
-
relationship: string,
|
|
2047
|
-
): void {
|
|
2048
|
-
if (table.getRecords()) return // user wired it explicitly
|
|
2049
|
-
table.records(modelRelationTableRecords(parentModel, parent, relationship, table))
|
|
2050
|
-
}
|
|
2051
|
-
|
|
2052
|
-
/**
|
|
2053
|
-
* Plan #13 polish — auto-inject `TrashedFilter` on a relation manager's
|
|
2054
|
-
* table when the **related** Resource opts into soft deletes. Mirrors the
|
|
2055
|
-
* resource-list pattern in `defaultPages.applyTableDefaults`. The check
|
|
2056
|
-
* is on the related Resource (not the manager), because soft-delete is a
|
|
2057
|
-
* model-level capability — if the child model supports trashing, the
|
|
2058
|
-
* manager's table should expose the toggle.
|
|
2059
|
-
*
|
|
2060
|
-
* No-op when:
|
|
2061
|
-
* - the related Resource hasn't set `softDeletes = true`
|
|
2062
|
-
* - the user already attached a `TrashedFilter` in `M.table()`
|
|
2063
|
-
*/
|
|
2064
|
-
function injectManagerTrashedFilter(
|
|
2065
|
-
table: Table,
|
|
2066
|
-
Related: ResourceClass | undefined,
|
|
2067
|
-
): void {
|
|
2068
|
-
if (!Related?.softDeletes) return
|
|
2069
|
-
const children = table.getChildren() ?? []
|
|
2070
|
-
const hasTrashed = children.some(c => c instanceof TrashedFilter)
|
|
2071
|
-
if (hasTrashed) return
|
|
2072
|
-
const existing = children.filter(c => c instanceof Filter) as Filter[]
|
|
2073
|
-
table.filters([...existing, TrashedFilter.make()])
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
/**
|
|
2077
|
-
* Auto-wire the manager's form save + loadRecord handlers against the
|
|
2078
|
-
* **related** Resource's `model` when the user didn't set them. The
|
|
2079
|
-
* route handler is responsible for stamping the parent context
|
|
2080
|
-
* (parent, parentRecord, parentId, relationship) onto the
|
|
2081
|
-
* `FormContext` so user-supplied `mutateDataBeforeCreate` etc. can
|
|
2082
|
-
* read them.
|
|
2083
|
-
*/
|
|
2084
|
-
function autoWireManagerForm(form: Form, Related: ResourceClass): void {
|
|
2085
|
-
const RelatedModel = Related.model
|
|
2086
|
-
if (!RelatedModel) return
|
|
2087
|
-
if (!form.getSave()) form.save(modelSave(RelatedModel))
|
|
2088
|
-
if (!form.getLoadRecord()) form.loadRecord(modelLoadRecord(Related))
|
|
2089
|
-
}
|
|
2090
|
-
|
|
2091
|
-
async function safePolicy(fn: () => Promise<boolean> | boolean): Promise<boolean> {
|
|
2092
|
-
try { return Boolean(await fn()) } catch { return false }
|
|
2093
|
-
}
|
|
2094
|
-
|
|
2095
|
-
/** Plan #11 — authorization predicate names a `RelationManager` carries.
|
|
2096
|
-
* Re-exported from `RelationManager.ts`. */
|
|
2097
|
-
export type ManagerCanMethod = ManagerCanMethodType
|
|
2098
|
-
|
|
2099
|
-
/** Plan #11 — authorize a relation-manager action with sensible defaults.
|
|
2100
|
-
* Re-exported from `RelationManager.ts` so external callers (route
|
|
2101
|
-
* handlers, third-party plugins) keep their existing import path. */
|
|
2102
|
-
export const safeManagerPolicy = safeManagerPolicyImpl
|
|
2103
|
-
|
|
2104
|
-
/**
|
|
2105
|
-
* Plan #11 — render data for the three relation-manager URL scopes.
|
|
2106
|
-
* Mirrors the resource* builders' shape so routes and Vike +data hooks
|
|
2107
|
-
* consume identical props. Authorization runs inline (parent
|
|
2108
|
-
* `canAccess + canEdit(parent)` then manager-scoped predicate); IDOR
|
|
2109
|
-
* check on `relation-edit` runs against the parent's relation query.
|
|
2110
|
-
*
|
|
2111
|
-
* Returns:
|
|
2112
|
-
* - `null` when panel / parent / manager / child don't exist.
|
|
2113
|
-
* - `{ ok: false, status: 403 }` when authorization denies.
|
|
2114
|
-
* - the props record on success (route picks SSR view / SPA prop
|
|
2115
|
-
* downstream).
|
|
2116
|
-
*/
|
|
2117
|
-
export async function relationManagerData(
|
|
2118
|
-
pilotiq: Pilotiq,
|
|
2119
|
-
scope: RelationManagerScope,
|
|
2120
|
-
req?: unknown,
|
|
2121
|
-
): Promise<RelationManagerResult> {
|
|
2122
|
-
// Phase B nested-relation-* scopes split out into their own pipeline
|
|
2123
|
-
// — the chain walking + per-layer auth differs enough from the
|
|
2124
|
-
// depth-1 path that interleaving them would mostly hurt readability.
|
|
2125
|
-
if (scope.kind === 'nested-relation-list'
|
|
2126
|
-
|| scope.kind === 'nested-relation-create'
|
|
2127
|
-
|| scope.kind === 'nested-relation-view'
|
|
2128
|
-
|| scope.kind === 'nested-relation-edit') {
|
|
2129
|
-
return nestedRelationManagerData(pilotiq, scope, req)
|
|
2130
|
-
}
|
|
2131
|
-
|
|
2132
|
-
const cfg = pilotiq.getConfig()
|
|
2133
|
-
|
|
2134
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
2135
|
-
if (!R) return null
|
|
2136
|
-
|
|
2137
|
-
const M = findManager(R, scope.relationship)
|
|
2138
|
-
if (!M) return null
|
|
2139
|
-
|
|
2140
|
-
const user = await pilotiq.resolveUser(req)
|
|
2141
|
-
|
|
2142
|
-
// Layer 1: parent access. canAccess gates the resource entirely;
|
|
2143
|
-
// canEdit gates managing its relations (managers are read-write
|
|
2144
|
-
// surfaces — read-only inline views opt in by overriding the
|
|
2145
|
-
// manager's can*). Cluster gate composes with R.canAccess — both
|
|
2146
|
-
// must pass when the parent resource is inside a cluster.
|
|
2147
|
-
if (R.cluster && !await safePolicy(() => R.cluster!.canAccess(user))) return { ok: false, status: 403 }
|
|
2148
|
-
if (!await safePolicy(() => R.canAccess(user))) return { ok: false, status: 403 }
|
|
2149
|
-
|
|
2150
|
-
if (!R.model) {
|
|
2151
|
-
// Without a model on the parent we can't load the parent record,
|
|
2152
|
-
// and without that we can't IDOR-check children. Point users at
|
|
2153
|
-
// the missing wiring rather than silent 500s.
|
|
2154
|
-
throw new Error(
|
|
2155
|
-
`[Pilotiq] Resource "${R.name}" has relations(${M.name}) but no static model. ` +
|
|
2156
|
-
`Set Resource.model = … to enable relation managers, or remove the manager.`,
|
|
2157
|
-
)
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
const parentRecord = await findRecord(R, scope.recordId, { user }).catch(() => undefined)
|
|
2161
|
-
if (!parentRecord) return null
|
|
2162
|
-
|
|
2163
|
-
if (!await safePolicy(() => R.canEdit(user, parentRecord))) return { ok: false, status: 403 }
|
|
2164
|
-
|
|
2165
|
-
// Read the relation type off the parent's relations map once,
|
|
2166
|
-
// normalize to the six-way `RelationMode` the manager-side logic
|
|
2167
|
-
// uses. `belongsToMany` / `morphToMany` (owning polymorphic) /
|
|
2168
|
-
// `morphedByMany` (inverse polymorphic) all flip into pivot-mutation
|
|
2169
|
-
// mode (attach / detach / sync — same accessor surface), `morphMany|
|
|
2170
|
-
// morphOne` collapses to `'morphMany'` (parent-side polymorphic —
|
|
2171
|
-
// auto-fills morph columns on create), `morphTo` is the child-side
|
|
2172
|
-
// polymorphic (no auto-actions; requires explicit `M.relatedResource`).
|
|
2173
|
-
// Everything else collapses to `'hasMany'`.
|
|
2174
|
-
const relationType = getRelationType(R.model, scope.relationship)
|
|
2175
|
-
const mode: RelationMode = normalizeRelationMode(relationType)
|
|
2176
|
-
|
|
2177
|
-
const Related = findRelatedResource(M, R, cfg)
|
|
2178
|
-
// Related Resource is required for: edit/create form auto-wire,
|
|
2179
|
-
// child loading on edit, related URL generation. Throw when missing
|
|
2180
|
-
// *only* if we'd otherwise need it — for `relation-list` it's
|
|
2181
|
-
// optional (the table can be hand-wired by the user).
|
|
2182
|
-
const needRelated = scope.kind !== 'relation-list'
|
|
2183
|
-
if (needRelated && !Related) {
|
|
2184
|
-
throw new Error(
|
|
2185
|
-
`[Pilotiq] RelationManager ${M.name} on ${R.name} could not resolve its related Resource. ` +
|
|
2186
|
-
`Set static relatedResource on the manager, or ensure the parent's model declares relations[${JSON.stringify(M.getRelationship())}].`,
|
|
2187
|
-
)
|
|
2188
|
-
}
|
|
2189
|
-
|
|
2190
|
-
switch (scope.kind) {
|
|
2191
|
-
case 'relation-list':
|
|
2192
|
-
return buildRelationListData(pilotiq, R, M, Related, parentRecord, scope, req, user, mode)
|
|
2193
|
-
case 'relation-create':
|
|
2194
|
-
return buildRelationCreateData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
|
|
2195
|
-
case 'relation-view':
|
|
2196
|
-
return buildRelationViewData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
|
|
2197
|
-
case 'relation-edit':
|
|
2198
|
-
return buildRelationEditData(pilotiq, R, M, Related!, parentRecord, scope, req, user, mode)
|
|
2199
|
-
}
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
async function buildRelationListData(
|
|
2203
|
-
pilotiq: Pilotiq,
|
|
2204
|
-
R: ResourceClass,
|
|
2205
|
-
M: typeof RelationManager,
|
|
2206
|
-
Related: ResourceClass | undefined,
|
|
2207
|
-
parentRecord: unknown,
|
|
2208
|
-
scope: Extract<RelationManagerScope, { kind: 'relation-list' }>,
|
|
2209
|
-
req: unknown,
|
|
2210
|
-
user: unknown,
|
|
2211
|
-
mode: RelationMode,
|
|
2212
|
-
): Promise<RelationManagerResult> {
|
|
2213
|
-
if (!await safeManagerPolicy(M, 'canViewAny', Related, user, parentRecord)) return { ok: false, status: 403 }
|
|
2214
|
-
|
|
2215
|
-
const cfg = pilotiq.getConfig()
|
|
2216
|
-
const base = cfg.path
|
|
2217
|
-
const resourceBase = resourceBasePath(base, R)
|
|
2218
|
-
const listUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}`
|
|
2219
|
-
|
|
2220
|
-
// Build a single Table by piping a fresh Table through M.table(table, ctx).
|
|
2221
|
-
// Context lets the user wire `Action.relationCreate / relationEdit /
|
|
2222
|
-
// relationDelete(M, ctx)` factories inside `static table()` to template
|
|
2223
|
-
// URLs without threading basePath / parentId by hand.
|
|
2224
|
-
const managerCtx: RelationManagerContext = {
|
|
2225
|
-
basePath: base,
|
|
2226
|
-
parentSlug: scope.slug,
|
|
2227
|
-
parentId: scope.recordId,
|
|
2228
|
-
relationship: scope.relationship,
|
|
2229
|
-
parentRecord,
|
|
2230
|
-
related: Related,
|
|
2231
|
-
mode,
|
|
2232
|
-
}
|
|
2233
|
-
const table = M.table(Table.make(), managerCtx)
|
|
2234
|
-
autoWireManagerTable(table, R.model as ModelLike, parentRecord, scope.relationship)
|
|
2235
|
-
injectManagerTrashedFilter(table, Related)
|
|
2236
|
-
|
|
2237
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2238
|
-
mode: 'table',
|
|
2239
|
-
basePath: base,
|
|
2240
|
-
record: parentRecord,
|
|
2241
|
-
}, user), cfg)
|
|
2242
|
-
|
|
2243
|
-
const elements: Element[] = [table]
|
|
2244
|
-
tagActionDispatch(elements, listUrl)
|
|
2245
|
-
await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
|
|
2246
|
-
|
|
2247
|
-
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2248
|
-
if (tabs) elements.unshift(tabs)
|
|
2249
|
-
|
|
2250
|
-
const breadcrumbs = relationListBreadcrumbs(
|
|
2251
|
-
cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord),
|
|
2252
|
-
)
|
|
2253
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2254
|
-
|
|
2255
|
-
const relationListRoute: PanelInfoRoute = { resource: R, recordId: scope.recordId }
|
|
2256
|
-
const schemaData = await applyRoleHooks(
|
|
2257
|
-
pilotiq, user, 'relation-list',
|
|
2258
|
-
await resolveSchema(elements, ctx),
|
|
2259
|
-
relationListRoute,
|
|
2260
|
-
)
|
|
2261
|
-
|
|
2262
|
-
return {
|
|
2263
|
-
pageType: 'relation-list',
|
|
2264
|
-
panel: await panelInfo(pilotiq, req, relationListRoute),
|
|
2265
|
-
resource: { name: R.name, label: R.label, labelSingular: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
|
|
2266
|
-
relation: {
|
|
2267
|
-
name: M.name,
|
|
2268
|
-
label: M.getLabel(),
|
|
2269
|
-
labelSingular: M.getLabelSingular(),
|
|
2270
|
-
relationship: scope.relationship,
|
|
2271
|
-
icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
|
|
2272
|
-
relatedSlug: Related?.getSlug(),
|
|
2273
|
-
},
|
|
2274
|
-
parent: {
|
|
2275
|
-
id: scope.recordId,
|
|
2276
|
-
title: deriveParentTitle(R, parentRecord),
|
|
2277
|
-
},
|
|
2278
|
-
basePath: base,
|
|
2279
|
-
layout: cfg.layout,
|
|
2280
|
-
schemaData,
|
|
2281
|
-
notifications: consumeFlashedNotifications(req),
|
|
2282
|
-
}
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
async function buildRelationCreateData(
|
|
2286
|
-
pilotiq: Pilotiq,
|
|
2287
|
-
R: ResourceClass,
|
|
2288
|
-
M: typeof RelationManager,
|
|
2289
|
-
Related: ResourceClass,
|
|
2290
|
-
parentRecord: unknown,
|
|
2291
|
-
scope: Extract<RelationManagerScope, { kind: 'relation-create' }>,
|
|
2292
|
-
req: unknown,
|
|
2293
|
-
user: unknown,
|
|
2294
|
-
mode: RelationMode,
|
|
2295
|
-
): Promise<RelationManagerResult> {
|
|
2296
|
-
if (!await safeManagerPolicy(M, 'canCreate', Related, user, parentRecord)) return { ok: false, status: 403 }
|
|
2297
|
-
|
|
2298
|
-
const cfg = pilotiq.getConfig()
|
|
2299
|
-
const base = cfg.path
|
|
2300
|
-
const resourceBase = resourceBasePath(base, R)
|
|
2301
|
-
const createUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}/create`
|
|
2302
|
-
|
|
2303
|
-
const managerCtx: RelationManagerContext = {
|
|
2304
|
-
basePath: base,
|
|
2305
|
-
parentSlug: scope.slug,
|
|
2306
|
-
parentId: scope.recordId,
|
|
2307
|
-
relationship: scope.relationship,
|
|
2308
|
-
parentRecord,
|
|
2309
|
-
related: Related,
|
|
2310
|
-
mode,
|
|
2311
|
-
}
|
|
2312
|
-
const form = M.form(Form.make(), managerCtx)
|
|
2313
|
-
if (Related.model) autoWireManagerForm(form, Related)
|
|
2314
|
-
|
|
2315
|
-
const elements: Element[] = [form]
|
|
2316
|
-
tagFormActions(elements, createUrl)
|
|
2317
|
-
|
|
2318
|
-
if (scope.prefill) {
|
|
2319
|
-
if (scope.prefill.values) form.withValues(scope.prefill.values)
|
|
2320
|
-
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2324
|
-
if (tabs) elements.unshift(tabs)
|
|
2325
|
-
|
|
2326
|
-
const breadcrumbs = relationCreateBreadcrumbs(
|
|
2327
|
-
cfg, R, M, scope.recordId, deriveParentTitle(R, parentRecord),
|
|
2328
|
-
)
|
|
2329
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2330
|
-
|
|
2331
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2332
|
-
mode: 'create',
|
|
2333
|
-
basePath: base,
|
|
2334
|
-
record: parentRecord,
|
|
2335
|
-
}, user), cfg)
|
|
2336
|
-
|
|
2337
|
-
const relationCreateRoute: PanelInfoRoute = { resource: R, recordId: scope.recordId }
|
|
2338
|
-
const schemaData = await applyRoleHooks(
|
|
2339
|
-
pilotiq, user, 'relation-create',
|
|
2340
|
-
await resolveSchema(elements, ctx),
|
|
2341
|
-
relationCreateRoute,
|
|
2342
|
-
)
|
|
2343
|
-
|
|
2344
|
-
return {
|
|
2345
|
-
pageType: 'relation-create',
|
|
2346
|
-
panel: await panelInfo(pilotiq, req, relationCreateRoute),
|
|
2347
|
-
resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
|
|
2348
|
-
relation: {
|
|
2349
|
-
name: M.name,
|
|
2350
|
-
label: M.getLabel(),
|
|
2351
|
-
labelSingular: M.getLabelSingular(),
|
|
2352
|
-
relationship: scope.relationship,
|
|
2353
|
-
icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
|
|
2354
|
-
relatedSlug: Related.getSlug(),
|
|
2355
|
-
},
|
|
2356
|
-
parent: {
|
|
2357
|
-
id: scope.recordId,
|
|
2358
|
-
title: deriveParentTitle(R, parentRecord),
|
|
2359
|
-
},
|
|
2360
|
-
mode: 'create' as const,
|
|
2361
|
-
basePath: base,
|
|
2362
|
-
layout: cfg.layout,
|
|
2363
|
-
schemaData,
|
|
2364
|
-
notifications: consumeFlashedNotifications(req),
|
|
2365
|
-
...(scope.prefill?.errors ? { hasErrors: true } : {}),
|
|
2366
|
-
}
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
/**
|
|
2370
|
-
* Phase A — read-only view page for a related record at depth-2:
|
|
2371
|
-
* `${base}/${slug}/:id/${rel}/:childId`. Mirrors `buildRelationEditData`'s
|
|
2372
|
-
* IDOR + auth posture but resolves the manager's `static detail(child,
|
|
2373
|
-
* parent)` instead of its form. The default `detail()` returns `[]` —
|
|
2374
|
-
* managers opt in by overriding it; the chrome (RelationTabs strip)
|
|
2375
|
-
* still renders so users can sideways-nav between sibling managers.
|
|
2376
|
-
*/
|
|
2377
|
-
async function buildRelationViewData(
|
|
2378
|
-
pilotiq: Pilotiq,
|
|
2379
|
-
R: ResourceClass,
|
|
2380
|
-
M: typeof RelationManager,
|
|
2381
|
-
Related: ResourceClass,
|
|
2382
|
-
parentRecord: unknown,
|
|
2383
|
-
scope: Extract<RelationManagerScope, { kind: 'relation-view' }>,
|
|
2384
|
-
req: unknown,
|
|
2385
|
-
user: unknown,
|
|
2386
|
-
_mode: RelationMode,
|
|
2387
|
-
): Promise<RelationManagerResult> {
|
|
2388
|
-
if (!Related.model) {
|
|
2389
|
-
throw new Error(
|
|
2390
|
-
`[Pilotiq] Cannot load child record for ${M.name}: Related Resource ${Related.name} has no static model.`,
|
|
2391
|
-
)
|
|
2392
|
-
}
|
|
2393
|
-
const childPk = getPrimaryKey(Related.model)
|
|
2394
|
-
|
|
2395
|
-
const belongs = await childBelongsToParent(
|
|
2396
|
-
R.model as ModelLike, parentRecord, scope.relationship, childPk, scope.childId,
|
|
2397
|
-
)
|
|
2398
|
-
if (!belongs) return null
|
|
2399
|
-
|
|
2400
|
-
const child = await findRecord(Related, scope.childId, { user }).catch(() => undefined)
|
|
2401
|
-
if (!child) return null
|
|
2402
|
-
|
|
2403
|
-
if (!await safeManagerPolicy(M, 'canView', Related, user, parentRecord, child)) return { ok: false, status: 403 }
|
|
2404
|
-
|
|
2405
|
-
const cfg = pilotiq.getConfig()
|
|
2406
|
-
const base = cfg.path
|
|
2407
|
-
|
|
2408
|
-
const elements: Element[] = M.detail(child, parentRecord)
|
|
2409
|
-
|
|
2410
|
-
// Phase B polish — when M declares nested managers, surface them on
|
|
2411
|
-
// this page too. The strip lists the leaf parent's view tab plus one
|
|
2412
|
-
// tab per sibling nested manager so users can jump from the Phase A
|
|
2413
|
-
// view straight into a grandchild list / create / view / edit page.
|
|
2414
|
-
// Active key `'__view'` because the user is currently viewing the
|
|
2415
|
-
// leaf parent record itself, not any nested manager.
|
|
2416
|
-
const nestedTabs = await buildNestedRelationTabs(
|
|
2417
|
-
R, M, base,
|
|
2418
|
-
{ recordId: scope.recordId, relationship: scope.relationship },
|
|
2419
|
-
scope.childId,
|
|
2420
|
-
'__view',
|
|
2421
|
-
user, child,
|
|
2422
|
-
)
|
|
2423
|
-
if (nestedTabs) elements.unshift(nestedTabs)
|
|
2424
|
-
|
|
2425
|
-
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2426
|
-
if (tabs) elements.unshift(tabs)
|
|
2427
|
-
|
|
2428
|
-
const breadcrumbs = relationViewBreadcrumbs(
|
|
2429
|
-
cfg, R, M, scope.recordId,
|
|
2430
|
-
deriveParentTitle(R, parentRecord),
|
|
2431
|
-
deriveParentTitle(Related, child, M),
|
|
2432
|
-
)
|
|
2433
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2434
|
-
|
|
2435
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2436
|
-
mode: 'view',
|
|
2437
|
-
basePath: base,
|
|
2438
|
-
record: child,
|
|
2439
|
-
recordId: scope.childId,
|
|
2440
|
-
}, user), cfg)
|
|
2441
|
-
|
|
2442
|
-
const relationViewRoute: PanelInfoRoute = { resource: R, recordId: scope.childId }
|
|
2443
|
-
const schemaData = await applyRoleHooks(
|
|
2444
|
-
pilotiq, user, 'relation-view',
|
|
2445
|
-
await resolveSchema(elements, ctx),
|
|
2446
|
-
relationViewRoute,
|
|
2447
|
-
)
|
|
2448
|
-
|
|
2449
|
-
return {
|
|
2450
|
-
pageType: 'relation-view',
|
|
2451
|
-
panel: await panelInfo(pilotiq, req, relationViewRoute),
|
|
2452
|
-
resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
|
|
2453
|
-
relation: {
|
|
2454
|
-
name: M.name,
|
|
2455
|
-
label: M.getLabel(),
|
|
2456
|
-
labelSingular: M.getLabelSingular(),
|
|
2457
|
-
relationship: scope.relationship,
|
|
2458
|
-
icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
|
|
2459
|
-
relatedSlug: Related.getSlug(),
|
|
2460
|
-
},
|
|
2461
|
-
parent: {
|
|
2462
|
-
id: scope.recordId,
|
|
2463
|
-
title: deriveParentTitle(R, parentRecord),
|
|
2464
|
-
},
|
|
2465
|
-
mode: 'view' as const,
|
|
2466
|
-
childId: scope.childId,
|
|
2467
|
-
basePath: base,
|
|
2468
|
-
layout: cfg.layout,
|
|
2469
|
-
schemaData,
|
|
2470
|
-
notifications: consumeFlashedNotifications(req),
|
|
2471
|
-
}
|
|
2472
|
-
}
|
|
2473
|
-
|
|
2474
|
-
async function buildRelationEditData(
|
|
2475
|
-
pilotiq: Pilotiq,
|
|
2476
|
-
R: ResourceClass,
|
|
2477
|
-
M: typeof RelationManager,
|
|
2478
|
-
Related: ResourceClass,
|
|
2479
|
-
parentRecord: unknown,
|
|
2480
|
-
scope: Extract<RelationManagerScope, { kind: 'relation-edit' }>,
|
|
2481
|
-
req: unknown,
|
|
2482
|
-
user: unknown,
|
|
2483
|
-
mode: RelationMode,
|
|
2484
|
-
): Promise<RelationManagerResult> {
|
|
2485
|
-
if (!Related.model) {
|
|
2486
|
-
throw new Error(
|
|
2487
|
-
`[Pilotiq] Cannot load child record for ${M.name}: Related Resource ${Related.name} has no static model.`,
|
|
2488
|
-
)
|
|
2489
|
-
}
|
|
2490
|
-
const childPk = getPrimaryKey(Related.model)
|
|
2491
|
-
|
|
2492
|
-
// IDOR check first — confirm the child actually belongs to the
|
|
2493
|
-
// parent under this relationship before doing anything else. Guards
|
|
2494
|
-
// against URL tampering swapping `:childId`.
|
|
2495
|
-
const belongs = await childBelongsToParent(
|
|
2496
|
-
R.model as ModelLike, parentRecord, scope.relationship, childPk, scope.childId,
|
|
2497
|
-
)
|
|
2498
|
-
if (!belongs) return null
|
|
2499
|
-
|
|
2500
|
-
const child = await findRecord(Related, scope.childId, { user }).catch(() => undefined)
|
|
2501
|
-
if (!child) return null
|
|
2502
|
-
|
|
2503
|
-
if (!await safeManagerPolicy(M, 'canEdit', Related, user, parentRecord, child)) return { ok: false, status: 403 }
|
|
2504
|
-
|
|
2505
|
-
const cfg = pilotiq.getConfig()
|
|
2506
|
-
const base = cfg.path
|
|
2507
|
-
const resourceBase = resourceBasePath(base, R)
|
|
2508
|
-
const editUrl = `${resourceBase}/${scope.recordId}/${scope.relationship}/${scope.childId}/edit`
|
|
2509
|
-
|
|
2510
|
-
const managerCtx: RelationManagerContext = {
|
|
2511
|
-
basePath: base,
|
|
2512
|
-
parentSlug: scope.slug,
|
|
2513
|
-
parentId: scope.recordId,
|
|
2514
|
-
relationship: scope.relationship,
|
|
2515
|
-
parentRecord,
|
|
2516
|
-
related: Related,
|
|
2517
|
-
mode,
|
|
2518
|
-
}
|
|
2519
|
-
const form = M.form(Form.make(), managerCtx)
|
|
2520
|
-
autoWireManagerForm(form, Related)
|
|
2521
|
-
|
|
2522
|
-
const elements: Element[] = [form]
|
|
2523
|
-
tagFormActions(elements, editUrl)
|
|
2524
|
-
|
|
2525
|
-
// Prefill values: explicit prefill (re-render after 422) wins,
|
|
2526
|
-
// otherwise pipe the loaded child through Form's fill pipeline.
|
|
2527
|
-
if (scope.prefill?.values) {
|
|
2528
|
-
form.withValues(scope.prefill.values)
|
|
2529
|
-
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
2530
|
-
} else if (child != null) {
|
|
2531
|
-
const values = await applyFillPipeline(form, child)
|
|
2532
|
-
form.withValues(values)
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
const tabs = await buildRelationTabs(R, scope.recordId, base, scope.relationship, user, parentRecord)
|
|
2536
|
-
if (tabs) elements.unshift(tabs)
|
|
2537
|
-
|
|
2538
|
-
const breadcrumbs = relationEditBreadcrumbs(
|
|
2539
|
-
cfg, R, M, scope.recordId,
|
|
2540
|
-
deriveParentTitle(R, parentRecord),
|
|
2541
|
-
scope.childId,
|
|
2542
|
-
deriveParentTitle(Related, child, M),
|
|
2543
|
-
)
|
|
2544
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2545
|
-
|
|
2546
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2547
|
-
mode: 'edit',
|
|
2548
|
-
basePath: base,
|
|
2549
|
-
record: child,
|
|
2550
|
-
recordId: scope.childId,
|
|
2551
|
-
}, user), cfg)
|
|
2552
|
-
|
|
2553
|
-
const relationEditRoute: PanelInfoRoute = { resource: R, recordId: scope.childId }
|
|
2554
|
-
const schemaData = await applyRoleHooks(
|
|
2555
|
-
pilotiq, user, 'relation-edit',
|
|
2556
|
-
await resolveSchema(elements, ctx),
|
|
2557
|
-
relationEditRoute,
|
|
2558
|
-
)
|
|
2559
|
-
|
|
2560
|
-
return {
|
|
2561
|
-
pageType: 'relation-edit',
|
|
2562
|
-
panel: await panelInfo(pilotiq, req, relationEditRoute),
|
|
2563
|
-
resource: { name: R.name, label: R.labelSingular, slug: scope.slug, icon: serializeIcon(R.icon, R.name) },
|
|
2564
|
-
relation: {
|
|
2565
|
-
name: M.name,
|
|
2566
|
-
label: M.getLabel(),
|
|
2567
|
-
labelSingular: M.getLabelSingular(),
|
|
2568
|
-
relationship: scope.relationship,
|
|
2569
|
-
icon: M.getIcon() ? serializeIcon(M.getIcon()!, M.name) : undefined,
|
|
2570
|
-
relatedSlug: Related.getSlug(),
|
|
2571
|
-
},
|
|
2572
|
-
parent: {
|
|
2573
|
-
id: scope.recordId,
|
|
2574
|
-
title: deriveParentTitle(R, parentRecord),
|
|
2575
|
-
},
|
|
2576
|
-
mode: 'edit' as const,
|
|
2577
|
-
childId: scope.childId,
|
|
2578
|
-
basePath: base,
|
|
2579
|
-
layout: cfg.layout,
|
|
2580
|
-
schemaData,
|
|
2581
|
-
notifications: consumeFlashedNotifications(req),
|
|
2582
|
-
...(scope.prefill?.errors ? { hasErrors: true } : {}),
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
|
|
2586
|
-
// ─── Phase B nested-relation pipeline ────────────────────────
|
|
2587
|
-
|
|
2588
|
-
/**
|
|
2589
|
-
* Phase B — narrow `scope` discriminator for nested-relation-*. Lets
|
|
2590
|
-
* the helpers below avoid restating the union for every parameter.
|
|
2591
|
-
*/
|
|
2592
|
-
type NestedRelationScope = Extract<RelationManagerScope, { kind: `nested-relation-${string}` }>
|
|
2593
|
-
|
|
2594
|
-
/**
|
|
2595
|
-
* Phase B — chain walk result. Resolved layer-by-layer in
|
|
2596
|
-
* `resolveRelationChain`; nested builders consume it. Failures bubble
|
|
2597
|
-
* up as the same `{ ok: false, status: 403 }` / `null` shape the
|
|
2598
|
-
* depth-1 path uses.
|
|
2599
|
-
*/
|
|
2600
|
-
export interface ResolvedChain {
|
|
2601
|
-
R: ResourceClass
|
|
2602
|
-
parentRecord: unknown
|
|
2603
|
-
M1: typeof RelationManager
|
|
2604
|
-
Related1: ResourceClass
|
|
2605
|
-
child1: unknown
|
|
2606
|
-
child1Mode: RelationMode
|
|
2607
|
-
M2: typeof RelationManager
|
|
2608
|
-
Related2: ResourceClass | undefined
|
|
2609
|
-
child2Mode: RelationMode
|
|
2610
|
-
}
|
|
2611
|
-
|
|
2612
|
-
/**
|
|
2613
|
-
* Phase B — resolve a depth-2 chain, running every auth + IDOR layer:
|
|
2614
|
-
* Layer 0 — top-level Resource: cluster gate, R.canAccess.
|
|
2615
|
-
* Layer 1 — parent record: R.canEdit(parent) (Phase A gate to manage relations).
|
|
2616
|
-
* Layer 2 — first manager M1: relationship discovered, related resource discovered.
|
|
2617
|
-
* IDOR #1 — child1 (the leaf parent) must belong to parentRecord under chain[0].relationship.
|
|
2618
|
-
* Layer 3 — M1.canView(child1, parent) (Filament-style: must be allowed
|
|
2619
|
-
* to view the child to drill into its sub-relations).
|
|
2620
|
-
* Layer 4 — second manager M2 lookup; relation type read off Related1.model.
|
|
2621
|
-
*
|
|
2622
|
-
* The leaf manager's per-scope predicate (canViewAny / canCreate /
|
|
2623
|
-
* canView / canEdit) runs inside the per-scope builders below, since
|
|
2624
|
-
* each predicate has different arguments.
|
|
2625
|
-
*/
|
|
2626
|
-
export async function resolveRelationChain(
|
|
2627
|
-
pilotiq: Pilotiq,
|
|
2628
|
-
scope: NestedRelationScope,
|
|
2629
|
-
user: unknown,
|
|
2630
|
-
): Promise<ResolvedChain | { ok: false; status: 403 } | null> {
|
|
2631
|
-
const cfg = pilotiq.getConfig()
|
|
2632
|
-
|
|
2633
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
2634
|
-
if (!R) return null
|
|
2635
|
-
|
|
2636
|
-
// Layer 0 — same gates as the depth-1 pipeline.
|
|
2637
|
-
if (R.cluster && !await safePolicy(() => R.cluster!.canAccess(user))) return { ok: false, status: 403 }
|
|
2638
|
-
if (!await safePolicy(() => R.canAccess(user))) return { ok: false, status: 403 }
|
|
2639
|
-
|
|
2640
|
-
if (!R.model) {
|
|
2641
|
-
throw new Error(
|
|
2642
|
-
`[Pilotiq] Resource "${R.name}" has nested relations but no static model. ` +
|
|
2643
|
-
`Set Resource.model = … or remove the manager.`,
|
|
2644
|
-
)
|
|
2645
|
-
}
|
|
2646
|
-
|
|
2647
|
-
const [step0, step1] = scope.chain
|
|
2648
|
-
const parentRecord = await findRecord(R, step0.recordId, { user }).catch(() => undefined)
|
|
2649
|
-
if (!parentRecord) return null
|
|
2650
|
-
|
|
2651
|
-
// Layer 1 — parent record gate.
|
|
2652
|
-
if (!await safePolicy(() => R.canEdit(user, parentRecord))) return { ok: false, status: 403 }
|
|
2653
|
-
|
|
2654
|
-
// Layer 2 — first manager M1.
|
|
2655
|
-
const M1 = findManager(R, step0.relationship)
|
|
2656
|
-
if (!M1) return null
|
|
2657
|
-
const Related1 = findRelatedResource(M1, R, cfg)
|
|
2658
|
-
if (!Related1) {
|
|
2659
|
-
throw new Error(
|
|
2660
|
-
`[Pilotiq] RelationManager ${M1.name} on ${R.name} could not resolve its related Resource. ` +
|
|
2661
|
-
`Set static relatedResource on the manager, or ensure the parent's model declares relations[${JSON.stringify(M1.getRelationship())}].`,
|
|
2662
|
-
)
|
|
2663
|
-
}
|
|
2664
|
-
if (!Related1.model) {
|
|
2665
|
-
throw new Error(
|
|
2666
|
-
`[Pilotiq] Related Resource ${Related1.name} has no static model — ` +
|
|
2667
|
-
`cannot resolve nested manager chain through it.`,
|
|
2668
|
-
)
|
|
2669
|
-
}
|
|
2670
|
-
const child1Mode: RelationMode = normalizeRelationMode(getRelationType(R.model, step0.relationship))
|
|
2671
|
-
|
|
2672
|
-
// IDOR #1 — confirm the leaf parent (`step1.recordId`) actually
|
|
2673
|
-
// belongs to the top parent under the first relationship key.
|
|
2674
|
-
const child1Pk = getPrimaryKey(Related1.model)
|
|
2675
|
-
const belongs1 = await childBelongsToParent(
|
|
2676
|
-
R.model as ModelLike, parentRecord, step0.relationship, child1Pk, step1.recordId,
|
|
2677
|
-
)
|
|
2678
|
-
if (!belongs1) return null
|
|
2679
|
-
|
|
2680
|
-
const child1 = await findRecord(Related1, step1.recordId, { user }).catch(() => undefined)
|
|
2681
|
-
if (!child1) return null
|
|
2682
|
-
|
|
2683
|
-
// Layer 3 — M1.canView(child1, parent) gate. Filament-style: viewing
|
|
2684
|
-
// the child is the prerequisite for entering its nested manager strip.
|
|
2685
|
-
if (!await safeManagerPolicy(M1, 'canView', Related1, user, parentRecord, child1)) return { ok: false, status: 403 }
|
|
2686
|
-
|
|
2687
|
-
// Layer 4 — second manager M2 declared under M1.relations().
|
|
2688
|
-
const M2 = M1.relations().find(N => {
|
|
2689
|
-
try { return N.getRelationship() === step1.relationship } catch { return false }
|
|
2690
|
-
})
|
|
2691
|
-
if (!M2) return null
|
|
2692
|
-
const Related2 = findRelatedResource(M2, Related1, cfg)
|
|
2693
|
-
const child2Mode: RelationMode = normalizeRelationMode(getRelationType(Related1.model, step1.relationship))
|
|
2694
|
-
|
|
2695
|
-
return { R, parentRecord, M1, Related1, child1, child1Mode, M2, Related2, child2Mode }
|
|
2696
|
-
}
|
|
2697
|
-
|
|
2698
|
-
/**
|
|
2699
|
-
* Phase B dispatcher — splits the four nested scopes onto their builders
|
|
2700
|
-
* after the shared chain walk. Mirrors the depth-1 `relationManagerData`
|
|
2701
|
-
* function shape.
|
|
2702
|
-
*/
|
|
2703
|
-
async function nestedRelationManagerData(
|
|
2704
|
-
pilotiq: Pilotiq,
|
|
2705
|
-
scope: NestedRelationScope,
|
|
2706
|
-
req?: unknown,
|
|
2707
|
-
): Promise<RelationManagerResult> {
|
|
2708
|
-
const user = await pilotiq.resolveUser(req)
|
|
2709
|
-
const resolved = await resolveRelationChain(pilotiq, scope, user)
|
|
2710
|
-
if (resolved === null) return null
|
|
2711
|
-
if ('ok' in resolved) return resolved
|
|
2712
|
-
|
|
2713
|
-
// For create / view / edit we strictly need a registered Related2 so
|
|
2714
|
-
// we can load the leaf record + auto-wire the form save.
|
|
2715
|
-
const needRelated2 = scope.kind !== 'nested-relation-list'
|
|
2716
|
-
if (needRelated2 && !resolved.Related2) {
|
|
2717
|
-
throw new Error(
|
|
2718
|
-
`[Pilotiq] Nested RelationManager ${resolved.M2.name} under ${resolved.M1.name} ` +
|
|
2719
|
-
`on ${resolved.R.name} could not resolve its related Resource. ` +
|
|
2720
|
-
`Set static relatedResource on the manager, or ensure the parent's model declares ` +
|
|
2721
|
-
`relations[${JSON.stringify(resolved.M2.getRelationship())}].`,
|
|
2722
|
-
)
|
|
2723
|
-
}
|
|
2724
|
-
|
|
2725
|
-
switch (scope.kind) {
|
|
2726
|
-
case 'nested-relation-list':
|
|
2727
|
-
return buildNestedRelationListData(pilotiq, scope, resolved, req, user)
|
|
2728
|
-
case 'nested-relation-create':
|
|
2729
|
-
return buildNestedRelationCreateData(pilotiq, scope, resolved, req, user)
|
|
2730
|
-
case 'nested-relation-view':
|
|
2731
|
-
return buildNestedRelationViewData(pilotiq, scope, resolved, req, user)
|
|
2732
|
-
case 'nested-relation-edit':
|
|
2733
|
-
return buildNestedRelationEditData(pilotiq, scope, resolved, req, user)
|
|
2734
|
-
}
|
|
2735
|
-
}
|
|
2736
|
-
|
|
2737
|
-
/** Phase B — build the manager context for a nested leaf manager. The
|
|
2738
|
-
* parent here is `child1` (the chain's leaf parent record); the URL
|
|
2739
|
-
* prefix comes from `scope.chain[0]` via `Action.relation*` factories
|
|
2740
|
-
* reading `ctx.chain`. */
|
|
2741
|
-
function nestedManagerCtx(
|
|
2742
|
-
base: string,
|
|
2743
|
-
scope: NestedRelationScope,
|
|
2744
|
-
resolved: ResolvedChain,
|
|
2745
|
-
): RelationManagerContext {
|
|
2746
|
-
const [step0, step1] = scope.chain
|
|
2747
|
-
return {
|
|
2748
|
-
basePath: base,
|
|
2749
|
-
parentSlug: resolved.R.getSlug(),
|
|
2750
|
-
parentId: step1.recordId, // immediate parent = child1's id
|
|
2751
|
-
relationship: step1.relationship, // leaf manager's relationship
|
|
2752
|
-
parentRecord: resolved.child1, // immediate parent record = child1
|
|
2753
|
-
related: resolved.Related2,
|
|
2754
|
-
mode: resolved.child2Mode,
|
|
2755
|
-
chain: [{
|
|
2756
|
-
slug: resolved.R.getSlug(),
|
|
2757
|
-
recordId: step0.recordId,
|
|
2758
|
-
relationship: step0.relationship,
|
|
2759
|
-
}],
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
|
|
2763
|
-
/** Phase B — assemble the response shape that mirrors the depth-1
|
|
2764
|
-
* builders but adds a `chain` array so renderers can build breadcrumbs
|
|
2765
|
-
* and back-links without re-deriving them. */
|
|
2766
|
-
function nestedResponseEnvelope(
|
|
2767
|
-
pageType: 'nested-relation-list' | 'nested-relation-create' | 'nested-relation-view' | 'nested-relation-edit',
|
|
2768
|
-
pilotiq: Pilotiq,
|
|
2769
|
-
base: string,
|
|
2770
|
-
scope: NestedRelationScope,
|
|
2771
|
-
resolved: ResolvedChain,
|
|
2772
|
-
req: unknown,
|
|
2773
|
-
): {
|
|
2774
|
-
pageType: typeof pageType
|
|
2775
|
-
resource: { name: string; label?: string | undefined; slug: string; icon?: SerializedIcon | undefined }
|
|
2776
|
-
parentRelation: { name: string; relationship: string; label: string; relatedSlug?: string | undefined }
|
|
2777
|
-
parentChild: { id: string; title: string }
|
|
2778
|
-
relation: { name: string; relationship: string; label: string; labelSingular: string; icon?: SerializedIcon | undefined; relatedSlug?: string | undefined }
|
|
2779
|
-
parent: { id: string; title: string }
|
|
2780
|
-
basePath: string
|
|
2781
|
-
layout: PilotiqConfig['layout']
|
|
2782
|
-
notifications: ReturnType<typeof consumeFlashedNotifications>
|
|
2783
|
-
} {
|
|
2784
|
-
const { R, M1, Related1, child1, M2, Related2 } = resolved
|
|
2785
|
-
const [step0, step1] = scope.chain
|
|
2786
|
-
const parentChildTitle = deriveParentTitle(Related1, child1, M1)
|
|
2787
|
-
|
|
2788
|
-
return {
|
|
2789
|
-
pageType,
|
|
2790
|
-
resource: { name: R.name, label: R.labelSingular, slug: R.getSlug(), icon: serializeIcon(R.icon, R.name) },
|
|
2791
|
-
parentRelation: {
|
|
2792
|
-
name: M1.name,
|
|
2793
|
-
relationship: step0.relationship,
|
|
2794
|
-
label: M1.getLabel(),
|
|
2795
|
-
relatedSlug: Related1.getSlug(),
|
|
2796
|
-
},
|
|
2797
|
-
parentChild: {
|
|
2798
|
-
id: step1.recordId,
|
|
2799
|
-
title: parentChildTitle,
|
|
2800
|
-
},
|
|
2801
|
-
relation: {
|
|
2802
|
-
name: M2.name,
|
|
2803
|
-
relationship: step1.relationship,
|
|
2804
|
-
label: M2.getLabel(),
|
|
2805
|
-
labelSingular: M2.getLabelSingular(),
|
|
2806
|
-
icon: M2.getIcon() ? serializeIcon(M2.getIcon()!, M2.name) : undefined,
|
|
2807
|
-
relatedSlug: Related2?.getSlug(),
|
|
2808
|
-
},
|
|
2809
|
-
parent: {
|
|
2810
|
-
// Top-of-chain record — same shape the depth-1 builders ship as
|
|
2811
|
-
// `parent` so renderers can reuse the back-to-resource link.
|
|
2812
|
-
id: step0.recordId,
|
|
2813
|
-
title: deriveParentTitle(R, resolved.parentRecord),
|
|
2814
|
-
},
|
|
2815
|
-
basePath: base,
|
|
2816
|
-
layout: pilotiq.getConfig().layout,
|
|
2817
|
-
notifications: consumeFlashedNotifications(req),
|
|
2818
|
-
}
|
|
2819
|
-
}
|
|
2820
|
-
|
|
2821
|
-
async function buildNestedRelationListData(
|
|
2822
|
-
pilotiq: Pilotiq,
|
|
2823
|
-
scope: Extract<NestedRelationScope, { kind: 'nested-relation-list' }>,
|
|
2824
|
-
resolved: ResolvedChain,
|
|
2825
|
-
req: unknown,
|
|
2826
|
-
user: unknown,
|
|
2827
|
-
): Promise<RelationManagerResult> {
|
|
2828
|
-
const { Related1, child1, M2, Related2 } = resolved
|
|
2829
|
-
|
|
2830
|
-
if (!await safeManagerPolicy(M2, 'canViewAny', Related2, user, child1)) return { ok: false, status: 403 }
|
|
2831
|
-
|
|
2832
|
-
const cfg = pilotiq.getConfig()
|
|
2833
|
-
const base = cfg.path
|
|
2834
|
-
const [step0, step1] = scope.chain
|
|
2835
|
-
const resourceBase = resourceBasePath(base, resolved.R)
|
|
2836
|
-
const listUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}`
|
|
2837
|
-
|
|
2838
|
-
const managerCtx = nestedManagerCtx(base, scope, resolved)
|
|
2839
|
-
const table = M2.table(Table.make(), managerCtx)
|
|
2840
|
-
if (Related1.model) {
|
|
2841
|
-
autoWireManagerTable(table, Related1.model as ModelLike, child1, step1.relationship)
|
|
2842
|
-
}
|
|
2843
|
-
injectManagerTrashedFilter(table, Related2)
|
|
2844
|
-
|
|
2845
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2846
|
-
mode: 'table',
|
|
2847
|
-
basePath: base,
|
|
2848
|
-
record: child1,
|
|
2849
|
-
}, user), cfg)
|
|
2850
|
-
|
|
2851
|
-
const elements: Element[] = [table]
|
|
2852
|
-
tagActionDispatch(elements, listUrl)
|
|
2853
|
-
await loadTableRecords(elements, scope.query ?? {}, listUrl, user)
|
|
2854
|
-
|
|
2855
|
-
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2856
|
-
if (tabs) elements.unshift(tabs)
|
|
2857
|
-
|
|
2858
|
-
const breadcrumbs = nestedRelationListBreadcrumbs(
|
|
2859
|
-
cfg, resolved.R, resolved.M1, M2, scope.chain[0],
|
|
2860
|
-
deriveParentTitle(resolved.R, resolved.parentRecord),
|
|
2861
|
-
scope.chain[1].recordId,
|
|
2862
|
-
deriveParentTitle(Related1, child1, resolved.M1),
|
|
2863
|
-
)
|
|
2864
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2865
|
-
|
|
2866
|
-
const nestedListRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.chain[1].recordId }
|
|
2867
|
-
const schemaData = await applyRoleHooks(
|
|
2868
|
-
pilotiq, user, 'relation-list',
|
|
2869
|
-
await resolveSchema(elements, ctx),
|
|
2870
|
-
nestedListRoute,
|
|
2871
|
-
)
|
|
2872
|
-
|
|
2873
|
-
return {
|
|
2874
|
-
...nestedResponseEnvelope('nested-relation-list', pilotiq, base, scope, resolved, req),
|
|
2875
|
-
panel: await panelInfo(pilotiq, req, nestedListRoute),
|
|
2876
|
-
schemaData,
|
|
2877
|
-
}
|
|
2878
|
-
}
|
|
2879
|
-
|
|
2880
|
-
async function buildNestedRelationCreateData(
|
|
2881
|
-
pilotiq: Pilotiq,
|
|
2882
|
-
scope: Extract<NestedRelationScope, { kind: 'nested-relation-create' }>,
|
|
2883
|
-
resolved: ResolvedChain,
|
|
2884
|
-
req: unknown,
|
|
2885
|
-
user: unknown,
|
|
2886
|
-
): Promise<RelationManagerResult> {
|
|
2887
|
-
const { child1, M2, Related2 } = resolved
|
|
2888
|
-
|
|
2889
|
-
if (!await safeManagerPolicy(M2, 'canCreate', Related2, user, child1)) return { ok: false, status: 403 }
|
|
2890
|
-
|
|
2891
|
-
const cfg = pilotiq.getConfig()
|
|
2892
|
-
const base = cfg.path
|
|
2893
|
-
const [step0, step1] = scope.chain
|
|
2894
|
-
const resourceBase = resourceBasePath(base, resolved.R)
|
|
2895
|
-
const createUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}/create`
|
|
2896
|
-
|
|
2897
|
-
const managerCtx = nestedManagerCtx(base, scope, resolved)
|
|
2898
|
-
const form = M2.form(Form.make(), managerCtx)
|
|
2899
|
-
if (Related2?.model) autoWireManagerForm(form, Related2)
|
|
2900
|
-
|
|
2901
|
-
const elements: Element[] = [form]
|
|
2902
|
-
tagFormActions(elements, createUrl)
|
|
2903
|
-
|
|
2904
|
-
if (scope.prefill) {
|
|
2905
|
-
if (scope.prefill.values) form.withValues(scope.prefill.values)
|
|
2906
|
-
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
2907
|
-
}
|
|
2908
|
-
|
|
2909
|
-
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2910
|
-
if (tabs) elements.unshift(tabs)
|
|
2911
|
-
|
|
2912
|
-
const breadcrumbs = nestedRelationCreateBreadcrumbs(
|
|
2913
|
-
cfg, resolved.R, resolved.M1, M2, scope.chain[0],
|
|
2914
|
-
deriveParentTitle(resolved.R, resolved.parentRecord),
|
|
2915
|
-
scope.chain[1].recordId,
|
|
2916
|
-
deriveParentTitle(resolved.Related1, child1, resolved.M1),
|
|
2917
|
-
)
|
|
2918
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2919
|
-
|
|
2920
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2921
|
-
mode: 'create',
|
|
2922
|
-
basePath: base,
|
|
2923
|
-
record: child1,
|
|
2924
|
-
}, user), cfg)
|
|
2925
|
-
|
|
2926
|
-
const nestedCreateRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.chain[1].recordId }
|
|
2927
|
-
const schemaData = await applyRoleHooks(
|
|
2928
|
-
pilotiq, user, 'relation-create',
|
|
2929
|
-
await resolveSchema(elements, ctx),
|
|
2930
|
-
nestedCreateRoute,
|
|
2931
|
-
)
|
|
2932
|
-
|
|
2933
|
-
return {
|
|
2934
|
-
...nestedResponseEnvelope('nested-relation-create', pilotiq, base, scope, resolved, req),
|
|
2935
|
-
panel: await panelInfo(pilotiq, req, nestedCreateRoute),
|
|
2936
|
-
mode: 'create' as const,
|
|
2937
|
-
schemaData,
|
|
2938
|
-
...(scope.prefill?.errors ? { hasErrors: true } : {}),
|
|
2939
|
-
}
|
|
2940
|
-
}
|
|
2941
|
-
|
|
2942
|
-
async function buildNestedRelationViewData(
|
|
2943
|
-
pilotiq: Pilotiq,
|
|
2944
|
-
scope: Extract<NestedRelationScope, { kind: 'nested-relation-view' }>,
|
|
2945
|
-
resolved: ResolvedChain,
|
|
2946
|
-
req: unknown,
|
|
2947
|
-
user: unknown,
|
|
2948
|
-
): Promise<RelationManagerResult> {
|
|
2949
|
-
const { Related1, child1, M2, Related2 } = resolved
|
|
2950
|
-
if (!Related2?.model) {
|
|
2951
|
-
throw new Error(
|
|
2952
|
-
`[Pilotiq] Cannot load child record for nested manager ${M2.name}: ` +
|
|
2953
|
-
`Related Resource ${Related2?.name ?? '(none)'} has no static model.`,
|
|
2954
|
-
)
|
|
2955
|
-
}
|
|
2956
|
-
const [, step1] = scope.chain
|
|
2957
|
-
const child2Pk = getPrimaryKey(Related2.model)
|
|
2958
|
-
|
|
2959
|
-
const belongs2 = await childBelongsToParent(
|
|
2960
|
-
Related1.model as ModelLike, child1, step1.relationship, child2Pk, scope.childId,
|
|
2961
|
-
)
|
|
2962
|
-
if (!belongs2) return null
|
|
2963
|
-
|
|
2964
|
-
const child2 = await findRecord(Related2, scope.childId, { user }).catch(() => undefined)
|
|
2965
|
-
if (!child2) return null
|
|
2966
|
-
|
|
2967
|
-
if (!await safeManagerPolicy(M2, 'canView', Related2, user, child1, child2)) return { ok: false, status: 403 }
|
|
2968
|
-
|
|
2969
|
-
const cfg = pilotiq.getConfig()
|
|
2970
|
-
const base = cfg.path
|
|
2971
|
-
|
|
2972
|
-
const elements: Element[] = M2.detail(child2, child1)
|
|
2973
|
-
|
|
2974
|
-
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
2975
|
-
if (tabs) elements.unshift(tabs)
|
|
2976
|
-
|
|
2977
|
-
const breadcrumbs = nestedRelationViewBreadcrumbs(
|
|
2978
|
-
cfg, resolved.R, resolved.M1, M2, scope.chain[0],
|
|
2979
|
-
deriveParentTitle(resolved.R, resolved.parentRecord),
|
|
2980
|
-
scope.chain[1].recordId,
|
|
2981
|
-
deriveParentTitle(Related1, child1, resolved.M1),
|
|
2982
|
-
deriveParentTitle(Related2, child2, M2),
|
|
2983
|
-
)
|
|
2984
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
2985
|
-
|
|
2986
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
2987
|
-
mode: 'view',
|
|
2988
|
-
basePath: base,
|
|
2989
|
-
record: child2,
|
|
2990
|
-
recordId: scope.childId,
|
|
2991
|
-
}, user), cfg)
|
|
2992
|
-
|
|
2993
|
-
const nestedViewRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.childId }
|
|
2994
|
-
const schemaData = await applyRoleHooks(
|
|
2995
|
-
pilotiq, user, 'relation-view',
|
|
2996
|
-
await resolveSchema(elements, ctx),
|
|
2997
|
-
nestedViewRoute,
|
|
2998
|
-
)
|
|
2999
|
-
|
|
3000
|
-
return {
|
|
3001
|
-
...nestedResponseEnvelope('nested-relation-view', pilotiq, base, scope, resolved, req),
|
|
3002
|
-
panel: await panelInfo(pilotiq, req, nestedViewRoute),
|
|
3003
|
-
mode: 'view' as const,
|
|
3004
|
-
childId: scope.childId,
|
|
3005
|
-
schemaData,
|
|
3006
|
-
}
|
|
3007
|
-
}
|
|
3008
|
-
|
|
3009
|
-
async function buildNestedRelationEditData(
|
|
3010
|
-
pilotiq: Pilotiq,
|
|
3011
|
-
scope: Extract<NestedRelationScope, { kind: 'nested-relation-edit' }>,
|
|
3012
|
-
resolved: ResolvedChain,
|
|
3013
|
-
req: unknown,
|
|
3014
|
-
user: unknown,
|
|
3015
|
-
): Promise<RelationManagerResult> {
|
|
3016
|
-
const { Related1, child1, M2, Related2 } = resolved
|
|
3017
|
-
if (!Related2?.model) {
|
|
3018
|
-
throw new Error(
|
|
3019
|
-
`[Pilotiq] Cannot load child record for nested manager ${M2.name}: ` +
|
|
3020
|
-
`Related Resource ${Related2?.name ?? '(none)'} has no static model.`,
|
|
3021
|
-
)
|
|
3022
|
-
}
|
|
3023
|
-
const [step0, step1] = scope.chain
|
|
3024
|
-
const child2Pk = getPrimaryKey(Related2.model)
|
|
3025
|
-
|
|
3026
|
-
const belongs2 = await childBelongsToParent(
|
|
3027
|
-
Related1.model as ModelLike, child1, step1.relationship, child2Pk, scope.childId,
|
|
3028
|
-
)
|
|
3029
|
-
if (!belongs2) return null
|
|
3030
|
-
|
|
3031
|
-
const child2 = await findRecord(Related2, scope.childId, { user }).catch(() => undefined)
|
|
3032
|
-
if (!child2) return null
|
|
3033
|
-
|
|
3034
|
-
if (!await safeManagerPolicy(M2, 'canEdit', Related2, user, child1, child2)) return { ok: false, status: 403 }
|
|
3035
|
-
|
|
3036
|
-
const cfg = pilotiq.getConfig()
|
|
3037
|
-
const base = cfg.path
|
|
3038
|
-
const resourceBase = resourceBasePath(base, resolved.R)
|
|
3039
|
-
const editUrl = `${resourceBase}/${step0.recordId}/${step0.relationship}/${step1.recordId}/${step1.relationship}/${scope.childId}/edit`
|
|
3040
|
-
|
|
3041
|
-
const managerCtx = nestedManagerCtx(base, scope, resolved)
|
|
3042
|
-
const form = M2.form(Form.make(), managerCtx)
|
|
3043
|
-
autoWireManagerForm(form, Related2)
|
|
3044
|
-
|
|
3045
|
-
const elements: Element[] = [form]
|
|
3046
|
-
tagFormActions(elements, editUrl)
|
|
3047
|
-
|
|
3048
|
-
if (scope.prefill?.values) {
|
|
3049
|
-
form.withValues(scope.prefill.values)
|
|
3050
|
-
if (scope.prefill.errors) form.withErrors(scope.prefill.errors)
|
|
3051
|
-
} else if (child2 != null) {
|
|
3052
|
-
const values = await applyFillPipeline(form, child2)
|
|
3053
|
-
form.withValues(values)
|
|
3054
|
-
}
|
|
3055
|
-
|
|
3056
|
-
const tabs = await buildNestedRelationTabs(resolved.R, resolved.M1, base, scope.chain[0], scope.chain[1].recordId, scope.chain[1].relationship, user, resolved.child1)
|
|
3057
|
-
if (tabs) elements.unshift(tabs)
|
|
3058
|
-
|
|
3059
|
-
const breadcrumbs = nestedRelationEditBreadcrumbs(
|
|
3060
|
-
cfg, resolved.R, resolved.M1, M2, scope.chain[0],
|
|
3061
|
-
deriveParentTitle(resolved.R, resolved.parentRecord),
|
|
3062
|
-
scope.chain[1].recordId,
|
|
3063
|
-
deriveParentTitle(Related1, child1, resolved.M1),
|
|
3064
|
-
scope.childId,
|
|
3065
|
-
deriveParentTitle(Related2, child2, M2),
|
|
3066
|
-
)
|
|
3067
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
3068
|
-
|
|
3069
|
-
const ctx: SchemaContext = uploadCtx(userCtx({
|
|
3070
|
-
mode: 'edit',
|
|
3071
|
-
basePath: base,
|
|
3072
|
-
record: child2,
|
|
3073
|
-
recordId: scope.childId,
|
|
3074
|
-
}, user), cfg)
|
|
3075
|
-
|
|
3076
|
-
const nestedEditRoute: PanelInfoRoute = { resource: resolved.R, recordId: scope.childId }
|
|
3077
|
-
const schemaData = await applyRoleHooks(
|
|
3078
|
-
pilotiq, user, 'relation-edit',
|
|
3079
|
-
await resolveSchema(elements, ctx),
|
|
3080
|
-
nestedEditRoute,
|
|
3081
|
-
)
|
|
3082
|
-
|
|
3083
|
-
return {
|
|
3084
|
-
...nestedResponseEnvelope('nested-relation-edit', pilotiq, base, scope, resolved, req),
|
|
3085
|
-
panel: await panelInfo(pilotiq, req, nestedEditRoute),
|
|
3086
|
-
mode: 'edit' as const,
|
|
3087
|
-
childId: scope.childId,
|
|
3088
|
-
schemaData,
|
|
3089
|
-
...(scope.prefill?.errors ? { hasErrors: true } : {}),
|
|
3090
|
-
}
|
|
3091
|
-
}
|
|
3092
|
-
|
|
3093
|
-
/**
|
|
3094
|
-
* Phase B — build a `RelationTabs` strip scoped to a parent's nested
|
|
3095
|
-
* children (e.g. tabs for `replies / reactions` listed under a single
|
|
3096
|
-
* comment). The strip's "back" tab points to the depth-2 view page for
|
|
3097
|
-
* the leaf parent itself, then one tab per sibling nested manager.
|
|
3098
|
-
*
|
|
3099
|
-
* Only emitted when the depth-1 manager declares `M.relations()` —
|
|
3100
|
-
* absent that, callers skip the prepend so single-manager surfaces stay
|
|
3101
|
-
* clean. `activeKey` accepts the literal `'__view'` for the leaf
|
|
3102
|
-
* parent's view tab, or any sibling manager's relationship key.
|
|
3103
|
-
*
|
|
3104
|
-
* Per-tab `canX` gating (2026-05-11) — sibling nested-manager tabs run
|
|
3105
|
-
* `N.canViewAny(user, child1Record)` (with fall-through to the related
|
|
3106
|
-
* Resource via `safeManagerPolicy`) so the strip hides tabs the user
|
|
3107
|
-
* couldn't reach anyway. The back-link `__view` stays unconditional
|
|
3108
|
-
* since the user is already on a page scoped under `M.canViewAny` —
|
|
3109
|
-
* they reached this strip, they can navigate back to it.
|
|
3110
|
-
*/
|
|
3111
|
-
async function buildNestedRelationTabs(
|
|
3112
|
-
R: ResourceClass,
|
|
3113
|
-
M: typeof RelationManager,
|
|
3114
|
-
basePath: string,
|
|
3115
|
-
step0: RelationChainStep,
|
|
3116
|
-
child1Id: string,
|
|
3117
|
-
activeKey: string,
|
|
3118
|
-
user: unknown,
|
|
3119
|
-
child1Record: unknown,
|
|
3120
|
-
): Promise<RelationTabs | undefined> {
|
|
3121
|
-
const siblings = M.relations()
|
|
3122
|
-
if (siblings.length === 0) return undefined
|
|
3123
|
-
|
|
3124
|
-
const resourceBase = resourceBasePath(basePath, R)
|
|
3125
|
-
const parentBase = `${resourceBase}/${step0.recordId}/${step0.relationship}`
|
|
3126
|
-
|
|
3127
|
-
// Sibling gating runs in parallel — each predicate may hit auth /
|
|
3128
|
-
// db, so don't serialize them.
|
|
3129
|
-
const siblingGates = siblings.map(N => {
|
|
3130
|
-
const Related = (N as unknown as { relatedResource?: ResourceClass }).relatedResource
|
|
3131
|
-
return safeManagerPolicyImpl(N, 'canViewAny', Related, user, child1Record)
|
|
3132
|
-
})
|
|
3133
|
-
const siblingVisible = await Promise.all(siblingGates)
|
|
3134
|
-
|
|
3135
|
-
const tabs: RelationTabMeta[] = []
|
|
3136
|
-
|
|
3137
|
-
// Back-link: depth-2 view page for the leaf parent record. Acts as
|
|
3138
|
-
// the "View" tab in the same way `__view` does on depth-1 strips.
|
|
3139
|
-
tabs.push(relationTab({
|
|
3140
|
-
key: '__view',
|
|
3141
|
-
label: M.getLabelSingular(),
|
|
3142
|
-
url: `${parentBase}/${child1Id}`,
|
|
3143
|
-
active: activeKey === '__view',
|
|
3144
|
-
icon: M.getIcon(),
|
|
3145
|
-
iconOwner: M.name,
|
|
3146
|
-
}))
|
|
3147
|
-
|
|
3148
|
-
siblings.forEach((N, i) => {
|
|
3149
|
-
if (!siblingVisible[i]) return
|
|
3150
|
-
let nestedRel = ''
|
|
3151
|
-
try { nestedRel = N.getRelationship() } catch { return }
|
|
3152
|
-
const icon = N.getIcon()
|
|
3153
|
-
tabs.push(relationTab({
|
|
3154
|
-
key: nestedRel,
|
|
3155
|
-
label: N.getLabel(),
|
|
3156
|
-
url: `${parentBase}/${child1Id}/${nestedRel}`,
|
|
3157
|
-
active: activeKey === nestedRel,
|
|
3158
|
-
...(icon !== undefined ? { icon, iconOwner: N.name } : {}),
|
|
3159
|
-
}))
|
|
3160
|
-
})
|
|
3161
|
-
|
|
3162
|
-
// After gating, only the back-link may remain — one tab isn't a
|
|
3163
|
-
// useful sub-nav. Drop the strip in that case (consistent with
|
|
3164
|
-
// depth-1's empty-tabs branch).
|
|
3165
|
-
if (tabs.length <= 1) return undefined
|
|
3166
|
-
|
|
3167
|
-
return RelationTabs.make(tabs)
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
/**
|
|
3171
|
-
* Plan #11 — build the `RelationTabs` strip for a parent record. The
|
|
3172
|
-
* strip surfaces the per-record sub-navigation: View, Edit, plus one
|
|
3173
|
-
* tab per `R.relations()` manager. `activeKey` selects which tab the
|
|
3174
|
-
* renderer highlights — `'__view'` / `'__edit'` for the parent tabs,
|
|
3175
|
-
* the manager's relationship key for a manager tab.
|
|
3176
|
-
*
|
|
3177
|
-
* Sub-nav follow-up (2026-05-03 cont'd) — emit BOTH `__view` and
|
|
3178
|
-
* `__edit` as sibling tabs (record sub-navigation) instead of one
|
|
3179
|
-
* parent tab whose label depends on mode. Tabs are dropped when the
|
|
3180
|
-
* corresponding page role isn't registered (a Resource overriding
|
|
3181
|
-
* `pages()` to omit `view` or `edit` shouldn't surface a tab that
|
|
3182
|
-
* 404s).
|
|
3183
|
-
*
|
|
3184
|
-
* Per-tab `canX` gating (2026-05-11) — the strip now also evaluates
|
|
3185
|
-
* the matching predicate for each tab and drops the entry when the
|
|
3186
|
-
* user can't reach it. Routes still enforce; this is presentation
|
|
3187
|
-
* polish so the chrome doesn't promise a link that 403s on click.
|
|
3188
|
-
*
|
|
3189
|
-
* - `__view` → `R.canView(user, parentRecord)` (skip gating when
|
|
3190
|
-
* `parentRecord` is undefined — record load failed,
|
|
3191
|
-
* so the route's own load+gate will surface a 404/403
|
|
3192
|
-
* rather than the strip hiding silently).
|
|
3193
|
-
* - `__edit` → `R.canEdit(user, parentRecord)` (same posture).
|
|
3194
|
-
* - manager → `safeManagerPolicy(M, 'canViewAny', Related, user,
|
|
3195
|
-
* parentRecord)` (falls through to Related's
|
|
3196
|
-
* `canViewAny` when the manager hasn't overridden).
|
|
3197
|
-
*
|
|
3198
|
-
* Returns `undefined` when the resource has no relation managers — the
|
|
3199
|
-
* caller can then skip the prepend entirely so resources without
|
|
3200
|
-
* relations stay shape-compatible with their existing schemaData.
|
|
3201
|
-
* (View+Edit sub-nav alone isn't worth a tab strip; users navigate
|
|
3202
|
-
* those via headerActions or the back link.)
|
|
3203
|
-
*/
|
|
3204
|
-
async function buildRelationTabs(
|
|
3205
|
-
R: ResourceClass,
|
|
3206
|
-
recordId: string,
|
|
3207
|
-
basePath: string,
|
|
3208
|
-
activeKey: string,
|
|
3209
|
-
user: unknown,
|
|
3210
|
-
parentRecord: unknown,
|
|
3211
|
-
): Promise<RelationTabs | undefined> {
|
|
3212
|
-
const managers = R.relations()
|
|
3213
|
-
const recordPageMap = R.getRecordPages()
|
|
3214
|
-
const recordPageSlugs = Object.keys(recordPageMap)
|
|
3215
|
-
// No managers AND no record sub-pages → no strip. View+Edit alone
|
|
3216
|
-
// isn't worth a tab strip; users navigate those via headerActions or
|
|
3217
|
-
// the back link. (When either is non-empty, the strip is worth
|
|
3218
|
-
// mounting even if all the dynamic tabs end up gated away — the
|
|
3219
|
-
// post-gate emptiness check below catches that.)
|
|
3220
|
-
if (managers.length === 0 && recordPageSlugs.length === 0) return undefined
|
|
3221
|
-
|
|
3222
|
-
const resourceBase = resourceBasePath(basePath, R)
|
|
3223
|
-
const pages = R.resolvePages()
|
|
3224
|
-
|
|
3225
|
-
// Evaluate every per-tab predicate in parallel. The arrays line up
|
|
3226
|
-
// 1:1 with `pages.view` / `pages.edit` / `recordPageSlugs` /
|
|
3227
|
-
// `managers` below — we resolve all gates first so the tab-build
|
|
3228
|
-
// loop stays straight-line.
|
|
3229
|
-
// Record-aware predicates short-circuit to `true` when no parent
|
|
3230
|
-
// record was loaded (presentation should never hide more aggressively
|
|
3231
|
-
// than the route can enforce; a missing record means the route will
|
|
3232
|
-
// 404/403 on click and the strip stays consistent with that).
|
|
3233
|
-
const canViewPromise = pages.view && parentRecord !== undefined && parentRecord !== null
|
|
3234
|
-
? safeBool(() => R.canView(user, parentRecord))
|
|
3235
|
-
: Promise.resolve(true)
|
|
3236
|
-
const canEditPromise = pages.edit && parentRecord !== undefined && parentRecord !== null
|
|
3237
|
-
? safeBool(() => R.canEdit(user, parentRecord))
|
|
3238
|
-
: Promise.resolve(true)
|
|
3239
|
-
const recordPageGates = recordPageSlugs.map(subSlug => {
|
|
3240
|
-
// Record sub-page gates run against the parent record verbatim —
|
|
3241
|
-
// missing record still calls the predicate so a sub-page that
|
|
3242
|
-
// gates on global user state (no record needed) still evaluates.
|
|
3243
|
-
// safeBool fails closed for throwing predicates.
|
|
3244
|
-
return safeBool(() => recordPageMap[subSlug]!.canAccess(user, parentRecord))
|
|
3245
|
-
})
|
|
3246
|
-
const managerGates = managers.map(M => {
|
|
3247
|
-
const Related = (M as unknown as { relatedResource?: ResourceClass }).relatedResource
|
|
3248
|
-
return safeManagerPolicyImpl(M, 'canViewAny', Related, user, parentRecord)
|
|
3249
|
-
})
|
|
3250
|
-
const gateResults = await Promise.all([
|
|
3251
|
-
canViewPromise, canEditPromise,
|
|
3252
|
-
...recordPageGates,
|
|
3253
|
-
...managerGates,
|
|
3254
|
-
])
|
|
3255
|
-
const canView = gateResults[0] as boolean
|
|
3256
|
-
const canEdit = gateResults[1] as boolean
|
|
3257
|
-
const recordPageVisible = gateResults.slice(2, 2 + recordPageSlugs.length) as boolean[]
|
|
3258
|
-
const managerVisible = gateResults.slice(2 + recordPageSlugs.length) as boolean[]
|
|
3259
|
-
|
|
3260
|
-
const tabs: RelationTabMeta[] = []
|
|
3261
|
-
|
|
3262
|
-
if (pages.view && canView) {
|
|
3263
|
-
tabs.push(relationTab({
|
|
3264
|
-
key: '__view',
|
|
3265
|
-
label: 'View',
|
|
3266
|
-
url: `${resourceBase}/${recordId}`,
|
|
3267
|
-
active: activeKey === '__view',
|
|
3268
|
-
icon: R.icon as IconValue | undefined,
|
|
3269
|
-
iconOwner: R.name,
|
|
3270
|
-
}))
|
|
3271
|
-
}
|
|
3272
|
-
|
|
3273
|
-
if (pages.edit && canEdit) {
|
|
3274
|
-
tabs.push(relationTab({
|
|
3275
|
-
key: '__edit',
|
|
3276
|
-
label: 'Edit',
|
|
3277
|
-
url: `${resourceBase}/${recordId}/edit`,
|
|
3278
|
-
active: activeKey === '__edit',
|
|
3279
|
-
// Re-use the resource icon so when ViewPage is pruned, Edit
|
|
3280
|
-
// still carries the visual identity. When both are present, the
|
|
3281
|
-
// icon repeats — acceptable; the labels disambiguate.
|
|
3282
|
-
icon: R.icon as IconValue | undefined,
|
|
3283
|
-
iconOwner: R.name,
|
|
3284
|
-
}))
|
|
3285
|
-
}
|
|
3286
|
-
|
|
3287
|
-
// Record sub-page tabs — between Edit and the managers, in declaration
|
|
3288
|
-
// order. Tab label inherits from the sub-page's class (`getLabel()`);
|
|
3289
|
-
// icon picks up the sub-page's static `icon` when set. Slug doubles as
|
|
3290
|
-
// the URL segment AND the `activeKey` discriminator the data builder
|
|
3291
|
-
// passes when rendering the sub-page.
|
|
3292
|
-
recordPageSlugs.forEach((subSlug, i) => {
|
|
3293
|
-
if (!recordPageVisible[i]) return
|
|
3294
|
-
const SubPage = recordPageMap[subSlug]!
|
|
3295
|
-
tabs.push(relationTab({
|
|
3296
|
-
key: subSlug,
|
|
3297
|
-
label: SubPage.getLabel(),
|
|
3298
|
-
url: `${resourceBase}/${recordId}/${subSlug}`,
|
|
3299
|
-
active: activeKey === subSlug,
|
|
3300
|
-
...(SubPage.icon !== undefined
|
|
3301
|
-
? { icon: SubPage.icon, iconOwner: SubPage.name }
|
|
3302
|
-
: {}),
|
|
3303
|
-
}))
|
|
3304
|
-
})
|
|
3305
|
-
|
|
3306
|
-
managers.forEach((M, i) => {
|
|
3307
|
-
if (!managerVisible[i]) return
|
|
3308
|
-
let rel = ''
|
|
3309
|
-
try { rel = M.getRelationship() } catch { return }
|
|
3310
|
-
const icon = M.getIcon()
|
|
3311
|
-
tabs.push(relationTab({
|
|
3312
|
-
key: rel,
|
|
3313
|
-
label: M.getLabel(),
|
|
3314
|
-
url: `${resourceBase}/${recordId}/${rel}`,
|
|
3315
|
-
active: activeKey === rel,
|
|
3316
|
-
...(icon !== undefined ? { icon, iconOwner: M.name } : {}),
|
|
3317
|
-
}))
|
|
3318
|
-
})
|
|
3319
|
-
|
|
3320
|
-
// After gating, the strip may collapse to zero entries. Mirror the
|
|
3321
|
-
// "no managers + no sub-pages" branch above — no strip is friendlier
|
|
3322
|
-
// than a one-tab strip with just the active page.
|
|
3323
|
-
if (tabs.length === 0) return undefined
|
|
3324
|
-
|
|
3325
|
-
return RelationTabs.make(tabs)
|
|
3326
|
-
}
|
|
3327
|
-
|
|
3328
|
-
/**
|
|
3329
|
-
* Tiny shim over `try { Boolean(await fn()) } catch { false }` so the
|
|
3330
|
-
* relation-tabs builder stays straight-line — mirrors `checkPolicy`
|
|
3331
|
-
* in `routes.ts` but kept local to avoid cross-module imports.
|
|
3332
|
-
*/
|
|
3333
|
-
async function safeBool(fn: () => boolean | Promise<boolean>): Promise<boolean> {
|
|
3334
|
-
try { return Boolean(await fn()) } catch { return false }
|
|
3335
|
-
}
|
|
3336
|
-
|
|
3337
|
-
/** Pull a human-readable title off a parent record for breadcrumb /
|
|
3338
|
-
* page-title use. Falls back through `recordTitleAttribute` →
|
|
3339
|
-
* `name` → `title` → primary key value → 'Record'. */
|
|
3340
|
-
function deriveParentTitle(
|
|
3341
|
-
R: ResourceClass,
|
|
3342
|
-
record: unknown,
|
|
3343
|
-
manager?: typeof RelationManager,
|
|
3344
|
-
): string {
|
|
3345
|
-
const r = record as Record<string, unknown>
|
|
3346
|
-
// Manager-scoped child rows prefer the manager's `recordTitleAttribute`
|
|
3347
|
-
// when set — the manager owns its presentation surface, and the related
|
|
3348
|
-
// Resource may not opt into the same column (e.g. nested-only Resources
|
|
3349
|
-
// that exist purely to back a manager).
|
|
3350
|
-
const managerAttr = manager?.recordTitleAttribute
|
|
3351
|
-
if (managerAttr && r[managerAttr] != null) return String(r[managerAttr])
|
|
3352
|
-
const attr = R.recordTitleAttribute
|
|
3353
|
-
if (attr && r[attr] != null) return String(r[attr])
|
|
3354
|
-
if (r['name'] != null) return String(r['name'])
|
|
3355
|
-
if (r['title'] != null) return String(r['title'])
|
|
3356
|
-
if (R.model) {
|
|
3357
|
-
const pk = getPrimaryKey(R.model)
|
|
3358
|
-
if (r[pk] != null) return String(r[pk])
|
|
3359
|
-
}
|
|
3360
|
-
return 'Record'
|
|
3361
|
-
}
|
|
3362
|
-
|
|
3363
|
-
// ─── Phase C breadcrumb builders ─────────────────────────────
|
|
3364
|
-
//
|
|
3365
|
-
// Server-resolved chain rendered above any other top-of-page chrome
|
|
3366
|
-
// (e.g. RelationTabs). The trailing item is always the current page,
|
|
3367
|
-
// emitted without a `url` so the renderer can paint it as plain text
|
|
3368
|
-
// + `aria-current="page"`. All earlier items link to their canonical
|
|
3369
|
-
// URL — clusters route through `clusterBasePath`, resources through
|
|
3370
|
-
// `resourceBasePath`, etc., so a clustered resource resolves to
|
|
3371
|
-
// `Home / Cluster / Resource / …` instead of skipping the cluster
|
|
3372
|
-
// rung.
|
|
3373
|
-
|
|
3374
|
-
function homeBreadcrumb(cfg: PilotiqConfig): BreadcrumbItem {
|
|
3375
|
-
return {
|
|
3376
|
-
label: cfg.branding?.title ?? cfg.name ?? 'Home',
|
|
3377
|
-
url: cfg.path,
|
|
3378
|
-
}
|
|
3379
|
-
}
|
|
3380
|
-
|
|
3381
|
-
function clusterBreadcrumb(cfg: PilotiqConfig, child: { cluster?: ClusterClass }): BreadcrumbItem | undefined {
|
|
3382
|
-
if (!child.cluster) return undefined
|
|
3383
|
-
return {
|
|
3384
|
-
label: child.cluster.label,
|
|
3385
|
-
url: clusterBasePath(cfg.path, child.cluster),
|
|
3386
|
-
}
|
|
3387
|
-
}
|
|
3388
|
-
|
|
3389
|
-
function buildBreadcrumbs(items: BreadcrumbItem[]): Breadcrumbs | undefined {
|
|
3390
|
-
// A single "Home" rung carries no information beyond the dashboard
|
|
3391
|
-
// link the layout already exposes — drop it. Every other length is
|
|
3392
|
-
// worth rendering.
|
|
3393
|
-
if (items.length < 2) return undefined
|
|
3394
|
-
return Breadcrumbs.make(items)
|
|
3395
|
-
}
|
|
3396
|
-
|
|
3397
|
-
function resourceListBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass): Breadcrumbs | undefined {
|
|
3398
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3399
|
-
const cluster = clusterBreadcrumb(cfg, R)
|
|
3400
|
-
if (cluster) items.push(cluster)
|
|
3401
|
-
items.push({ label: R.getBreadcrumb() })
|
|
3402
|
-
return buildBreadcrumbs(items)
|
|
3403
|
-
}
|
|
3404
|
-
|
|
3405
|
-
function resourceCreateBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass): Breadcrumbs | undefined {
|
|
3406
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3407
|
-
const cluster = clusterBreadcrumb(cfg, R)
|
|
3408
|
-
if (cluster) items.push(cluster)
|
|
3409
|
-
items.push({ label: R.getBreadcrumb(), url: resourceBasePath(cfg.path, R) })
|
|
3410
|
-
items.push({ label: 'Create' })
|
|
3411
|
-
return buildBreadcrumbs(items)
|
|
3412
|
-
}
|
|
3413
|
-
|
|
3414
|
-
function resourceViewBreadcrumbs(cfg: PilotiqConfig, R: ResourceClass, recordTitle: string): Breadcrumbs | undefined {
|
|
3415
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3416
|
-
const cluster = clusterBreadcrumb(cfg, R)
|
|
3417
|
-
if (cluster) items.push(cluster)
|
|
3418
|
-
items.push({ label: R.getBreadcrumb(), url: resourceBasePath(cfg.path, R) })
|
|
3419
|
-
items.push({ label: recordTitle })
|
|
3420
|
-
return buildBreadcrumbs(items)
|
|
3421
|
-
}
|
|
3422
|
-
|
|
3423
|
-
function resourceEditBreadcrumbs(
|
|
3424
|
-
cfg: PilotiqConfig,
|
|
3425
|
-
R: ResourceClass,
|
|
3426
|
-
recordId: string,
|
|
3427
|
-
recordTitle: string,
|
|
3428
|
-
): Breadcrumbs | undefined {
|
|
3429
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3430
|
-
const cluster = clusterBreadcrumb(cfg, R)
|
|
3431
|
-
if (cluster) items.push(cluster)
|
|
3432
|
-
const resourceBase = resourceBasePath(cfg.path, R)
|
|
3433
|
-
items.push({ label: R.getBreadcrumb(), url: resourceBase })
|
|
3434
|
-
// Link the record title to the View page when registered — falls
|
|
3435
|
-
// back to plain text so users who pruned ViewPage don't hit a 404.
|
|
3436
|
-
const hasView = R.resolvePages().view !== undefined
|
|
3437
|
-
items.push(hasView
|
|
3438
|
-
? { label: recordTitle, url: `${resourceBase}/${recordId}` }
|
|
3439
|
-
: { label: recordTitle })
|
|
3440
|
-
items.push({ label: 'Edit' })
|
|
3441
|
-
return buildBreadcrumbs(items)
|
|
3442
|
-
}
|
|
3443
|
-
|
|
3444
|
-
function globalBreadcrumbs(cfg: PilotiqConfig, G: GlobalClass): Breadcrumbs | undefined {
|
|
3445
|
-
// Globals don't have a list page — `Home > <Global Label>` is the
|
|
3446
|
-
// shortest meaningful chain. Edit and View collapse to the same
|
|
3447
|
-
// breadcrumb (both render the singleton).
|
|
3448
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3449
|
-
const cluster = clusterBreadcrumb(cfg, G)
|
|
3450
|
-
if (cluster) items.push(cluster)
|
|
3451
|
-
items.push({ label: G.label })
|
|
3452
|
-
return buildBreadcrumbs(items)
|
|
3453
|
-
}
|
|
3454
|
-
|
|
3455
|
-
function customPageBreadcrumbs(cfg: PilotiqConfig, P: typeof Page): Breadcrumbs | undefined {
|
|
3456
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3457
|
-
const cluster = clusterBreadcrumb(cfg, P)
|
|
3458
|
-
if (cluster) items.push(cluster)
|
|
3459
|
-
items.push({ label: P.getLabel() })
|
|
3460
|
-
return buildBreadcrumbs(items)
|
|
3461
|
-
}
|
|
3462
|
-
|
|
3463
|
-
/** Common "Home / cluster? / Resource / parent record" prefix used by
|
|
3464
|
-
* every relation-* / nested-relation-* breadcrumb. The parent record
|
|
3465
|
-
* links to its View page when registered; the resource list is the
|
|
3466
|
-
* fallback so users still have a back-link out of the relation chain. */
|
|
3467
|
-
function relationBreadcrumbPrefix(
|
|
3468
|
-
cfg: PilotiqConfig,
|
|
3469
|
-
R: ResourceClass,
|
|
3470
|
-
parentId: string,
|
|
3471
|
-
parentTitle: string,
|
|
3472
|
-
): BreadcrumbItem[] {
|
|
3473
|
-
const items: BreadcrumbItem[] = [homeBreadcrumb(cfg)]
|
|
3474
|
-
const cluster = clusterBreadcrumb(cfg, R)
|
|
3475
|
-
if (cluster) items.push(cluster)
|
|
3476
|
-
const resourceBase = resourceBasePath(cfg.path, R)
|
|
3477
|
-
items.push({ label: R.getBreadcrumb(), url: resourceBase })
|
|
3478
|
-
const hasView = R.resolvePages().view !== undefined
|
|
3479
|
-
items.push(hasView
|
|
3480
|
-
? { label: parentTitle, url: `${resourceBase}/${parentId}` }
|
|
3481
|
-
: { label: parentTitle })
|
|
3482
|
-
return items
|
|
3483
|
-
}
|
|
3484
13
|
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
M: typeof RelationManager,
|
|
3530
|
-
parentId: string,
|
|
3531
|
-
parentTitle: string,
|
|
3532
|
-
childId: string,
|
|
3533
|
-
childTitle: string,
|
|
3534
|
-
): Breadcrumbs | undefined {
|
|
3535
|
-
const items = relationBreadcrumbPrefix(cfg, R, parentId, parentTitle)
|
|
3536
|
-
const relBase = `${resourceBasePath(cfg.path, R)}/${parentId}/${M.getRelationship()}`
|
|
3537
|
-
items.push({ label: M.getLabel(), url: relBase })
|
|
3538
|
-
// Phase A always mounts the relation-view page per (R, M), so the
|
|
3539
|
-
// child title can always link back to it.
|
|
3540
|
-
items.push({ label: childTitle, url: `${relBase}/${childId}` })
|
|
3541
|
-
items.push({ label: 'Edit' })
|
|
3542
|
-
return buildBreadcrumbs(items)
|
|
3543
|
-
}
|
|
3544
|
-
|
|
3545
|
-
/** Phase B — depth-2 prefix shared by every nested-relation-* role.
|
|
3546
|
-
* Returns "Home / cluster? / Resource / parent / M1 / child1". */
|
|
3547
|
-
function nestedRelationBreadcrumbPrefix(
|
|
3548
|
-
cfg: PilotiqConfig,
|
|
3549
|
-
R: ResourceClass,
|
|
3550
|
-
M1: typeof RelationManager,
|
|
3551
|
-
step0: RelationChainStep,
|
|
3552
|
-
parentTitle: string,
|
|
3553
|
-
child1Id: string,
|
|
3554
|
-
child1Title: string,
|
|
3555
|
-
): BreadcrumbItem[] {
|
|
3556
|
-
const items = relationBreadcrumbPrefix(cfg, R, step0.recordId, parentTitle)
|
|
3557
|
-
const rel1Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}`
|
|
3558
|
-
items.push({ label: M1.getLabel(), url: rel1Base })
|
|
3559
|
-
// Phase A relation-view always mounted, so child1 always links.
|
|
3560
|
-
items.push({ label: child1Title, url: `${rel1Base}/${child1Id}` })
|
|
3561
|
-
return items
|
|
3562
|
-
}
|
|
3563
|
-
|
|
3564
|
-
function nestedRelationListBreadcrumbs(
|
|
3565
|
-
cfg: PilotiqConfig,
|
|
3566
|
-
R: ResourceClass,
|
|
3567
|
-
M1: typeof RelationManager,
|
|
3568
|
-
M2: typeof RelationManager,
|
|
3569
|
-
step0: RelationChainStep,
|
|
3570
|
-
parentTitle: string,
|
|
3571
|
-
child1Id: string,
|
|
3572
|
-
child1Title: string,
|
|
3573
|
-
): Breadcrumbs | undefined {
|
|
3574
|
-
const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
|
|
3575
|
-
items.push({ label: M2.getLabel() })
|
|
3576
|
-
return buildBreadcrumbs(items)
|
|
3577
|
-
}
|
|
3578
|
-
|
|
3579
|
-
function nestedRelationCreateBreadcrumbs(
|
|
3580
|
-
cfg: PilotiqConfig,
|
|
3581
|
-
R: ResourceClass,
|
|
3582
|
-
M1: typeof RelationManager,
|
|
3583
|
-
M2: typeof RelationManager,
|
|
3584
|
-
step0: RelationChainStep,
|
|
3585
|
-
parentTitle: string,
|
|
3586
|
-
child1Id: string,
|
|
3587
|
-
child1Title: string,
|
|
3588
|
-
): Breadcrumbs | undefined {
|
|
3589
|
-
const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
|
|
3590
|
-
const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
|
|
3591
|
-
items.push({ label: M2.getLabel(), url: rel2Base })
|
|
3592
|
-
items.push({ label: 'Create' })
|
|
3593
|
-
return buildBreadcrumbs(items)
|
|
3594
|
-
}
|
|
3595
|
-
|
|
3596
|
-
function nestedRelationViewBreadcrumbs(
|
|
3597
|
-
cfg: PilotiqConfig,
|
|
3598
|
-
R: ResourceClass,
|
|
3599
|
-
M1: typeof RelationManager,
|
|
3600
|
-
M2: typeof RelationManager,
|
|
3601
|
-
step0: RelationChainStep,
|
|
3602
|
-
parentTitle: string,
|
|
3603
|
-
child1Id: string,
|
|
3604
|
-
child1Title: string,
|
|
3605
|
-
child2Title: string,
|
|
3606
|
-
): Breadcrumbs | undefined {
|
|
3607
|
-
const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
|
|
3608
|
-
const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
|
|
3609
|
-
items.push({ label: M2.getLabel(), url: rel2Base })
|
|
3610
|
-
items.push({ label: child2Title })
|
|
3611
|
-
return buildBreadcrumbs(items)
|
|
3612
|
-
}
|
|
3613
|
-
|
|
3614
|
-
function nestedRelationEditBreadcrumbs(
|
|
3615
|
-
cfg: PilotiqConfig,
|
|
3616
|
-
R: ResourceClass,
|
|
3617
|
-
M1: typeof RelationManager,
|
|
3618
|
-
M2: typeof RelationManager,
|
|
3619
|
-
step0: RelationChainStep,
|
|
3620
|
-
parentTitle: string,
|
|
3621
|
-
child1Id: string,
|
|
3622
|
-
child1Title: string,
|
|
3623
|
-
child2Id: string,
|
|
3624
|
-
child2Title: string,
|
|
3625
|
-
): Breadcrumbs | undefined {
|
|
3626
|
-
const items = nestedRelationBreadcrumbPrefix(cfg, R, M1, step0, parentTitle, child1Id, child1Title)
|
|
3627
|
-
const rel2Base = `${resourceBasePath(cfg.path, R)}/${step0.recordId}/${step0.relationship}/${child1Id}/${M2.getRelationship()}`
|
|
3628
|
-
items.push({ label: M2.getLabel(), url: rel2Base })
|
|
3629
|
-
items.push({ label: child2Title, url: `${rel2Base}/${child2Id}` })
|
|
3630
|
-
items.push({ label: 'Edit' })
|
|
3631
|
-
return buildBreadcrumbs(items)
|
|
3632
|
-
}
|
|
3633
|
-
|
|
3634
|
-
// ─── Plan #5 partial-resolve data builder ────────────────────
|
|
3635
|
-
|
|
3636
|
-
export type FormStateScope =
|
|
3637
|
-
| { kind: 'resource-create'; slug: string }
|
|
3638
|
-
| { kind: 'resource-edit'; slug: string; recordId: string }
|
|
3639
|
-
| { kind: 'global-edit'; slug: string }
|
|
3640
|
-
| { kind: 'page'; pageSlug: string }
|
|
3641
|
-
|
|
3642
|
-
export interface FormStateRequest {
|
|
3643
|
-
formId: string
|
|
3644
|
-
changed: string
|
|
3645
|
-
values: Record<string, unknown>
|
|
3646
|
-
}
|
|
3647
|
-
|
|
3648
|
-
export interface FormStateResult {
|
|
3649
|
-
ok: true
|
|
3650
|
-
form: Record<string, unknown> // resolved FormMeta
|
|
3651
|
-
dirty: string[]
|
|
3652
|
-
}
|
|
3653
|
-
|
|
3654
|
-
export interface FormStateError {
|
|
3655
|
-
ok: false
|
|
3656
|
-
status: 404 | 422
|
|
3657
|
-
error: string
|
|
3658
|
-
}
|
|
3659
|
-
|
|
3660
|
-
/**
|
|
3661
|
-
* Plan #5 — handle a partial-resolve roundtrip from a `live()` field.
|
|
3662
|
-
*
|
|
3663
|
-
* Locates the page's schema, finds the targeted form by `formId`, runs
|
|
3664
|
-
* `applyStateUpdate` to apply the changed value + run
|
|
3665
|
-
* `afterStateUpdated`, then re-resolves the form's children with the
|
|
3666
|
-
* mutated values + bound `$get / $set` so dependent options /
|
|
3667
|
-
* conditional visibility re-evaluate. Returns the resolved FormMeta the
|
|
3668
|
-
* client uses to replace its rendered form.
|
|
3669
|
-
*
|
|
3670
|
-
* Returns `null` when the route prefix doesn't resolve to a real
|
|
3671
|
-
* resource/global/page — the route handler turns this into a 404. The
|
|
3672
|
-
* inner `{ status: 422 }` failure is for "form found but `changed`
|
|
3673
|
-
* field doesn't exist on it" — also a client-side bug.
|
|
3674
|
-
*/
|
|
3675
|
-
export async function formStateData(
|
|
3676
|
-
pilotiq: Pilotiq,
|
|
3677
|
-
scope: FormStateScope,
|
|
3678
|
-
body: FormStateRequest,
|
|
3679
|
-
req?: unknown,
|
|
3680
|
-
): Promise<FormStateResult | FormStateError | null> {
|
|
3681
|
-
const cfg = pilotiq.getConfig()
|
|
3682
|
-
const user = await pilotiq.resolveUser(req)
|
|
3683
|
-
|
|
3684
|
-
let PageClass: typeof Page | undefined
|
|
3685
|
-
let mode: 'create' | 'edit'
|
|
3686
|
-
let record: unknown = undefined
|
|
3687
|
-
let recordId: string | undefined
|
|
3688
|
-
let baseCtxExtras: Record<string, unknown> = {}
|
|
3689
|
-
|
|
3690
|
-
if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
|
|
3691
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
3692
|
-
if (!R) return null
|
|
3693
|
-
const pages = R.resolvePages()
|
|
3694
|
-
if (scope.kind === 'resource-create') {
|
|
3695
|
-
if (!pages.create) return null
|
|
3696
|
-
PageClass = pages.create
|
|
3697
|
-
mode = 'create'
|
|
3698
|
-
} else {
|
|
3699
|
-
if (!pages.edit) return null
|
|
3700
|
-
PageClass = pages.edit
|
|
3701
|
-
mode = 'edit'
|
|
3702
|
-
recordId = scope.recordId
|
|
3703
|
-
baseCtxExtras = { recordId }
|
|
3704
|
-
if (R.model) {
|
|
3705
|
-
try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
|
|
3706
|
-
} else if (recordId) {
|
|
3707
|
-
record = { id: recordId }
|
|
3708
|
-
}
|
|
3709
|
-
}
|
|
3710
|
-
} else if (scope.kind === 'global-edit') {
|
|
3711
|
-
const G = cfg.globals.find(g => g.getSlug() === scope.slug)
|
|
3712
|
-
if (!G) return null
|
|
3713
|
-
const pages = G.resolvePages()
|
|
3714
|
-
if (!pages.edit) return null
|
|
3715
|
-
PageClass = pages.edit
|
|
3716
|
-
mode = 'edit'
|
|
3717
|
-
} else {
|
|
3718
|
-
const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
|
|
3719
|
-
if (!P) return null
|
|
3720
|
-
PageClass = P
|
|
3721
|
-
// Custom pages don't have a record/edit-mode concept — pass mode
|
|
3722
|
-
// 'edit' so resolveSchema treats fields as form inputs (not table
|
|
3723
|
-
// cells / view-mode read-only).
|
|
3724
|
-
mode = 'edit'
|
|
3725
|
-
}
|
|
3726
|
-
|
|
3727
|
-
if (!PageClass) return null
|
|
3728
|
-
|
|
3729
|
-
const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
|
|
3730
|
-
const elements = await callPageSchema(PageClass, baseCtx)
|
|
3731
|
-
const form = selectFormById(findForms(elements), body.formId)
|
|
3732
|
-
if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
|
|
3733
|
-
|
|
3734
|
-
const update = await applyStateUpdate(form, body.values, body.changed, {
|
|
3735
|
-
...(record !== undefined ? { record } : {}),
|
|
3736
|
-
...(user !== null ? { user } : {}),
|
|
3737
|
-
request: req,
|
|
3738
|
-
})
|
|
3739
|
-
if (!update) {
|
|
3740
|
-
return { ok: false, status: 422, error: `Field "${body.changed}" not found on form "${body.formId}"` }
|
|
3741
|
-
}
|
|
3742
|
-
|
|
3743
|
-
// Re-resolve the form with the mutated values bound. We bind
|
|
3744
|
-
// `$get / $set` against the post-update values map so further
|
|
3745
|
-
// resolve-time logic (SelectField.options(fn), reactive
|
|
3746
|
-
// visibility) reads current state.
|
|
3747
|
-
const $get = (name: string): unknown => update.values[name]
|
|
3748
|
-
// $set on the resolve pass is a no-op — only afterStateUpdated
|
|
3749
|
-
// mutations survive into the response. Resolve-time `$set` would
|
|
3750
|
-
// race against the client's view of the world.
|
|
3751
|
-
const $set = (_name: string, _v: unknown): void => { /* intentional no-op */ }
|
|
3752
|
-
|
|
3753
|
-
const resolveCtx = {
|
|
3754
|
-
...baseCtx,
|
|
3755
|
-
values: update.values,
|
|
3756
|
-
$get,
|
|
3757
|
-
$set,
|
|
3758
|
-
changed: body.changed,
|
|
3759
|
-
...(record !== undefined ? { record } : {}),
|
|
3760
|
-
}
|
|
3761
|
-
// Snapshot values onto the form so its FormMeta carries them.
|
|
3762
|
-
form.withValues(update.values)
|
|
3763
|
-
const resolved = await resolveSchema([form], resolveCtx)
|
|
3764
|
-
const formMeta = resolved[0]
|
|
3765
|
-
if (!formMeta || formMeta.type !== 'form') {
|
|
3766
|
-
return { ok: false, status: 422, error: 'Form re-resolved to non-form meta' }
|
|
3767
|
-
}
|
|
3768
|
-
|
|
3769
|
-
return { ok: true, form: formMeta, dirty: update.dirty }
|
|
3770
|
-
}
|
|
3771
|
-
|
|
3772
|
-
// ─── Plan #8 wizard step-validate data builder ────────────────
|
|
3773
|
-
|
|
3774
|
-
export interface FormWizardRequest {
|
|
3775
|
-
formId: string
|
|
3776
|
-
step: number
|
|
3777
|
-
values: Record<string, unknown>
|
|
3778
|
-
}
|
|
3779
|
-
|
|
3780
|
-
export interface FormWizardSuccess {
|
|
3781
|
-
ok: true
|
|
3782
|
-
}
|
|
3783
|
-
|
|
3784
|
-
export interface FormWizardFailure {
|
|
3785
|
-
ok: false
|
|
3786
|
-
status: 404 | 422
|
|
3787
|
-
error?: string
|
|
3788
|
-
errors?: Record<string, string[]>
|
|
3789
|
-
}
|
|
3790
|
-
|
|
3791
|
-
/**
|
|
3792
|
-
* Plan #8 — handle a Wizard step-validate POST. Locates the form by id,
|
|
3793
|
-
* walks to the Wizard descendant, validates only the fields inside step
|
|
3794
|
-
* `step` against `values`. Returns `{ ok: true }` on success or
|
|
3795
|
-
* `{ ok: false, status: 422, errors }` when fields fail validation.
|
|
3796
|
-
*
|
|
3797
|
-
* Errors are keyed by field name, same shape as the form-submit 422 path,
|
|
3798
|
-
* so the client (`FormStateApi.applyErrors`) can surface them in-place.
|
|
3799
|
-
*/
|
|
3800
|
-
export async function formWizardData(
|
|
3801
|
-
pilotiq: Pilotiq,
|
|
3802
|
-
scope: FormStateScope,
|
|
3803
|
-
body: FormWizardRequest,
|
|
3804
|
-
req?: unknown,
|
|
3805
|
-
): Promise<FormWizardSuccess | FormWizardFailure | null> {
|
|
3806
|
-
const cfg = pilotiq.getConfig()
|
|
3807
|
-
const user = await pilotiq.resolveUser(req)
|
|
3808
|
-
|
|
3809
|
-
let PageClass: typeof Page | undefined
|
|
3810
|
-
let mode: 'create' | 'edit'
|
|
3811
|
-
let record: unknown = undefined
|
|
3812
|
-
let baseCtxExtras: Record<string, unknown> = {}
|
|
3813
|
-
|
|
3814
|
-
if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
|
|
3815
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
3816
|
-
if (!R) return null
|
|
3817
|
-
const pages = R.resolvePages()
|
|
3818
|
-
if (scope.kind === 'resource-create') {
|
|
3819
|
-
if (!pages.create) return null
|
|
3820
|
-
PageClass = pages.create
|
|
3821
|
-
mode = 'create'
|
|
3822
|
-
} else {
|
|
3823
|
-
if (!pages.edit) return null
|
|
3824
|
-
PageClass = pages.edit
|
|
3825
|
-
mode = 'edit'
|
|
3826
|
-
baseCtxExtras = { recordId: scope.recordId }
|
|
3827
|
-
if (R.model) {
|
|
3828
|
-
try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
|
|
3829
|
-
} else {
|
|
3830
|
-
record = { id: scope.recordId }
|
|
3831
|
-
}
|
|
3832
|
-
}
|
|
3833
|
-
} else if (scope.kind === 'global-edit') {
|
|
3834
|
-
const G = cfg.globals.find(g => g.getSlug() === scope.slug)
|
|
3835
|
-
if (!G) return null
|
|
3836
|
-
const pages = G.resolvePages()
|
|
3837
|
-
if (!pages.edit) return null
|
|
3838
|
-
PageClass = pages.edit
|
|
3839
|
-
mode = 'edit'
|
|
3840
|
-
} else {
|
|
3841
|
-
const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
|
|
3842
|
-
if (!P) return null
|
|
3843
|
-
PageClass = P
|
|
3844
|
-
mode = 'edit'
|
|
3845
|
-
}
|
|
3846
|
-
|
|
3847
|
-
if (!PageClass) return null
|
|
3848
|
-
|
|
3849
|
-
const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
|
|
3850
|
-
const elements = await callPageSchema(PageClass, baseCtx)
|
|
3851
|
-
const form = selectFormById(findForms(elements), body.formId)
|
|
3852
|
-
if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
|
|
3853
|
-
|
|
3854
|
-
const formChildren = form.getChildren() ?? []
|
|
3855
|
-
const step = findWizardStep(formChildren, body.step)
|
|
3856
|
-
if (!step) return { ok: false, status: 404, error: `Step ${body.step} not found on form "${body.formId}"` }
|
|
3857
|
-
|
|
3858
|
-
// Step.beforeValidation — runs before validators. May mutate `body.values`
|
|
3859
|
-
// in place (the validator reads from the same object), or throw to halt
|
|
3860
|
-
// with a 422 stamped under the reserved `_step` key.
|
|
3861
|
-
type StepHook = (values: Record<string, unknown>, ctx: { record?: unknown; user?: unknown }) => void | Promise<void>
|
|
3862
|
-
const stepHooks = step as {
|
|
3863
|
-
getBeforeValidation?: () => StepHook | undefined
|
|
3864
|
-
getAfterValidation?: () => StepHook | undefined
|
|
3865
|
-
}
|
|
3866
|
-
const beforeHook = stepHooks.getBeforeValidation?.call(step)
|
|
3867
|
-
if (beforeHook) {
|
|
3868
|
-
try { await beforeHook(body.values, { record, user }) }
|
|
3869
|
-
catch (err) {
|
|
3870
|
-
return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
|
|
3871
|
-
}
|
|
3872
|
-
}
|
|
3873
|
-
|
|
3874
|
-
const errors = await validateSchema(step.getChildren() ?? [], body.values, record)
|
|
3875
|
-
if (Object.keys(errors).length > 0) {
|
|
3876
|
-
return { ok: false, status: 422, errors }
|
|
3877
|
-
}
|
|
3878
|
-
|
|
3879
|
-
// Step.afterValidation — fires only when validators pass. Same throw →
|
|
3880
|
-
// 422 contract as beforeValidation.
|
|
3881
|
-
const afterHook = stepHooks.getAfterValidation?.call(step)
|
|
3882
|
-
if (afterHook) {
|
|
3883
|
-
try { await afterHook(body.values, { record, user }) }
|
|
3884
|
-
catch (err) {
|
|
3885
|
-
return { ok: false, status: 422, errors: { _step: [stepHookErrorMessage(err)] } }
|
|
3886
|
-
}
|
|
3887
|
-
}
|
|
3888
|
-
|
|
3889
|
-
return { ok: true }
|
|
3890
|
-
}
|
|
3891
|
-
|
|
3892
|
-
function stepHookErrorMessage(err: unknown): string {
|
|
3893
|
-
if (err instanceof Error && err.message) return err.message
|
|
3894
|
-
if (typeof err === 'string' && err.length > 0) return err
|
|
3895
|
-
return 'Step validation failed'
|
|
3896
|
-
}
|
|
3897
|
-
|
|
3898
|
-
// ─── SelectField inline-create-option data builder ───────────
|
|
3899
|
-
|
|
3900
|
-
export interface FormCreateOptionRequest {
|
|
3901
|
-
formId: string
|
|
3902
|
-
fieldName: string
|
|
3903
|
-
values: Record<string, unknown>
|
|
3904
|
-
}
|
|
3905
|
-
|
|
3906
|
-
export interface FormCreateOptionSuccess {
|
|
3907
|
-
ok: true
|
|
3908
|
-
option: { value: string; label: string }
|
|
3909
|
-
}
|
|
3910
|
-
|
|
3911
|
-
export interface FormCreateOptionFailure {
|
|
3912
|
-
ok: false
|
|
3913
|
-
status: 403 | 404 | 422 | 500
|
|
3914
|
-
error?: string
|
|
3915
|
-
errors?: Record<string, string[]>
|
|
3916
|
-
}
|
|
3917
|
-
|
|
3918
|
-
/** Find a `SelectField` by name inside a form's children, walking through
|
|
3919
|
-
* layout containers but stopping at Repeater / Builder boundaries
|
|
3920
|
-
* (parallel to `tagSelectCreateOptionUrls`'s walker). Returns the first
|
|
3921
|
-
* match or `undefined`. */
|
|
3922
|
-
function findSelectFieldByName(elements: Element[], name: string): SelectField | undefined {
|
|
3923
|
-
for (const el of elements) {
|
|
3924
|
-
if (el instanceof SelectField) {
|
|
3925
|
-
if (el.name === name) return el
|
|
3926
|
-
continue
|
|
3927
|
-
}
|
|
3928
|
-
if (el instanceof RepeaterField) continue
|
|
3929
|
-
if (el instanceof BuilderField) continue
|
|
3930
|
-
const children = el.getChildren()
|
|
3931
|
-
if (children && children.length > 0) {
|
|
3932
|
-
const found = findSelectFieldByName(children as Element[], name)
|
|
3933
|
-
if (found) return found
|
|
3934
|
-
}
|
|
3935
|
-
}
|
|
3936
|
-
return undefined
|
|
3937
|
-
}
|
|
3938
|
-
|
|
3939
|
-
/**
|
|
3940
|
-
* Audit row 2026-05-07 cont'd⁸ — handle a `SelectField.createOptionForm()`
|
|
3941
|
-
* modal submit. Locates the parent form by `formId`, finds the SelectField
|
|
3942
|
-
* by `fieldName`, re-evaluates the `createOptionAuthorize` rule (so a
|
|
3943
|
-
* tampered URL can't bypass), coerces + validates the body against the
|
|
3944
|
-
* sub-form's fields, then calls `createOptionUsing(handler)` and returns
|
|
3945
|
-
* `{ option }` for the client to append + select.
|
|
3946
|
-
*
|
|
3947
|
-
* Returns `null` when the route prefix doesn't resolve to a real
|
|
3948
|
-
* resource/global/page (route handler turns into 404).
|
|
3949
|
-
*/
|
|
3950
|
-
export async function formCreateOptionData(
|
|
3951
|
-
pilotiq: Pilotiq,
|
|
3952
|
-
scope: FormStateScope,
|
|
3953
|
-
body: FormCreateOptionRequest,
|
|
3954
|
-
req?: unknown,
|
|
3955
|
-
): Promise<FormCreateOptionSuccess | FormCreateOptionFailure | null> {
|
|
3956
|
-
const cfg = pilotiq.getConfig()
|
|
3957
|
-
const user = await pilotiq.resolveUser(req)
|
|
3958
|
-
|
|
3959
|
-
let PageClass: typeof Page | undefined
|
|
3960
|
-
let mode: 'create' | 'edit'
|
|
3961
|
-
let record: unknown = undefined
|
|
3962
|
-
let baseCtxExtras: Record<string, unknown> = {}
|
|
3963
|
-
|
|
3964
|
-
if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
|
|
3965
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
3966
|
-
if (!R) return null
|
|
3967
|
-
const pages = R.resolvePages()
|
|
3968
|
-
if (scope.kind === 'resource-create') {
|
|
3969
|
-
if (!pages.create) return null
|
|
3970
|
-
PageClass = pages.create
|
|
3971
|
-
mode = 'create'
|
|
3972
|
-
} else {
|
|
3973
|
-
if (!pages.edit) return null
|
|
3974
|
-
PageClass = pages.edit
|
|
3975
|
-
mode = 'edit'
|
|
3976
|
-
baseCtxExtras = { recordId: scope.recordId }
|
|
3977
|
-
if (R.model) {
|
|
3978
|
-
try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
|
|
3979
|
-
} else {
|
|
3980
|
-
record = { id: scope.recordId }
|
|
3981
|
-
}
|
|
3982
|
-
}
|
|
3983
|
-
} else if (scope.kind === 'global-edit') {
|
|
3984
|
-
const G = cfg.globals.find(g => g.getSlug() === scope.slug)
|
|
3985
|
-
if (!G) return null
|
|
3986
|
-
const pages = G.resolvePages()
|
|
3987
|
-
if (!pages.edit) return null
|
|
3988
|
-
PageClass = pages.edit
|
|
3989
|
-
mode = 'edit'
|
|
3990
|
-
} else {
|
|
3991
|
-
const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
|
|
3992
|
-
if (!P) return null
|
|
3993
|
-
PageClass = P
|
|
3994
|
-
mode = 'edit'
|
|
3995
|
-
}
|
|
3996
|
-
|
|
3997
|
-
if (!PageClass) return null
|
|
3998
|
-
|
|
3999
|
-
const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
|
|
4000
|
-
const elements = await callPageSchema(PageClass, baseCtx)
|
|
4001
|
-
const form = selectFormById(findForms(elements), body.formId)
|
|
4002
|
-
if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
|
|
4003
|
-
|
|
4004
|
-
const field = findSelectFieldByName(form.getChildren() as Element[] ?? [], body.fieldName)
|
|
4005
|
-
if (!field) return { ok: false, status: 404, error: `SelectField "${body.fieldName}" not found on form "${body.formId}"` }
|
|
4006
|
-
if (!field.hasCreateOption()) return { ok: false, status: 404, error: `SelectField "${body.fieldName}" does not configure createOptionForm()` }
|
|
4007
|
-
|
|
4008
|
-
const createForm = field.getCreateOptionForm()!
|
|
4009
|
-
const handler = field.getCreateOptionHandler()
|
|
4010
|
-
if (!handler) {
|
|
4011
|
-
return { ok: false, status: 500, error: `SelectField "${body.fieldName}" has createOptionForm() but no createOptionUsing() handler` }
|
|
4012
|
-
}
|
|
4013
|
-
|
|
4014
|
-
// Re-evaluate authorize. Build the same ActionVisibilityContext shape
|
|
4015
|
-
// the field's `toMeta` did — keeps server / meta-build paths consistent.
|
|
4016
|
-
const authorize = field.getCreateOptionAuthorize()
|
|
4017
|
-
if (authorize !== undefined) {
|
|
4018
|
-
const authVisible = await (async () => {
|
|
4019
|
-
if (typeof authorize !== 'function') return authorize
|
|
4020
|
-
const visCtx: import('./actions/Action.js').ActionVisibilityContext = {}
|
|
4021
|
-
if (record !== undefined) visCtx.record = record
|
|
4022
|
-
if (user !== null ) visCtx.user = user
|
|
4023
|
-
try { return await authorize(visCtx) } catch { return false }
|
|
4024
|
-
})()
|
|
4025
|
-
if (!authVisible) return { ok: false, status: 403, error: 'createOptionAuthorize denied' }
|
|
4026
|
-
}
|
|
4027
|
-
|
|
4028
|
-
// Coerce + validate body against the sub-form's fields. The createOption
|
|
4029
|
-
// sub-schema is detached from the parent form so we run it against its
|
|
4030
|
-
// own children only — coerceFormValues mutates `out` to normalize toggle
|
|
4031
|
-
// / number / date / etc. shapes (same shape parent forms use).
|
|
4032
|
-
const coerced = coerceFormValues(createForm, { ...body.values })
|
|
4033
|
-
const errors = await validateSchema(createForm, coerced, undefined)
|
|
4034
|
-
if (Object.keys(errors).length > 0) {
|
|
4035
|
-
return { ok: false, status: 422, errors }
|
|
4036
|
-
}
|
|
4037
|
-
|
|
4038
|
-
const ctx: RenderContext = {
|
|
4039
|
-
...baseCtx,
|
|
4040
|
-
values: coerced,
|
|
4041
|
-
...(record !== undefined ? { record } : {}),
|
|
4042
|
-
}
|
|
4043
|
-
let option: { value: string; label: string }
|
|
4044
|
-
try {
|
|
4045
|
-
option = await handler(coerced, ctx)
|
|
4046
|
-
} catch (e) {
|
|
4047
|
-
return { ok: false, status: 500, error: e instanceof Error ? e.message : String(e) }
|
|
4048
|
-
}
|
|
4049
|
-
|
|
4050
|
-
if (!option || typeof option.value !== 'string' || typeof option.label !== 'string') {
|
|
4051
|
-
return { ok: false, status: 500, error: `createOptionUsing must return { value: string, label: string }` }
|
|
4052
|
-
}
|
|
4053
|
-
|
|
4054
|
-
return { ok: true, option }
|
|
4055
|
-
}
|
|
4056
|
-
|
|
4057
|
-
// ─── Async-mention resolve data builder ──────────────────────
|
|
4058
|
-
|
|
4059
|
-
export interface MentionResolveRequest {
|
|
4060
|
-
formId: string
|
|
4061
|
-
field: string
|
|
4062
|
-
trigger: string
|
|
4063
|
-
query: string
|
|
4064
|
-
}
|
|
4065
|
-
|
|
4066
|
-
/** Wire-side shape for a single resolved item — mirrors `MentionItem` from
|
|
4067
|
-
* `@pilotiq/tiptap`. Pilotiq core doesn't import that package, so the
|
|
4068
|
-
* duck-typed shape lives here. */
|
|
4069
|
-
export interface MentionResolveItem {
|
|
4070
|
-
id: string
|
|
4071
|
-
label: string
|
|
4072
|
-
group?: string
|
|
4073
|
-
}
|
|
4074
|
-
|
|
4075
|
-
export interface MentionResolveSuccess {
|
|
4076
|
-
ok: true
|
|
4077
|
-
items: MentionResolveItem[]
|
|
4078
|
-
}
|
|
4079
|
-
|
|
4080
|
-
export interface MentionResolveError {
|
|
4081
|
-
ok: false
|
|
4082
|
-
status: 404 | 422
|
|
4083
|
-
error: string
|
|
4084
|
-
}
|
|
4085
|
-
|
|
4086
|
-
interface AsyncMentionResolverField {
|
|
4087
|
-
resolveMention(
|
|
4088
|
-
trigger: string,
|
|
4089
|
-
query: string,
|
|
4090
|
-
ctx: { user?: unknown; record?: unknown; request?: unknown },
|
|
4091
|
-
): Promise<MentionResolveItem[] | null>
|
|
4092
|
-
}
|
|
4093
|
-
|
|
4094
|
-
function isMentionResolverField(el: Element): el is Element & AsyncMentionResolverField {
|
|
4095
|
-
if (el.getType() !== 'richtext') return false
|
|
4096
|
-
const candidate = el as unknown as Partial<AsyncMentionResolverField>
|
|
4097
|
-
return typeof candidate.resolveMention === 'function'
|
|
4098
|
-
}
|
|
4099
|
-
|
|
4100
|
-
/**
|
|
4101
|
-
* Walk a form's tree looking for the named field. Descends into Repeater /
|
|
4102
|
-
* Builder rows when the requested name carries the row-prefix shape:
|
|
4103
|
-
*
|
|
4104
|
-
* - Repeater rows: `<repeaterName>.<index>.<innerPath>` — looks up
|
|
4105
|
-
* `<innerPath>` against the Repeater's template schema. Field config
|
|
4106
|
-
* (providers, async resolver) is shared across rows, so any row index
|
|
4107
|
-
* resolves to the same template field.
|
|
4108
|
-
* - Builder rows: `<builderName>.<index>.data.<innerPath>` — looks up
|
|
4109
|
-
* `<innerPath>` against every block's schema; first match wins. Block
|
|
4110
|
-
* schemas often share leaf names — if two blocks define a RichTextField
|
|
4111
|
-
* with the same name and different async-mention providers, only the
|
|
4112
|
-
* first block in declaration order is reachable here. Authors needing
|
|
4113
|
-
* per-block resolution should give the leaves distinct names.
|
|
4114
|
-
*
|
|
4115
|
-
* Mirrors the boundary-stopping posture of `findFieldByName` inside
|
|
4116
|
-
* `dispatchForm.ts` for top-level matches — only the dotted-prefix branch
|
|
4117
|
-
* crosses into row schemas.
|
|
4118
|
-
*/
|
|
4119
|
-
function findRichTextFieldByName(
|
|
4120
|
-
elements: ReadonlyArray<Element>,
|
|
4121
|
-
name: string,
|
|
4122
|
-
): (Element & AsyncMentionResolverField) | undefined {
|
|
4123
|
-
for (const el of elements) {
|
|
4124
|
-
if (isMentionResolverField(el) && (el as unknown as { name: string }).name === name) {
|
|
4125
|
-
return el
|
|
4126
|
-
}
|
|
4127
|
-
if (isRepeaterField(el)) {
|
|
4128
|
-
const inner = stripRepeaterRowPrefix(name, (el as RepeaterField).name)
|
|
4129
|
-
if (inner !== undefined) {
|
|
4130
|
-
const hit = findRichTextFieldByName((el as RepeaterField).getInnerSchema(), inner)
|
|
4131
|
-
if (hit) return hit
|
|
4132
|
-
}
|
|
4133
|
-
continue
|
|
4134
|
-
}
|
|
4135
|
-
if (isBuilderField(el)) {
|
|
4136
|
-
const inner = stripBuilderRowPrefix(name, (el as BuilderField).name)
|
|
4137
|
-
if (inner !== undefined) {
|
|
4138
|
-
for (const block of (el as BuilderField).getBlocks()) {
|
|
4139
|
-
const hit = findRichTextFieldByName(block.getSchema(), inner)
|
|
4140
|
-
if (hit) return hit
|
|
4141
|
-
}
|
|
4142
|
-
}
|
|
4143
|
-
continue
|
|
4144
|
-
}
|
|
4145
|
-
const children = el.getChildren()
|
|
4146
|
-
if (children && children.length > 0) {
|
|
4147
|
-
const hit = findRichTextFieldByName(children, name)
|
|
4148
|
-
if (hit) return hit
|
|
4149
|
-
}
|
|
4150
|
-
}
|
|
4151
|
-
return undefined
|
|
4152
|
-
}
|
|
4153
|
-
|
|
4154
|
-
/**
|
|
4155
|
-
* `items.0.body` → `body`. Returns `undefined` when the path doesn't match
|
|
4156
|
-
* the `<repeaterName>.<digits>.<rest>` shape so the walker keeps searching
|
|
4157
|
-
* other branches instead of misinterpreting an unrelated dotted name.
|
|
4158
|
-
*/
|
|
4159
|
-
function stripRepeaterRowPrefix(path: string, repeaterName: string): string | undefined {
|
|
4160
|
-
const parts = path.split('.')
|
|
4161
|
-
if (parts.length < 3) return undefined
|
|
4162
|
-
if (parts[0] !== repeaterName) return undefined
|
|
4163
|
-
if (!/^\d+$/.test(parts[1] ?? '')) return undefined
|
|
4164
|
-
return parts.slice(2).join('.')
|
|
4165
|
-
}
|
|
4166
|
-
|
|
4167
|
-
/**
|
|
4168
|
-
* `blocks.0.data.heading` → `heading`. The literal `data` segment matches
|
|
4169
|
-
* Builder's wire shape (`{ __id, type, data: {…} }`) and distinguishes a
|
|
4170
|
-
* Builder leaf from a Repeater leaf at the same depth.
|
|
4171
|
-
*/
|
|
4172
|
-
function stripBuilderRowPrefix(path: string, builderName: string): string | undefined {
|
|
4173
|
-
const parts = path.split('.')
|
|
4174
|
-
if (parts.length < 4) return undefined
|
|
4175
|
-
if (parts[0] !== builderName) return undefined
|
|
4176
|
-
if (!/^\d+$/.test(parts[1] ?? '')) return undefined
|
|
4177
|
-
if (parts[2] !== 'data') return undefined
|
|
4178
|
-
return parts.slice(3).join('.')
|
|
4179
|
-
}
|
|
4180
|
-
|
|
4181
|
-
/**
|
|
4182
|
-
* Resolve one async-mention round-trip. Locates the page's schema, finds
|
|
4183
|
-
* the form by `formId` and the RichTextField by `field`, calls its
|
|
4184
|
-
* `resolveMention(trigger, query, ctx)`. Returns `{ ok, items }`, a 404
|
|
4185
|
-
* when the form / field / trigger isn't present, or `null` for a missing
|
|
4186
|
-
* page (the route handler turns `null` into a 404 too).
|
|
4187
|
-
*
|
|
4188
|
-
* The dispatcher is duck-typed against the contract in `@pilotiq/tiptap`'s
|
|
4189
|
-
* `RichTextField` — pilotiq core never imports the adapter. Any future
|
|
4190
|
-
* field-type that ships an async-resolve trigger can implement the same
|
|
4191
|
-
* shape and pick up routing for free.
|
|
4192
|
-
*/
|
|
4193
|
-
export async function mentionResolveData(
|
|
4194
|
-
pilotiq: Pilotiq,
|
|
4195
|
-
scope: FormStateScope,
|
|
4196
|
-
body: MentionResolveRequest,
|
|
4197
|
-
req?: unknown,
|
|
4198
|
-
): Promise<MentionResolveSuccess | MentionResolveError | null> {
|
|
4199
|
-
const cfg = pilotiq.getConfig()
|
|
4200
|
-
const user = await pilotiq.resolveUser(req)
|
|
4201
|
-
|
|
4202
|
-
let PageClass: typeof Page | undefined
|
|
4203
|
-
let mode: 'create' | 'edit'
|
|
4204
|
-
let record: unknown = undefined
|
|
4205
|
-
let baseCtxExtras: Record<string, unknown> = {}
|
|
4206
|
-
|
|
4207
|
-
if (scope.kind === 'resource-create' || scope.kind === 'resource-edit') {
|
|
4208
|
-
const R = cfg.resources.find(r => r.getSlug() === scope.slug)
|
|
4209
|
-
if (!R) return null
|
|
4210
|
-
const pages = R.resolvePages()
|
|
4211
|
-
if (scope.kind === 'resource-create') {
|
|
4212
|
-
if (!pages.create) return null
|
|
4213
|
-
PageClass = pages.create
|
|
4214
|
-
mode = 'create'
|
|
4215
|
-
} else {
|
|
4216
|
-
if (!pages.edit) return null
|
|
4217
|
-
PageClass = pages.edit
|
|
4218
|
-
mode = 'edit'
|
|
4219
|
-
baseCtxExtras = { recordId: scope.recordId }
|
|
4220
|
-
if (R.model) {
|
|
4221
|
-
try { record = await findRecord(R, scope.recordId, { user }) } catch { /* ignore */ }
|
|
4222
|
-
} else {
|
|
4223
|
-
record = { id: scope.recordId }
|
|
4224
|
-
}
|
|
4225
|
-
}
|
|
4226
|
-
} else if (scope.kind === 'global-edit') {
|
|
4227
|
-
const G = cfg.globals.find(g => g.getSlug() === scope.slug)
|
|
4228
|
-
if (!G) return null
|
|
4229
|
-
const pages = G.resolvePages()
|
|
4230
|
-
if (!pages.edit) return null
|
|
4231
|
-
PageClass = pages.edit
|
|
4232
|
-
mode = 'edit'
|
|
4233
|
-
} else {
|
|
4234
|
-
const P = cfg.pages.find(p => p.getSlug() === scope.pageSlug)
|
|
4235
|
-
if (!P) return null
|
|
4236
|
-
PageClass = P
|
|
4237
|
-
mode = 'edit'
|
|
4238
|
-
}
|
|
4239
|
-
|
|
4240
|
-
if (!PageClass) return null
|
|
4241
|
-
|
|
4242
|
-
const baseCtx: SchemaContext = uploadCtx(userCtx({ mode, basePath: cfg.path, ...baseCtxExtras }, user), cfg)
|
|
4243
|
-
const elements = await callPageSchema(PageClass, baseCtx)
|
|
4244
|
-
const form = selectFormById(findForms(elements), body.formId)
|
|
4245
|
-
if (!form) return { ok: false, status: 404, error: `Form "${body.formId}" not found on page` }
|
|
4246
|
-
|
|
4247
|
-
const field = findRichTextFieldByName(form.getChildren() ?? [], body.field)
|
|
4248
|
-
if (!field) {
|
|
4249
|
-
return { ok: false, status: 404, error: `Rich-text field "${body.field}" not found on form "${body.formId}"` }
|
|
4250
|
-
}
|
|
4251
|
-
|
|
4252
|
-
let items: MentionResolveItem[] | null
|
|
4253
|
-
try {
|
|
4254
|
-
items = await field.resolveMention(body.trigger, body.query, {
|
|
4255
|
-
...(record !== undefined ? { record } : {}),
|
|
4256
|
-
...(user !== null ? { user } : {}),
|
|
4257
|
-
request: req,
|
|
4258
|
-
})
|
|
4259
|
-
} catch (err) {
|
|
4260
|
-
return {
|
|
4261
|
-
ok: false,
|
|
4262
|
-
status: 422,
|
|
4263
|
-
error: err instanceof Error ? err.message : 'Mention resolver threw',
|
|
4264
|
-
}
|
|
4265
|
-
}
|
|
4266
|
-
|
|
4267
|
-
if (items === null) {
|
|
4268
|
-
return { ok: false, status: 404, error: `No mention provider for trigger "${body.trigger}" on field "${body.field}"` }
|
|
4269
|
-
}
|
|
4270
|
-
|
|
4271
|
-
return { ok: true, items }
|
|
4272
|
-
}
|
|
4273
|
-
|
|
4274
|
-
export async function resourceViewData(
|
|
4275
|
-
pilotiq: Pilotiq,
|
|
4276
|
-
slug: string,
|
|
4277
|
-
recordId: string,
|
|
4278
|
-
req?: unknown,
|
|
4279
|
-
): Promise<Record<string, unknown> | null> {
|
|
4280
|
-
const cfg = pilotiq.getConfig()
|
|
4281
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
4282
|
-
if (!R) return null
|
|
4283
|
-
const pages = R.resolvePages()
|
|
4284
|
-
if (!pages.view) return null
|
|
4285
|
-
const PageClass = pages.view
|
|
4286
|
-
|
|
4287
|
-
const user = await pilotiq.resolveUser(req)
|
|
4288
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
|
|
4289
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
4290
|
-
// For the view page we want the record threaded into resolveSchema so
|
|
4291
|
-
// factory-attached visibility predicates see it. Resource.detail()
|
|
4292
|
-
// already runs against the loaded record in user code; here we mirror
|
|
4293
|
-
// that into ctx.record for the action eval pass.
|
|
4294
|
-
let record: unknown = undefined
|
|
4295
|
-
if (R.model) {
|
|
4296
|
-
try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
|
|
4297
|
-
}
|
|
4298
|
-
|
|
4299
|
-
// Plan #11 — prepend the relation tabs strip with the "Details" tab
|
|
4300
|
-
// active when the resource has relation managers configured.
|
|
4301
|
-
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, '__view', user, record)
|
|
4302
|
-
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
4303
|
-
|
|
4304
|
-
const recordTitle = record !== undefined && record !== null
|
|
4305
|
-
? deriveParentTitle(R, record)
|
|
4306
|
-
: recordId
|
|
4307
|
-
const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
|
|
4308
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4309
|
-
|
|
4310
|
-
const viewRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
|
|
4311
|
-
const schemaData = await applyRoleHooks(
|
|
4312
|
-
pilotiq, user, 'view',
|
|
4313
|
-
await resolveSchema(
|
|
4314
|
-
elements,
|
|
4315
|
-
record !== undefined ? { ...ctx, record } : ctx,
|
|
4316
|
-
),
|
|
4317
|
-
viewRoute,
|
|
4318
|
-
)
|
|
4319
|
-
|
|
4320
|
-
return {
|
|
4321
|
-
panel: await panelInfo(pilotiq, req, viewRoute),
|
|
4322
|
-
page: PageClass.toMeta(),
|
|
4323
|
-
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
4324
|
-
mode: 'view' as const,
|
|
4325
|
-
recordId,
|
|
4326
|
-
basePath: cfg.path,
|
|
4327
|
-
layout: cfg.layout,
|
|
4328
|
-
schemaData,
|
|
4329
|
-
notifications: consumeFlashedNotifications(req),
|
|
4330
|
-
}
|
|
4331
|
-
}
|
|
4332
|
-
|
|
4333
|
-
/**
|
|
4334
|
-
* Custom record sub-page data builder. Mounted at
|
|
4335
|
-
* `${resourceBase}/${slug}/:id/${subPageSlug}` for each entry in
|
|
4336
|
-
* `Resource.pages().record`. Mirrors `resourceViewData`'s shape: load
|
|
4337
|
-
* the record, run R.canAccess + R.canView (parent-resource gates),
|
|
4338
|
-
* then SubPage.canAccess(user, record) (sub-page-specific gate),
|
|
4339
|
-
* then render the sub-page's schema with `ctx.record` set. Tab strip
|
|
4340
|
-
* carries the sub-page slug as the active key so the matching record
|
|
4341
|
-
* sub-page tab highlights.
|
|
4342
|
-
*
|
|
4343
|
-
* Returns:
|
|
4344
|
-
* - `null` — resource / sub-page slug not found (404 upstream).
|
|
4345
|
-
* - `{ ok: false, status: 403 }` — any gate fails or throws.
|
|
4346
|
-
* - resolved page data — on success.
|
|
4347
|
-
*/
|
|
4348
|
-
export async function resourceRecordPageData(
|
|
4349
|
-
pilotiq: Pilotiq,
|
|
4350
|
-
slug: string,
|
|
4351
|
-
recordId: string,
|
|
4352
|
-
subPageSlug: string,
|
|
4353
|
-
req?: unknown,
|
|
4354
|
-
): Promise<Record<string, unknown> | null | { ok: false; status: 403 }> {
|
|
4355
|
-
const cfg = pilotiq.getConfig()
|
|
4356
|
-
const R = cfg.resources.find(r => r.getSlug() === slug)
|
|
4357
|
-
if (!R) return null
|
|
4358
|
-
const recordPages = R.getRecordPages()
|
|
4359
|
-
const PageClass = recordPages[subPageSlug]
|
|
4360
|
-
if (!PageClass) return null
|
|
4361
|
-
|
|
4362
|
-
const user = await pilotiq.resolveUser(req)
|
|
4363
|
-
|
|
4364
|
-
// Load the parent record before gating so canView / SubPage.canAccess
|
|
4365
|
-
// can branch on record state. Sub-pages without a Resource.model
|
|
4366
|
-
// still get gated against an `undefined` record — the same posture as
|
|
4367
|
-
// resourceViewData when no model is bound.
|
|
4368
|
-
let record: unknown = undefined
|
|
4369
|
-
if (R.model) {
|
|
4370
|
-
try { record = await findRecord(R, recordId, { user }) } catch { /* ignore */ }
|
|
4371
|
-
}
|
|
4372
|
-
if (record === undefined || record === null) {
|
|
4373
|
-
// Distinguish "model bound but record missing" (route should 404)
|
|
4374
|
-
// from "no model bound" (treat record as `{ id: recordId }` so the
|
|
4375
|
-
// page can still render — same convention as the edit page).
|
|
4376
|
-
if (R.model) return null
|
|
4377
|
-
record = { id: recordId }
|
|
4378
|
-
}
|
|
4379
|
-
|
|
4380
|
-
// Three gates: parent resource access + view, then the sub-page's own
|
|
4381
|
-
// canAccess. The route would have run R.canAccess upstream, but
|
|
4382
|
-
// re-running here makes resourceRecordPageData safe to call from
|
|
4383
|
-
// dispatchPageData (where the SPA path skips the route prelude).
|
|
4384
|
-
if (!await safeBool(() => R.canAccess(user))) return { ok: false, status: 403 }
|
|
4385
|
-
if (!await safeBool(() => R.canView(user, record))) return { ok: false, status: 403 }
|
|
4386
|
-
if (!await safeBool(() => PageClass.canAccess(user, record))) return { ok: false, status: 403 }
|
|
4387
|
-
|
|
4388
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', recordId, basePath: cfg.path }, user), cfg)
|
|
4389
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
4390
|
-
|
|
4391
|
-
// Insert the relation-tabs strip with the sub-page slug active so the
|
|
4392
|
-
// matching tab highlights. `buildRelationTabs` evaluates per-tab
|
|
4393
|
-
// gating against `user + record` — record sub-page tabs are gated
|
|
4394
|
-
// alongside __view/__edit/managers.
|
|
4395
|
-
const relationTabsEl = await buildRelationTabs(R, recordId, cfg.path, subPageSlug, user, record)
|
|
4396
|
-
if (relationTabsEl) elements.unshift(relationTabsEl)
|
|
4397
|
-
|
|
4398
|
-
const recordTitle = record !== undefined && record !== null
|
|
4399
|
-
? deriveParentTitle(R, record)
|
|
4400
|
-
: recordId
|
|
4401
|
-
const breadcrumbs = resourceViewBreadcrumbs(cfg, R, recordTitle)
|
|
4402
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4403
|
-
|
|
4404
|
-
const recordPageRoute: PanelInfoRoute = { resource: R, page: PageClass, recordId }
|
|
4405
|
-
const schemaData = await applyRoleHooks(
|
|
4406
|
-
pilotiq, user, 'view',
|
|
4407
|
-
await resolveSchema(
|
|
4408
|
-
elements,
|
|
4409
|
-
record !== undefined ? { ...ctx, record } : ctx,
|
|
4410
|
-
),
|
|
4411
|
-
recordPageRoute,
|
|
4412
|
-
)
|
|
4413
|
-
|
|
4414
|
-
return {
|
|
4415
|
-
pageType: 'record-page' as const,
|
|
4416
|
-
panel: await panelInfo(pilotiq, req, recordPageRoute),
|
|
4417
|
-
page: PageClass.toMeta(),
|
|
4418
|
-
resource: { name: R.name, label: R.labelSingular, slug, icon: serializeIcon(R.icon, R.name) },
|
|
4419
|
-
mode: 'record' as const,
|
|
4420
|
-
recordId,
|
|
4421
|
-
subPage: { slug: subPageSlug, label: PageClass.getLabel() },
|
|
4422
|
-
basePath: cfg.path,
|
|
4423
|
-
layout: cfg.layout,
|
|
4424
|
-
schemaData,
|
|
4425
|
-
notifications: consumeFlashedNotifications(req),
|
|
4426
|
-
}
|
|
4427
|
-
}
|
|
4428
|
-
|
|
4429
|
-
export async function globalEditData(
|
|
4430
|
-
pilotiq: Pilotiq,
|
|
4431
|
-
slug: string,
|
|
4432
|
-
prefill?: { values?: Record<string, unknown>; errors?: Record<string, string[]> },
|
|
4433
|
-
req?: unknown,
|
|
4434
|
-
): Promise<Record<string, unknown> | null> {
|
|
4435
|
-
const cfg = pilotiq.getConfig()
|
|
4436
|
-
const G = cfg.globals.find(g => g.getSlug() === slug)
|
|
4437
|
-
if (!G) return null
|
|
4438
|
-
const pages = G.resolvePages()
|
|
4439
|
-
if (!pages.edit) return null
|
|
4440
|
-
const PageClass = pages.edit
|
|
4441
|
-
|
|
4442
|
-
const editUrl = globalBasePath(cfg.path, G)
|
|
4443
|
-
const user = await pilotiq.resolveUser(req)
|
|
4444
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'edit', basePath: cfg.path }, user), cfg)
|
|
4445
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
4446
|
-
tagFormActions(elements, editUrl)
|
|
4447
|
-
tagFormStateUrls(elements, formId => `${editUrl}/_form/${formId}/state`)
|
|
4448
|
-
tagFormWizardUrls(elements, formId => `${editUrl}/_form/${formId}/wizard`)
|
|
4449
|
-
tagRichTextMentionUrls(elements, formId => `${editUrl}/_form/${formId}/mentions`)
|
|
4450
|
-
tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${editUrl}/_form/${formId}/create-option/${fieldName}`)
|
|
4451
|
-
|
|
4452
|
-
const form = findForms(elements)[0]
|
|
4453
|
-
let record: unknown = undefined
|
|
4454
|
-
if (form?.getLoadRecord()) {
|
|
4455
|
-
try { record = await form.getLoadRecord()!('', { values: prefill?.values ?? {} }) } catch { /* ignore */ }
|
|
4456
|
-
if (!prefill?.values && record != null) {
|
|
4457
|
-
const values = await applyFillPipeline(form, record)
|
|
4458
|
-
form.withValues(values)
|
|
4459
|
-
} else if (prefill?.values) {
|
|
4460
|
-
form.withValues(prefill.values)
|
|
4461
|
-
}
|
|
4462
|
-
if (prefill?.errors) form.withErrors(prefill.errors)
|
|
4463
|
-
}
|
|
4464
|
-
|
|
4465
|
-
const breadcrumbs = globalBreadcrumbs(cfg, G)
|
|
4466
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4467
|
-
|
|
4468
|
-
const globalEditRoute: PanelInfoRoute = { global: G, page: PageClass }
|
|
4469
|
-
const schemaData = await applyRoleHooks(
|
|
4470
|
-
pilotiq, user, 'global-edit',
|
|
4471
|
-
await resolveSchema(
|
|
4472
|
-
elements,
|
|
4473
|
-
record !== undefined ? { ...ctx, record } : ctx,
|
|
4474
|
-
),
|
|
4475
|
-
globalEditRoute,
|
|
4476
|
-
)
|
|
4477
|
-
|
|
4478
|
-
return {
|
|
4479
|
-
pageType: 'global',
|
|
4480
|
-
panel: await panelInfo(pilotiq, req, globalEditRoute),
|
|
4481
|
-
page: PageClass.toMeta(),
|
|
4482
|
-
global: { name: G.name, label: G.label, labelSingular: G.labelSingular, slug, icon: serializeIcon(G.icon, G.name) },
|
|
4483
|
-
basePath: cfg.path,
|
|
4484
|
-
layout: cfg.layout,
|
|
4485
|
-
schemaData,
|
|
4486
|
-
notifications: consumeFlashedNotifications(req),
|
|
4487
|
-
...(prefill?.errors ? { hasErrors: true } : {}),
|
|
4488
|
-
}
|
|
4489
|
-
}
|
|
4490
|
-
|
|
4491
|
-
export async function globalViewData(
|
|
4492
|
-
pilotiq: Pilotiq,
|
|
4493
|
-
slug: string,
|
|
4494
|
-
req?: unknown,
|
|
4495
|
-
): Promise<Record<string, unknown> | null> {
|
|
4496
|
-
const cfg = pilotiq.getConfig()
|
|
4497
|
-
const G = cfg.globals.find(g => g.getSlug() === slug)
|
|
4498
|
-
if (!G) return null
|
|
4499
|
-
const pages = G.resolvePages()
|
|
4500
|
-
if (!pages.view) return null
|
|
4501
|
-
const PageClass = pages.view
|
|
4502
|
-
|
|
4503
|
-
const user = await pilotiq.resolveUser(req)
|
|
4504
|
-
const ctx: SchemaContext = uploadCtx(userCtx({ mode: 'view', basePath: cfg.path }, user), cfg)
|
|
4505
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
4506
|
-
|
|
4507
|
-
const breadcrumbs = globalBreadcrumbs(cfg, G)
|
|
4508
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4509
|
-
|
|
4510
|
-
const globalViewRoute: PanelInfoRoute = { global: G, page: PageClass }
|
|
4511
|
-
const schemaData = await applyRoleHooks(
|
|
4512
|
-
pilotiq, user, 'global-view',
|
|
4513
|
-
await resolveSchema(elements, ctx),
|
|
4514
|
-
globalViewRoute,
|
|
4515
|
-
)
|
|
4516
|
-
|
|
4517
|
-
return {
|
|
4518
|
-
panel: await panelInfo(pilotiq, req, globalViewRoute),
|
|
4519
|
-
page: PageClass.toMeta(),
|
|
4520
|
-
global: { name: G.name, label: G.label, labelSingular: G.labelSingular, slug, icon: serializeIcon(G.icon, G.name) },
|
|
4521
|
-
basePath: cfg.path,
|
|
4522
|
-
layout: cfg.layout,
|
|
4523
|
-
schemaData,
|
|
4524
|
-
notifications: consumeFlashedNotifications(req),
|
|
4525
|
-
}
|
|
4526
|
-
}
|
|
4527
|
-
|
|
4528
|
-
export async function customPageData(
|
|
4529
|
-
pilotiq: Pilotiq,
|
|
4530
|
-
pageSlug: string,
|
|
4531
|
-
req?: unknown,
|
|
4532
|
-
): Promise<Record<string, unknown> | null> {
|
|
4533
|
-
const cfg = pilotiq.getConfig()
|
|
4534
|
-
const PageClass = cfg.pages.find(P => P.getSlug() === pageSlug)
|
|
4535
|
-
if (!PageClass) return null
|
|
4536
|
-
|
|
4537
|
-
const pageUrl = pageBasePath(cfg.path, PageClass)
|
|
4538
|
-
const user = await pilotiq.resolveUser(req)
|
|
4539
|
-
const ctx: SchemaContext = uploadCtx(userCtx({}, user), cfg)
|
|
4540
|
-
const elements = await callPageSchema(PageClass, ctx)
|
|
4541
|
-
tagFormActions(elements, pageUrl)
|
|
4542
|
-
tagFormStateUrls(elements, formId => `${pageUrl}/_form/${formId}/state`)
|
|
4543
|
-
tagFormWizardUrls(elements, formId => `${pageUrl}/_form/${formId}/wizard`)
|
|
4544
|
-
tagRichTextMentionUrls(elements, formId => `${pageUrl}/_form/${formId}/mentions`)
|
|
4545
|
-
tagSelectCreateOptionUrls(elements, (formId, fieldName) => `${pageUrl}/_form/${formId}/create-option/${fieldName}`)
|
|
4546
|
-
tagActionDispatch(elements, pageUrl)
|
|
4547
|
-
// Page-scope polling URL (mirrors `${base}/${pageSlug}/_widget/:id`
|
|
4548
|
-
// route registered in routes.ts).
|
|
4549
|
-
tagWidgetUrls(elements, id => `${pageUrl}/_widget/${id}`)
|
|
4550
|
-
const widgetData = await resolveServerDataElements(elements, ctx)
|
|
4551
|
-
|
|
4552
|
-
const breadcrumbs = customPageBreadcrumbs(cfg, PageClass)
|
|
4553
|
-
if (breadcrumbs) elements.unshift(breadcrumbs)
|
|
4554
|
-
|
|
4555
|
-
const customRoute: PanelInfoRoute = { page: PageClass }
|
|
4556
|
-
const schemaData = await applyRoleHooks(
|
|
4557
|
-
pilotiq, user, 'page',
|
|
4558
|
-
await resolveSchema(elements, ctx),
|
|
4559
|
-
customRoute,
|
|
4560
|
-
)
|
|
4561
|
-
|
|
4562
|
-
return {
|
|
4563
|
-
pageType: 'page',
|
|
4564
|
-
panel: await panelInfo(pilotiq, req, customRoute),
|
|
4565
|
-
page: PageClass.toMeta(),
|
|
4566
|
-
schemaData,
|
|
4567
|
-
_widgetData: widgetData,
|
|
4568
|
-
basePath: cfg.path,
|
|
4569
|
-
layout: cfg.layout,
|
|
4570
|
-
notifications: consumeFlashedNotifications(req),
|
|
4571
|
-
}
|
|
4572
|
-
}
|
|
4573
|
-
|
|
4574
|
-
// ─── Plan #15 widget polling data builder ────────────────────
|
|
4575
|
-
|
|
4576
|
-
/**
|
|
4577
|
-
* Scopes the polling endpoint resolves against. Mirrors the
|
|
4578
|
-
* form-state / wizard scope discriminator.
|
|
4579
|
-
*
|
|
4580
|
-
* panel: dashboard page (`POST {base}/_widget/:id`)
|
|
4581
|
-
* page: custom page (`POST {base}/{pageSlug}/_widget/:id`)
|
|
4582
|
-
* resource: list page (`POST {base}/{slug}/_widget/:id`) —
|
|
4583
|
-
* resolves the resource's index `Page.schema()` so widgets
|
|
4584
|
-
* from `Resource.headerSchema()` / `footerSchema()` are
|
|
4585
|
-
* reachable. Auth runs `R.canAccess + R.canViewAny` in
|
|
4586
|
-
* front of the per-widget visibility check.
|
|
4587
|
-
*/
|
|
4588
|
-
export type WidgetScope =
|
|
4589
|
-
| { kind: 'panel' }
|
|
4590
|
-
| { kind: 'page'; pageSlug: string }
|
|
4591
|
-
| { kind: 'resource'; slug: string }
|
|
4592
|
-
|
|
4593
|
-
export interface WidgetRequest {
|
|
4594
|
-
id: string
|
|
4595
|
-
filter?: string
|
|
4596
|
-
}
|
|
14
|
+
// Re-export `RelationChainStep` so external callsites importing it via
|
|
15
|
+
// `./pageData.js` keep working.
|
|
16
|
+
export type { RelationChainStep } from './pageData/breadcrumbs.js'
|
|
17
|
+
|
|
18
|
+
// Re-export `ServerDataMap` so external imports via `./pageData.js` keep
|
|
19
|
+
// working — the type is also surfaced from `packages/pilotiq/src/index.ts`.
|
|
20
|
+
export type { ServerDataMap } from './pageData/helpers.js'
|
|
21
|
+
|
|
22
|
+
// Re-export the URL-tag helpers + fill pipeline + server-data resolver
|
|
23
|
+
// for consumers that import them through `./pageData.js`.
|
|
24
|
+
export {
|
|
25
|
+
applyFillPipeline,
|
|
26
|
+
applyRelationshipBuilderFill,
|
|
27
|
+
applyRelationshipRepeaterFill,
|
|
28
|
+
callPageSchema,
|
|
29
|
+
resolveServerDataElements,
|
|
30
|
+
tagActionDispatch,
|
|
31
|
+
tagCellEditUrls,
|
|
32
|
+
tagFieldAiUrls,
|
|
33
|
+
tagFormActions,
|
|
34
|
+
tagFormStateUrls,
|
|
35
|
+
tagFormWizardUrls,
|
|
36
|
+
tagRichTextMentionUrls,
|
|
37
|
+
tagSelectCreateOptionUrls,
|
|
38
|
+
tagTableDeferred,
|
|
39
|
+
tagTableReorderUrls,
|
|
40
|
+
tagWidgetUrls,
|
|
41
|
+
} from './pageData/helpers.js'
|
|
42
|
+
|
|
43
|
+
// Re-export navigation chrome surface so external callsites importing
|
|
44
|
+
// it via `./pageData.js` keep working (e.g. routes/test harnesses).
|
|
45
|
+
export type {
|
|
46
|
+
DatabaseNotificationsMeta,
|
|
47
|
+
NavItem,
|
|
48
|
+
PanelInfoRoute,
|
|
49
|
+
RightPanelMeta,
|
|
50
|
+
RightSidebarMeta,
|
|
51
|
+
UserMenuMeta,
|
|
52
|
+
} from './pageData/navigation.js'
|
|
53
|
+
export {
|
|
54
|
+
applyRoleHooks,
|
|
55
|
+
panelInfo,
|
|
56
|
+
resolvePageHooks,
|
|
57
|
+
} from './pageData/navigation.js'
|
|
4597
58
|
|
|
4598
|
-
|
|
4599
|
-
|
|
4600
|
-
|
|
4601
|
-
|
|
4602
|
-
|
|
4603
|
-
|
|
4604
|
-
|
|
4605
|
-
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
4615
|
-
|
|
4616
|
-
|
|
4617
|
-
|
|
4618
|
-
|
|
4619
|
-
|
|
4620
|
-
|
|
4621
|
-
|
|
4622
|
-
|
|
4623
|
-
|
|
4624
|
-
export
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
|
|
4630
|
-
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4640
|
-
|
|
4641
|
-
|
|
4642
|
-
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
|
|
4651
|
-
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4655
|
-
|
|
4656
|
-
// Stamp the request's filter onto the render context so widget hooks
|
|
4657
|
-
// can branch on it. Opaque string — widgets decode their own format.
|
|
4658
|
-
if (body.filter !== undefined) ctx = { ...ctx, filter: body.filter } as RenderContext
|
|
4659
|
-
|
|
4660
|
-
const widget = findWidgetById(elements, body.id)
|
|
4661
|
-
if (!widget) return { ok: false, status: 404, error: `Widget "${body.id}" not found` }
|
|
4662
|
-
|
|
4663
|
-
// Layout-level visibility re-check — if the widget is hidden by a
|
|
4664
|
-
// visible(rule), refuse to ship data. Same fail-closed posture as
|
|
4665
|
-
// the schema resolver. (Parent-container `visible(false)` would
|
|
4666
|
-
// already drop the widget from the schema tree at SSR time, so a
|
|
4667
|
-
// direct hidden-widget probe here covers the visible-rule-only case.)
|
|
4668
|
-
const layoutCtx: import('./schema/Element.js').LayoutContext = {}
|
|
4669
|
-
if (user !== null && user !== undefined) layoutCtx.user = user
|
|
4670
|
-
if (!await widget.evaluateVisibility(layoutCtx)) {
|
|
4671
|
-
return { ok: false, status: 403, error: 'Widget hidden' }
|
|
4672
|
-
}
|
|
4673
|
-
|
|
4674
|
-
try {
|
|
4675
|
-
const data = await widget.resolveServerData(ctx)
|
|
4676
|
-
return { ok: true, data, timestamp: Date.now() }
|
|
4677
|
-
} catch (err) {
|
|
4678
|
-
return {
|
|
4679
|
-
ok: false,
|
|
4680
|
-
status: 500,
|
|
4681
|
-
error: err instanceof Error ? err.message : 'Widget failed',
|
|
4682
|
-
}
|
|
4683
|
-
}
|
|
4684
|
-
}
|
|
4685
|
-
|
|
4686
|
-
/** Walk the element tree looking for a server-data element with the
|
|
4687
|
-
* given id. Same walker as `collectServerDataElements` but stops on
|
|
4688
|
-
* first match. */
|
|
4689
|
-
function findWidgetById(elements: ReadonlyArray<Element>, id: string): ServerDataElement | undefined {
|
|
4690
|
-
let found: ServerDataElement | undefined
|
|
4691
|
-
const walk = (els: ReadonlyArray<Element>): void => {
|
|
4692
|
-
for (const el of els) {
|
|
4693
|
-
if (found) return
|
|
4694
|
-
if (isServerDataElement(el)) {
|
|
4695
|
-
if (el.getId() === id) { found = el; return }
|
|
4696
|
-
continue
|
|
4697
|
-
}
|
|
4698
|
-
const type = el.getType()
|
|
4699
|
-
if (type === 'form' || type === 'repeater' || type === 'builder' || type === 'table' || type === 'tableWidget') continue
|
|
4700
|
-
const children = el.getChildren()
|
|
4701
|
-
if (children) walk(children)
|
|
4702
|
-
}
|
|
4703
|
-
}
|
|
4704
|
-
walk(elements)
|
|
4705
|
-
return found
|
|
4706
|
-
}
|
|
59
|
+
import {
|
|
60
|
+
dashboardData,
|
|
61
|
+
resourceCreateData,
|
|
62
|
+
resourceEditData,
|
|
63
|
+
resourceIndexData,
|
|
64
|
+
resourceRecordPageData,
|
|
65
|
+
resourceViewData,
|
|
66
|
+
} from './pageData/resourcePages.js'
|
|
67
|
+
|
|
68
|
+
// Re-export resource page builders so external callsites importing
|
|
69
|
+
// through `./pageData.js` keep working (e.g. routes.ts handlers, tests).
|
|
70
|
+
export {
|
|
71
|
+
dashboardData,
|
|
72
|
+
resolveActiveTab,
|
|
73
|
+
resourceCreateData,
|
|
74
|
+
resourceEditData,
|
|
75
|
+
resourceIndexData,
|
|
76
|
+
resourceRecordPageData,
|
|
77
|
+
resourceTableData,
|
|
78
|
+
resourceViewData,
|
|
79
|
+
} from './pageData/resourcePages.js'
|
|
80
|
+
|
|
81
|
+
import { relationManagerData } from './pageData/relationPages.js'
|
|
82
|
+
|
|
83
|
+
// Re-export relation manager builder surface for external consumers
|
|
84
|
+
// (routes.ts dispatches every relation-* role through these).
|
|
85
|
+
export type {
|
|
86
|
+
RelationManagerResult,
|
|
87
|
+
RelationManagerScope,
|
|
88
|
+
ResolvedChain,
|
|
89
|
+
} from './pageData/relationPages.js'
|
|
90
|
+
export {
|
|
91
|
+
findRelatedResource,
|
|
92
|
+
relationManagerData,
|
|
93
|
+
resolveRelationChain,
|
|
94
|
+
safeManagerPolicy,
|
|
95
|
+
} from './pageData/relationPages.js'
|
|
96
|
+
|
|
97
|
+
// Re-export form-related builders + types for external callsites.
|
|
98
|
+
export type {
|
|
99
|
+
FormCreateOptionFailure,
|
|
100
|
+
FormCreateOptionRequest,
|
|
101
|
+
FormCreateOptionSuccess,
|
|
102
|
+
FormStateError,
|
|
103
|
+
FormStateRequest,
|
|
104
|
+
FormStateResult,
|
|
105
|
+
FormStateScope,
|
|
106
|
+
FormWizardFailure,
|
|
107
|
+
FormWizardRequest,
|
|
108
|
+
FormWizardSuccess,
|
|
109
|
+
} from './pageData/forms.js'
|
|
110
|
+
export {
|
|
111
|
+
formCreateOptionData,
|
|
112
|
+
formStateData,
|
|
113
|
+
formWizardData,
|
|
114
|
+
mentionResolveData,
|
|
115
|
+
} from './pageData/forms.js'
|
|
4707
116
|
|
|
4708
|
-
|
|
117
|
+
import {
|
|
118
|
+
customPageData,
|
|
119
|
+
globalEditData,
|
|
120
|
+
globalViewData,
|
|
121
|
+
} from './pageData/misc.js'
|
|
122
|
+
|
|
123
|
+
// Re-export the misc page builders for external callsites (routes.ts
|
|
124
|
+
// dispatches into globals / custom-page / widget / search routes).
|
|
125
|
+
export {
|
|
126
|
+
customPageData,
|
|
127
|
+
globalEditData,
|
|
128
|
+
globalViewData,
|
|
129
|
+
searchData,
|
|
130
|
+
widgetData,
|
|
131
|
+
} from './pageData/misc.js'
|
|
132
|
+
export type {
|
|
133
|
+
WidgetFailure,
|
|
134
|
+
WidgetRequest,
|
|
135
|
+
WidgetScope,
|
|
136
|
+
WidgetSuccess,
|
|
137
|
+
} from './pageData/misc.js'
|
|
4709
138
|
|
|
4710
|
-
/**
|
|
4711
|
-
* Resolve the user via `pilotiq.resolveUser(req)` and run the
|
|
4712
|
-
* panel-wide search. Mirrors the formStateData/formWizardData
|
|
4713
|
-
* shape so the `/_search` route handler stays a thin wrapper.
|
|
4714
|
-
*
|
|
4715
|
-
* Also resolves the `panels::global-search.results.before/.after`
|
|
4716
|
-
* render hooks when the panel registered any — sparse, absent when
|
|
4717
|
-
* neither slot has registered fns. Sent as a `RenderHookMap` so the
|
|
4718
|
-
* client `<CommandPalette>` can mount `<RenderHookSlot>` above and
|
|
4719
|
-
* below the result list (same pattern chrome slots use).
|
|
4720
|
-
*/
|
|
4721
|
-
export async function searchData(
|
|
4722
|
-
pilotiq: Pilotiq,
|
|
4723
|
-
query: string,
|
|
4724
|
-
req?: unknown,
|
|
4725
|
-
): Promise<{
|
|
4726
|
-
ok: true
|
|
4727
|
-
results: GlobalSearchResult[]
|
|
4728
|
-
renderHooks?: RenderHookMap
|
|
4729
|
-
}> {
|
|
4730
|
-
const user = await pilotiq.resolveUser(req)
|
|
4731
|
-
const results = await searchAllResources(pilotiq, query, user)
|
|
4732
|
-
const cfg = pilotiq.getConfig()
|
|
4733
|
-
const out: { ok: true; results: GlobalSearchResult[]; renderHooks?: RenderHookMap } = {
|
|
4734
|
-
ok: true,
|
|
4735
|
-
results,
|
|
4736
|
-
}
|
|
4737
|
-
if (cfg.renderHooks && cfg.renderHooks.length > 0) {
|
|
4738
|
-
const hooks = await resolvePageHooks(
|
|
4739
|
-
pilotiq,
|
|
4740
|
-
user,
|
|
4741
|
-
pageHooksFor('search'),
|
|
4742
|
-
{ url: `${cfg.path}/_search` },
|
|
4743
|
-
)
|
|
4744
|
-
if (Object.keys(hooks).length > 0) out.renderHooks = hooks
|
|
4745
|
-
}
|
|
4746
|
-
return out
|
|
4747
|
-
}
|
|
4748
139
|
|
|
4749
140
|
// ─── Vike +data dispatcher ───────────────────────────────────
|
|
4750
141
|
|