@pilotiq/pilotiq 0.7.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +142 -0
- package/CLAUDE.md +59 -3
- package/dist/Pilotiq.d.ts +83 -0
- package/dist/Pilotiq.d.ts.map +1 -1
- package/dist/Pilotiq.js +39 -0
- package/dist/Pilotiq.js.map +1 -1
- package/dist/actions/Action.d.ts +27 -99
- package/dist/actions/Action.d.ts.map +1 -1
- package/dist/actions/Action.js +52 -754
- package/dist/actions/Action.js.map +1 -1
- package/dist/actions/bulkFactories.d.ts +46 -0
- package/dist/actions/bulkFactories.d.ts.map +1 -0
- package/dist/actions/bulkFactories.js +144 -0
- package/dist/actions/bulkFactories.js.map +1 -0
- package/dist/actions/crudFactories.d.ts +94 -0
- package/dist/actions/crudFactories.d.ts.map +1 -0
- package/dist/actions/crudFactories.js +209 -0
- package/dist/actions/crudFactories.js.map +1 -0
- package/dist/actions/factoryHelpers.d.ts +108 -0
- package/dist/actions/factoryHelpers.d.ts.map +1 -0
- package/dist/actions/factoryHelpers.js +138 -0
- package/dist/actions/factoryHelpers.js.map +1 -0
- package/dist/actions/m2mFactories.d.ts +47 -0
- package/dist/actions/m2mFactories.d.ts.map +1 -0
- package/dist/actions/m2mFactories.js +173 -0
- package/dist/actions/m2mFactories.js.map +1 -0
- package/dist/actions/relationFactories.d.ts +93 -0
- package/dist/actions/relationFactories.d.ts.map +1 -0
- package/dist/actions/relationFactories.js +321 -0
- package/dist/actions/relationFactories.js.map +1 -0
- package/dist/elements/dispatchForm.js +1 -1
- package/dist/elements/dispatchForm.js.map +1 -1
- package/dist/elements/dispatchTable.js +1 -1
- package/dist/elements/dispatchTable.js.map +1 -1
- package/dist/fields/Field.d.ts +31 -0
- package/dist/fields/Field.d.ts.map +1 -1
- package/dist/fields/Field.js +25 -0
- package/dist/fields/Field.js.map +1 -1
- package/dist/pageData/breadcrumbs.d.ts +42 -0
- package/dist/pageData/breadcrumbs.d.ts.map +1 -0
- package/dist/pageData/breadcrumbs.js +172 -0
- package/dist/pageData/breadcrumbs.js.map +1 -0
- package/dist/pageData/forms.d.ts +137 -0
- package/dist/pageData/forms.d.ts.map +1 -0
- package/dist/pageData/forms.js +427 -0
- package/dist/pageData/forms.js.map +1 -0
- package/dist/pageData/helpers.d.ts +239 -0
- package/dist/pageData/helpers.d.ts.map +1 -0
- package/dist/pageData/helpers.js +703 -0
- package/dist/pageData/helpers.js.map +1 -0
- package/dist/pageData/misc.d.ts +76 -0
- package/dist/pageData/misc.d.ts.map +1 -0
- package/dist/pageData/misc.js +263 -0
- package/dist/pageData/misc.js.map +1 -0
- package/dist/pageData/navigation.d.ts +292 -0
- package/dist/pageData/navigation.d.ts.map +1 -0
- package/dist/pageData/navigation.js +591 -0
- package/dist/pageData/navigation.js.map +1 -0
- package/dist/pageData/relationPages.d.ts +172 -0
- package/dist/pageData/relationPages.d.ts.map +1 -0
- package/dist/pageData/relationPages.js +867 -0
- package/dist/pageData/relationPages.js.map +1 -0
- package/dist/pageData/relationTabs.d.ts +65 -0
- package/dist/pageData/relationTabs.d.ts.map +1 -0
- package/dist/pageData/relationTabs.js +258 -0
- package/dist/pageData/relationTabs.js.map +1 -0
- package/dist/pageData/resourcePages.d.ts +48 -0
- package/dist/pageData/resourcePages.d.ts.map +1 -0
- package/dist/pageData/resourcePages.js +504 -0
- package/dist/pageData/resourcePages.js.map +1 -0
- package/dist/pageData.d.ts +12 -792
- package/dist/pageData.d.ts.map +1 -1
- package/dist/pageData.js +24 -3797
- package/dist/pageData.js.map +1 -1
- package/dist/react/AppShell.d.ts +8 -0
- package/dist/react/AppShell.d.ts.map +1 -1
- package/dist/react/AppShell.js +11 -1
- package/dist/react/AppShell.js.map +1 -1
- package/dist/react/CollabExtensionFactoryRegistry.d.ts +47 -0
- package/dist/react/CollabExtensionFactoryRegistry.d.ts.map +1 -0
- package/dist/react/CollabExtensionFactoryRegistry.js +14 -0
- package/dist/react/CollabExtensionFactoryRegistry.js.map +1 -0
- package/dist/react/CollabRoomContext.d.ts +37 -0
- package/dist/react/CollabRoomContext.d.ts.map +1 -0
- package/dist/react/CollabRoomContext.js +12 -0
- package/dist/react/CollabRoomContext.js.map +1 -0
- package/dist/react/FormCollabBindingRegistry.d.ts +62 -0
- package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -0
- package/dist/react/FormCollabBindingRegistry.js +14 -0
- package/dist/react/FormCollabBindingRegistry.js.map +1 -0
- package/dist/react/RecordWrapperGate.d.ts +25 -0
- package/dist/react/RecordWrapperGate.d.ts.map +1 -0
- package/dist/react/RecordWrapperGate.js +30 -0
- package/dist/react/RecordWrapperGate.js.map +1 -0
- package/dist/react/RecordWrapperRegistry.d.ts +31 -0
- package/dist/react/RecordWrapperRegistry.d.ts.map +1 -0
- package/dist/react/RecordWrapperRegistry.js +15 -0
- package/dist/react/RecordWrapperRegistry.js.map +1 -0
- package/dist/react/SchemaRenderer.d.ts +17 -23
- package/dist/react/SchemaRenderer.d.ts.map +1 -1
- package/dist/react/SchemaRenderer.js +71 -3647
- package/dist/react/SchemaRenderer.js.map +1 -1
- package/dist/react/component-slots.d.ts +103 -0
- package/dist/react/component-slots.d.ts.map +1 -0
- package/dist/react/component-slots.js +18 -0
- package/dist/react/component-slots.js.map +1 -0
- package/dist/react/fields/BuilderInput.d.ts.map +1 -1
- package/dist/react/fields/BuilderInput.js +21 -117
- package/dist/react/fields/BuilderInput.js.map +1 -1
- package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
- package/dist/react/fields/MarkdownInput.js +1 -3
- package/dist/react/fields/MarkdownInput.js.map +1 -1
- package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
- package/dist/react/fields/RepeaterInput.js +22 -127
- package/dist/react/fields/RepeaterInput.js.map +1 -1
- package/dist/react/fields/rowState.d.ts +40 -0
- package/dist/react/fields/rowState.d.ts.map +1 -0
- package/dist/react/fields/rowState.js +60 -0
- package/dist/react/fields/rowState.js.map +1 -0
- package/dist/react/fields/useRowReorderDnd.d.ts +28 -0
- package/dist/react/fields/useRowReorderDnd.d.ts.map +1 -0
- package/dist/react/fields/useRowReorderDnd.js +51 -0
- package/dist/react/fields/useRowReorderDnd.js.map +1 -0
- package/dist/react/index.d.ts +9 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +8 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts +1 -1
- package/dist/react/layouts/SidebarLayout.d.ts.map +1 -1
- package/dist/react/layouts/SidebarLayout.js +10 -2
- package/dist/react/layouts/SidebarLayout.js.map +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts +1 -1
- package/dist/react/layouts/TopbarLayout.d.ts.map +1 -1
- package/dist/react/layouts/TopbarLayout.js +19 -11
- package/dist/react/layouts/TopbarLayout.js.map +1 -1
- package/dist/react/parseRecordEditUrl.d.ts +29 -0
- package/dist/react/parseRecordEditUrl.d.ts.map +1 -0
- package/dist/react/parseRecordEditUrl.js +25 -0
- package/dist/react/parseRecordEditUrl.js.map +1 -0
- package/dist/react/persistedState.d.ts +19 -0
- package/dist/react/persistedState.d.ts.map +1 -0
- package/dist/react/persistedState.js +51 -0
- package/dist/react/persistedState.js.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts +12 -0
- package/dist/react/schemaRenderer/AlertRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/AlertRenderer.js +61 -0
- package/dist/react/schemaRenderer/AlertRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts +13 -0
- package/dist/react/schemaRenderer/EntryRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/EntryRenderer.js +277 -0
- package/dist/react/schemaRenderer/EntryRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts +16 -0
- package/dist/react/schemaRenderer/SectionRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SectionRenderer.js +62 -0
- package/dist/react/schemaRenderer/SectionRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts +25 -0
- package/dist/react/schemaRenderer/SimpleElements.d.ts.map +1 -0
- package/dist/react/schemaRenderer/SimpleElements.js +147 -0
- package/dist/react/schemaRenderer/SimpleElements.js.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts +17 -0
- package/dist/react/schemaRenderer/TabsRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/TabsRenderer.js +31 -0
- package/dist/react/schemaRenderer/TabsRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts +34 -0
- package/dist/react/schemaRenderer/WizardRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/WizardRenderer.js +208 -0
- package/dist/react/schemaRenderer/WizardRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts +21 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js +82 -0
- package/dist/react/schemaRenderer/action/ActionGroupTrigger.js.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts +30 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js +182 -0
- package/dist/react/schemaRenderer/action/ActionModalDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts +17 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js +19 -0
- package/dist/react/schemaRenderer/action/ConfirmActionDialog.js.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js +16 -0
- package/dist/react/schemaRenderer/action/HandlerActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts +22 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js +26 -0
- package/dist/react/schemaRenderer/action/MethodActionButton.js.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts +18 -0
- package/dist/react/schemaRenderer/action/buttons.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/buttons.js +74 -0
- package/dist/react/schemaRenderer/action/buttons.js.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts +26 -0
- package/dist/react/schemaRenderer/action/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/helpers.js +126 -0
- package/dist/react/schemaRenderer/action/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts +21 -0
- package/dist/react/schemaRenderer/action/renderAction.d.ts.map +1 -0
- package/dist/react/schemaRenderer/action/renderAction.js +102 -0
- package/dist/react/schemaRenderer/action/renderAction.js.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts +10 -0
- package/dist/react/schemaRenderer/columnFormat.d.ts.map +1 -0
- package/dist/react/schemaRenderer/columnFormat.js +76 -0
- package/dist/react/schemaRenderer/columnFormat.js.map +1 -0
- package/dist/react/schemaRenderer/constants.d.ts +8 -0
- package/dist/react/schemaRenderer/constants.d.ts.map +1 -0
- package/dist/react/schemaRenderer/constants.js +45 -0
- package/dist/react/schemaRenderer/constants.js.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/form/FormRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js +152 -0
- package/dist/react/schemaRenderer/form/FormRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts +6 -0
- package/dist/react/schemaRenderer/form/renderField.d.ts.map +1 -0
- package/dist/react/schemaRenderer/form/renderField.js +239 -0
- package/dist/react/schemaRenderer/form/renderField.js.map +1 -0
- package/dist/react/schemaRenderer/helpers.d.ts +32 -0
- package/dist/react/schemaRenderer/helpers.d.ts.map +1 -0
- package/dist/react/schemaRenderer/helpers.js +52 -0
- package/dist/react/schemaRenderer/helpers.js.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts +60 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js +189 -0
- package/dist/react/schemaRenderer/table/CardsLayoutBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts +29 -0
- package/dist/react/schemaRenderer/table/TableRenderer.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js +85 -0
- package/dist/react/schemaRenderer/table/TableRenderer.js.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts +18 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js +555 -0
- package/dist/react/schemaRenderer/table/TableRendererBody.js.map +1 -0
- package/dist/react/schemaRenderer/table/filters.d.ts +263 -0
- package/dist/react/schemaRenderer/table/filters.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/filters.js +497 -0
- package/dist/react/schemaRenderer/table/filters.js.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts +11 -0
- package/dist/react/schemaRenderer/table/formatCell.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/formatCell.js +172 -0
- package/dist/react/schemaRenderer/table/formatCell.js.map +1 -0
- package/dist/react/schemaRenderer/table/links.d.ts +42 -0
- package/dist/react/schemaRenderer/table/links.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/links.js +55 -0
- package/dist/react/schemaRenderer/table/links.js.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts +13 -0
- package/dist/react/schemaRenderer/table/renderRowActions.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js +25 -0
- package/dist/react/schemaRenderer/table/renderRowActions.js.map +1 -0
- package/dist/react/schemaRenderer/table/url.d.ts +41 -0
- package/dist/react/schemaRenderer/table/url.d.ts.map +1 -0
- package/dist/react/schemaRenderer/table/url.js +114 -0
- package/dist/react/schemaRenderer/table/url.js.map +1 -0
- package/dist/routes/globals.d.ts +13 -0
- package/dist/routes/globals.d.ts.map +1 -0
- package/dist/routes/globals.js +131 -0
- package/dist/routes/globals.js.map +1 -0
- package/dist/routes/helpers.d.ts +217 -0
- package/dist/routes/helpers.d.ts.map +1 -0
- package/dist/routes/helpers.js +498 -0
- package/dist/routes/helpers.js.map +1 -0
- package/dist/routes/pages.d.ts +15 -0
- package/dist/routes/pages.d.ts.map +1 -0
- package/dist/routes/pages.js +145 -0
- package/dist/routes/pages.js.map +1 -0
- package/dist/routes/panel.d.ts +19 -0
- package/dist/routes/panel.d.ts.map +1 -0
- package/dist/routes/panel.js +191 -0
- package/dist/routes/panel.js.map +1 -0
- package/dist/routes/relations.d.ts +21 -0
- package/dist/routes/relations.d.ts.map +1 -0
- package/dist/routes/relations.js +1239 -0
- package/dist/routes/relations.js.map +1 -0
- package/dist/routes/resources.d.ts +28 -0
- package/dist/routes/resources.d.ts.map +1 -0
- package/dist/routes/resources.js +741 -0
- package/dist/routes/resources.js.map +1 -0
- package/dist/routes/theme.d.ts +12 -0
- package/dist/routes/theme.d.ts.map +1 -0
- package/dist/routes/theme.js +82 -0
- package/dist/routes/theme.js.map +1 -0
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +64 -3078
- package/dist/routes.js.map +1 -1
- package/dist/vite.d.ts +1 -0
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +26 -5
- package/dist/vite.js.map +1 -1
- package/package.json +2 -1
- package/src/Pilotiq.ts +95 -0
- package/src/actions/Action.ts +79 -723
- package/src/actions/bulkFactories.ts +168 -0
- package/src/actions/crudFactories.ts +220 -0
- package/src/actions/factoryHelpers.ts +177 -0
- package/src/actions/m2mFactories.ts +193 -0
- package/src/actions/relationFactories.ts +372 -0
- package/src/elements/dispatchForm.ts +1 -1
- package/src/elements/dispatchTable.ts +1 -1
- package/src/fields/Field.ts +39 -0
- package/src/pageData/breadcrumbs.ts +288 -0
- package/src/pageData/forms.ts +578 -0
- package/src/pageData/helpers.ts +764 -0
- package/src/pageData/misc.ts +347 -0
- package/src/pageData/navigation.ts +779 -0
- package/src/pageData/relationPages.ts +1246 -0
- package/src/pageData/relationTabs.ts +286 -0
- package/src/pageData/resourcePages.ts +593 -0
- package/src/pageData.ts +122 -4731
- package/src/react/AppShell.tsx +27 -1
- package/src/react/CollabExtensionFactoryRegistry.ts +55 -0
- package/src/react/CollabRoomContext.ts +42 -0
- package/src/react/FormCollabBindingRegistry.ts +72 -0
- package/src/react/RecordWrapperGate.tsx +40 -0
- package/src/react/RecordWrapperRegistry.ts +39 -0
- package/src/react/SchemaRenderer.tsx +230 -6479
- package/src/react/component-slots.test.ts +103 -0
- package/src/react/component-slots.ts +116 -0
- package/src/react/fields/BuilderInput.tsx +29 -117
- package/src/react/fields/MarkdownInput.tsx +0 -1
- package/src/react/fields/RepeaterInput.tsx +29 -130
- package/src/react/fields/rowState.ts +106 -0
- package/src/react/fields/useRowReorderDnd.ts +78 -0
- package/src/react/index.ts +38 -0
- package/src/react/layouts/SidebarLayout.tsx +39 -28
- package/src/react/layouts/TopbarLayout.tsx +70 -57
- package/src/react/parseRecordEditUrl.test.ts +75 -0
- package/src/react/parseRecordEditUrl.ts +55 -0
- package/src/react/persistedState.ts +40 -0
- package/src/react/schemaRenderer/AlertRenderer.tsx +112 -0
- package/src/react/schemaRenderer/EntryRenderer.tsx +501 -0
- package/src/react/schemaRenderer/SectionRenderer.tsx +120 -0
- package/src/react/schemaRenderer/SimpleElements.tsx +306 -0
- package/src/react/schemaRenderer/TabsRenderer.tsx +62 -0
- package/src/react/schemaRenderer/WizardRenderer.tsx +338 -0
- package/src/react/schemaRenderer/action/ActionGroupTrigger.tsx +177 -0
- package/src/react/schemaRenderer/action/ActionModalDialog.tsx +273 -0
- package/src/react/schemaRenderer/action/ConfirmActionDialog.tsx +61 -0
- package/src/react/schemaRenderer/action/HandlerActionButton.tsx +43 -0
- package/src/react/schemaRenderer/action/MethodActionButton.tsx +64 -0
- package/src/react/schemaRenderer/action/buttons.tsx +99 -0
- package/src/react/schemaRenderer/action/helpers.ts +140 -0
- package/src/react/schemaRenderer/action/renderAction.tsx +245 -0
- package/src/react/schemaRenderer/columnFormat.ts +65 -0
- package/src/react/schemaRenderer/constants.ts +50 -0
- package/src/react/schemaRenderer/form/FormRenderer.tsx +233 -0
- package/src/react/schemaRenderer/form/renderField.tsx +511 -0
- package/src/react/schemaRenderer/helpers.tsx +81 -0
- package/src/react/schemaRenderer/table/CardsLayoutBody.tsx +308 -0
- package/src/react/schemaRenderer/table/TableRenderer.tsx +123 -0
- package/src/react/schemaRenderer/table/TableRendererBody.tsx +974 -0
- package/src/react/schemaRenderer/table/filters.tsx +1233 -0
- package/src/react/schemaRenderer/table/formatCell.tsx +264 -0
- package/src/react/schemaRenderer/table/links.tsx +112 -0
- package/src/react/schemaRenderer/table/renderRowActions.tsx +52 -0
- package/src/react/schemaRenderer/table/url.tsx +143 -0
- package/src/routes/globals.ts +154 -0
- package/src/routes/helpers.ts +668 -0
- package/src/routes/pages.ts +173 -0
- package/src/routes/panel.ts +204 -0
- package/src/routes/relations.ts +1219 -0
- package/src/routes/resources.ts +786 -0
- package/src/routes/theme.ts +109 -0
- package/src/routes.test.ts +1 -1
- package/src/routes.ts +64 -3176
- package/src/schema/TableWidget.test.ts +2 -2
- package/src/theme/migrate.test.ts +178 -0
- package/src/vite.test.ts +184 -0
- package/src/vite.ts +26 -4
|
@@ -1,65 +1,33 @@
|
|
|
1
1
|
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
3
|
-
import { getFieldRenderer } from './registry.js';
|
|
4
|
-
import { getFieldLabelSlot } from './FieldLabelSlotRegistry.js';
|
|
5
|
-
import { FormStateProvider, useFormState, FormIdContext } from './FormStateContext.js';
|
|
6
|
-
import { Checkbox } from './ui/checkbox.js';
|
|
7
|
-
import { Input } from './ui/input.js';
|
|
8
|
-
import { Popover, PopoverTrigger, PopoverContent } from './ui/popover.js';
|
|
9
|
-
import { FieldShell } from './fields/FieldShell.js';
|
|
10
|
-
import { TextLikeInput } from './fields/TextLikeInput.js';
|
|
11
|
-
import { useTextInputControls } from './fields/textInputControls.js';
|
|
12
|
-
import { SelectFieldInput } from './fields/SelectFieldInput.js';
|
|
13
|
-
import { ToggleFieldInput } from './fields/ToggleFieldInput.js';
|
|
14
|
-
import { DateFieldInput } from './fields/DateFieldInput.js';
|
|
15
|
-
import { HiddenInput } from './fields/HiddenInput.js';
|
|
16
|
-
import { CheckboxInput } from './fields/CheckboxInput.js';
|
|
17
|
-
import { RadioInput } from './fields/RadioInput.js';
|
|
18
|
-
import { ToggleButtonsInput } from './fields/ToggleButtonsInput.js';
|
|
19
|
-
import { CheckboxListInput } from './fields/CheckboxListInput.js';
|
|
20
|
-
import { SliderInput } from './fields/SliderInput.js';
|
|
21
|
-
import { ColorInput } from './fields/ColorInput.js';
|
|
22
|
-
import { DateTimeInput } from './fields/DateTimeInput.js';
|
|
23
|
-
import { KeyValueInput } from './fields/KeyValueInput.js';
|
|
24
|
-
import { TagsInput } from './fields/TagsInput.js';
|
|
25
|
-
import { FileUploadInput } from './fields/FileUploadInput.js';
|
|
26
|
-
import { MarkdownInput } from './fields/MarkdownInput.js';
|
|
27
|
-
import { RepeaterInput } from './fields/RepeaterInput.js';
|
|
28
|
-
import { BuilderInput } from './fields/BuilderInput.js';
|
|
29
|
-
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, } from './ui/dialog.js';
|
|
30
|
-
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs.js';
|
|
31
|
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from './ui/select.js';
|
|
32
|
-
import { Table as DataTable, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow, } from './ui/table.js';
|
|
33
|
-
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from './ui/dropdown-menu.js';
|
|
34
|
-
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from './ui/tooltip.js';
|
|
35
|
-
import { FilterIcon, CircleIcon, InboxIcon, GripVerticalIcon, ChevronDownIcon, CopyIcon, CheckIcon, XIcon, InfoIcon, TriangleAlertIcon, CircleCheckIcon, CircleAlertIcon, Columns3Icon, } from 'lucide-react';
|
|
2
|
+
import { CircleIcon } from 'lucide-react';
|
|
36
3
|
import { useNavigate } from './navigate.js';
|
|
37
|
-
import { parseDateRangeValue, encodeDateRangeValue, } from '../filters/DateRangeFilter.js';
|
|
38
|
-
import { parseMultiSelectValue, encodeMultiSelectValue, } from '../filters/MultiSelectFilter.js';
|
|
39
|
-
import { encodeFormFilterValue } from '../filters/FormFilter.js';
|
|
40
|
-
import { parseQueryBuilderValue, encodeQueryBuilderValue, isQueryBuilderTree, } from '../filters/QueryBuilderFilter.js';
|
|
41
4
|
import { useIconFor } from './icon-context.js';
|
|
42
|
-
import { useToast } from './Toaster.js';
|
|
43
|
-
import { getIcon } from '../icons/registry.js';
|
|
44
|
-
import { pickEditableCell } from './cells/EditableCell.js';
|
|
45
5
|
import { WidgetDataProvider } from './WidgetDataContext.js';
|
|
46
6
|
import { StatsOverviewRenderer } from './widgets/StatsOverviewRenderer.js';
|
|
47
7
|
import { TableWidgetRenderer } from './widgets/TableWidgetRenderer.js';
|
|
48
8
|
import { ViewRenderer } from './widgets/ViewRenderer.js';
|
|
49
|
-
import { getEntryComponent } from '../entries/registry.js';
|
|
50
9
|
import { getSlotComponent } from '../slot-components/registry.js';
|
|
51
10
|
import { getWidgetRenderer } from './widgetRegistry.js';
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
};
|
|
11
|
+
import { BADGE_COLOR_CLASSES, } from './schemaRenderer/constants.js';
|
|
12
|
+
import { resolveIcon, } from './schemaRenderer/helpers.js';
|
|
13
|
+
import { renderSimpleElement } from './schemaRenderer/SimpleElements.js';
|
|
14
|
+
import { AlertRenderer } from './schemaRenderer/AlertRenderer.js';
|
|
15
|
+
import { SectionRenderer } from './schemaRenderer/SectionRenderer.js';
|
|
16
|
+
import { TabsRenderer } from './schemaRenderer/TabsRenderer.js';
|
|
17
|
+
import { WizardRenderer } from './schemaRenderer/WizardRenderer.js';
|
|
18
|
+
import { renderEntry } from './schemaRenderer/EntryRenderer.js';
|
|
19
|
+
import { dispatchHandlerAction as actionDispatchHandlerAction, } from './schemaRenderer/action/helpers.js';
|
|
20
|
+
import { renderAction, renderActionLike as renderActionLikeImpl } from './schemaRenderer/action/renderAction.js';
|
|
21
|
+
import { ActionGroupTrigger } from './schemaRenderer/action/ActionGroupTrigger.js';
|
|
22
|
+
import { renderField as renderFieldImpl, } from './schemaRenderer/form/renderField.js';
|
|
23
|
+
import { FormRenderer as FormRendererImpl, renderFormChild as renderFormChildImpl, } from './schemaRenderer/form/FormRenderer.js';
|
|
24
|
+
import { TableRenderer as TableRendererImpl } from './schemaRenderer/table/TableRenderer.js';
|
|
25
|
+
/**
|
|
26
|
+
* Re-export `dispatchHandlerAction` from the action helpers so existing
|
|
27
|
+
* consumers (e.g. `RepeaterInput.tsx`) keep working through this barrel.
|
|
28
|
+
* Phase 4 may shift these imports onto the action subpath directly.
|
|
29
|
+
*/
|
|
30
|
+
export const dispatchHandlerAction = actionDispatchHandlerAction;
|
|
63
31
|
export function FormFields({ elements, values }) {
|
|
64
32
|
return (_jsx(_Fragment, { children: elements.map((el, i) => {
|
|
65
33
|
if (el['type'] !== 'field')
|
|
@@ -71,1693 +39,65 @@ export function FormFields({ elements, values }) {
|
|
|
71
39
|
return renderField(merged, i);
|
|
72
40
|
}) }));
|
|
73
41
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const fieldType = String(el['fieldType'] ?? 'text');
|
|
81
|
-
const name = String(el['name'] ?? '');
|
|
82
|
-
const label = String(el['label'] ?? name);
|
|
83
|
-
const required = Boolean(el['required']);
|
|
84
|
-
const disabled = Boolean(el['disabled']);
|
|
85
|
-
const placeholder = el['placeholder'] ? String(el['placeholder']) : undefined;
|
|
86
|
-
const defaultValue = el['defaultValue'];
|
|
87
|
-
const defaultStr = defaultValue !== undefined && defaultValue !== null ? String(defaultValue) : undefined;
|
|
88
|
-
// Hidden fields render bare — no label, no shell, no chrome. Bail
|
|
89
|
-
// before the renderField switch + FieldShell wrap.
|
|
90
|
-
if (fieldType === 'hidden') {
|
|
91
|
-
return _jsx(HiddenInput, { name: name, defaultValue: defaultValue }, index);
|
|
92
|
-
}
|
|
93
|
-
// Field label slot — rendered next to the label when a plugin registered
|
|
94
|
-
// a component via registerFieldLabelSlot() and the field has aiActions +
|
|
95
|
-
// _agentRunBase stamped on its meta (set by tagFieldAiUrls in pageData).
|
|
96
|
-
const LabelSlot = getFieldLabelSlot();
|
|
97
|
-
const aiActions = Array.isArray(el['aiActions']) ? el['aiActions'] : undefined;
|
|
98
|
-
const agentRunBase = typeof el['_agentRunBase'] === 'string' ? el['_agentRunBase'] : undefined;
|
|
99
|
-
const labelSlot = (LabelSlot && aiActions?.length && agentRunBase)
|
|
100
|
-
? _jsx(LabelSlot, { fieldName: name, actions: aiActions, agentRunBase: agentRunBase })
|
|
101
|
-
: undefined;
|
|
102
|
-
const autofocus = el['autofocus'] === true;
|
|
103
|
-
const extraInput = el['extraInputAttributes'];
|
|
104
|
-
const common = {
|
|
105
|
-
id: name,
|
|
106
|
-
name,
|
|
107
|
-
disabled,
|
|
108
|
-
placeholder,
|
|
109
|
-
required,
|
|
110
|
-
...(defaultStr !== undefined ? { defaultValue: defaultStr } : {}),
|
|
111
|
-
...(autofocus ? { autoFocus: true } : {}),
|
|
112
|
-
...(extraInput ?? {}),
|
|
113
|
-
};
|
|
114
|
-
// External packages (e.g. @pilotiq/tiptap) register custom renderers
|
|
115
|
-
// for non-built-in fieldTypes. The registry wins over the built-in
|
|
116
|
-
// switch so consumers can override built-ins too if they want.
|
|
117
|
-
const Custom = getFieldRenderer(fieldType);
|
|
118
|
-
if (Custom) {
|
|
119
|
-
return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, labelSlot: labelSlot, children: _jsx(Custom, { el: el, name: name, defaultValue: defaultValue, required: required, disabled: disabled, placeholder: placeholder }) }, index));
|
|
120
|
-
}
|
|
121
|
-
// TextField (and slug) rich affordances live in a dedicated shell so
|
|
122
|
-
// `useTextInputControls` can hold reveal-toggle / mask state via React
|
|
123
|
-
// hooks (renderField itself is a plain function, hooks would violate
|
|
124
|
-
// rules-of-hooks here).
|
|
125
|
-
if (fieldType === 'text' || fieldType === 'slug') {
|
|
126
|
-
return (_jsx(TextFieldShell, { el: el, name: name, label: label, required: required, common: common, labelSlot: labelSlot }, index));
|
|
127
|
-
}
|
|
128
|
-
const input = renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder);
|
|
129
|
-
return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, labelSlot: labelSlot, children: input }, index));
|
|
130
|
-
}
|
|
131
|
-
/**
|
|
132
|
-
* Component-shape TextField renderer — wraps the input shell so we can
|
|
133
|
-
* use `useTextInputControls()` (which holds the eye-toggle / mask state).
|
|
134
|
-
* Keeps `renderField` itself hook-free.
|
|
135
|
-
*/
|
|
136
|
-
function TextFieldShell({ el, name, label, required, common, labelSlot, }) {
|
|
137
|
-
const controls = useTextInputControls(el, name, (m) => renderElement(m, 0));
|
|
138
|
-
// Build the input with all the new HTML attrs (inputMode /
|
|
139
|
-
// autocapitalize / list / maxLength + the password/text type from
|
|
140
|
-
// the controls hook).
|
|
141
|
-
const textExtra = {};
|
|
142
|
-
if (el['maxLength'] !== undefined)
|
|
143
|
-
textExtra['maxLength'] = Number(el['maxLength']);
|
|
144
|
-
if (el['inputMode'] !== undefined)
|
|
145
|
-
textExtra['inputMode'] = String(el['inputMode']);
|
|
146
|
-
if (el['autocapitalize'] !== undefined)
|
|
147
|
-
textExtra['autoCapitalize'] = String(el['autocapitalize']);
|
|
148
|
-
if (Array.isArray(el['datalist']))
|
|
149
|
-
textExtra['list'] = `${name}__datalist`;
|
|
150
|
-
const datalist = Array.isArray(el['datalist']) ? el['datalist'] : undefined;
|
|
151
|
-
const input = (_jsxs(_Fragment, { children: [_jsx(TextLikeInput, { el: el, name: name, common: common, type: controls.type, extraProps: textExtra, multiline: false, applyMask: controls.applyMask }), datalist && (_jsx("datalist", { id: `${name}__datalist`, children: datalist.map((v, i) => _jsx("option", { value: v }, i)) }))] }));
|
|
152
|
-
return (_jsx(FieldShell, { el: el, name: name, label: label, required: required, before: controls.before, after: controls.after, labelSlot: labelSlot, children: input }));
|
|
153
|
-
}
|
|
154
|
-
function renderFieldInput(fieldType, el, name, defaultValue, defaultStr, common, disabled, required, placeholder) {
|
|
155
|
-
switch (fieldType) {
|
|
156
|
-
case 'textarea': {
|
|
157
|
-
const autosize = el['autosize'] === true;
|
|
158
|
-
const cols = typeof el['cols'] === 'number' ? Number(el['cols']) : undefined;
|
|
159
|
-
const extra = {};
|
|
160
|
-
// `field-sizing-content` on the Textarea component already grows
|
|
161
|
-
// the box with content; `autosize()` just unsets the explicit
|
|
162
|
-
// `rows` so the browser doesn't reserve a fixed minimum height.
|
|
163
|
-
if (!autosize)
|
|
164
|
-
extra['rows'] = Number(el['rows']) || 4;
|
|
165
|
-
if (cols !== undefined)
|
|
166
|
-
extra['cols'] = cols;
|
|
167
|
-
if (el['disableGrammarly'] === true) {
|
|
168
|
-
extra['data-gramm'] = 'false';
|
|
169
|
-
extra['data-gramm_editor'] = 'false';
|
|
170
|
-
extra['data-enable-grammarly'] = 'false';
|
|
171
|
-
}
|
|
172
|
-
return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "text", extraProps: extra, multiline: true }));
|
|
173
|
-
}
|
|
174
|
-
case 'select': {
|
|
175
|
-
const options = el['options'] ?? [];
|
|
176
|
-
const createOption = el['createOption'];
|
|
177
|
-
const fieldLabel = String(el['label'] ?? name);
|
|
178
|
-
return (_jsx(SelectFieldInput, { name: name, defaultValue: defaultStr, disabled: disabled, required: required, placeholder: placeholder, options: options, fieldLabel: fieldLabel, ...(createOption ? { createOption } : {}) }));
|
|
179
|
-
}
|
|
180
|
-
case 'toggle': {
|
|
181
|
-
const initialChecked = defaultValue === true || defaultValue === 'true' || defaultValue === 1 || defaultValue === '1';
|
|
182
|
-
return _jsx(ToggleFieldInput, { name: name, defaultChecked: initialChecked, disabled: disabled });
|
|
183
|
-
}
|
|
184
|
-
case 'checkbox': {
|
|
185
|
-
const initialChecked = defaultValue === true || defaultValue === 'true' || defaultValue === 1 || defaultValue === '1';
|
|
186
|
-
return _jsx(CheckboxInput, { name: name, defaultChecked: initialChecked, disabled: disabled });
|
|
187
|
-
}
|
|
188
|
-
case 'radio': {
|
|
189
|
-
const options = el['options'] ?? [];
|
|
190
|
-
const inline = Boolean(el['inline']);
|
|
191
|
-
return (_jsx(RadioInput, { name: name, defaultValue: defaultStr, disabled: disabled, options: options, inline: inline }));
|
|
192
|
-
}
|
|
193
|
-
case 'toggleButtons': {
|
|
194
|
-
const options = el['options'] ?? [];
|
|
195
|
-
return (_jsx(ToggleButtonsInput, { name: name, defaultValue: defaultStr, disabled: disabled, options: options }));
|
|
196
|
-
}
|
|
197
|
-
case 'checkboxList': {
|
|
198
|
-
const options = el['options'] ?? [];
|
|
199
|
-
const columns = Number(el['columns']) || 1;
|
|
200
|
-
return (_jsx(CheckboxListInput, { name: name, defaultValue: defaultValue, disabled: disabled, options: options, columns: columns }));
|
|
201
|
-
}
|
|
202
|
-
case 'slider': {
|
|
203
|
-
return (_jsx(SliderInput, { name: name, defaultValue: defaultValue, disabled: disabled, min: Number(el['min']) || 0, max: Number(el['max']) || 100, step: Number(el['step']) || 1, showValue: Boolean(el['showValue']) }));
|
|
204
|
-
}
|
|
205
|
-
case 'color': {
|
|
206
|
-
return (_jsx(ColorInput, { name: name, defaultValue: defaultValue, disabled: disabled }));
|
|
207
|
-
}
|
|
208
|
-
case 'keyValue': {
|
|
209
|
-
return (_jsx(KeyValueInput, { name: name, defaultValue: defaultValue, disabled: disabled, keyLabel: String(el['keyLabel'] ?? 'Key'), valueLabel: String(el['valueLabel'] ?? 'Value'), addLabel: String(el['addLabel'] ?? 'Add row'), reorderable: Boolean(el['reorderable']) }));
|
|
210
|
-
}
|
|
211
|
-
case 'tagsInput': {
|
|
212
|
-
const suggestions = el['suggestions'] ?? [];
|
|
213
|
-
// separator: omitted → ',' (default); explicit null → null (disabled).
|
|
214
|
-
const separator = 'separator' in el
|
|
215
|
-
? el['separator']
|
|
216
|
-
: ',';
|
|
217
|
-
const splitKeys = el['splitKeys'] ?? ['Enter'];
|
|
218
|
-
const maxTags = typeof el['maxTags'] === 'number' ? el['maxTags'] : null;
|
|
219
|
-
const reorderable = Boolean(el['reorderable']);
|
|
220
|
-
return (_jsx(TagsInput, { name: name, defaultValue: defaultValue, disabled: disabled, placeholder: placeholder, suggestions: suggestions, separator: separator, splitKeys: splitKeys, maxTags: maxTags, reorderable: reorderable }));
|
|
221
|
-
}
|
|
222
|
-
case 'fileUpload': {
|
|
223
|
-
return (_jsx(FileUploadInput, { name: name, defaultValue: defaultValue, disabled: disabled, accept: el['accept'], maxSize: typeof el['maxSize'] === 'number' ? el['maxSize'] : undefined, multiple: Boolean(el['multiple']), preview: el['preview'] !== false, directory: typeof el['directory'] === 'string' ? el['directory'] : undefined, uploadUrl: typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined, downloadable: Boolean(el['downloadable']), openable: Boolean(el['openable']), reorderable: Boolean(el['reorderable']), appendFiles: Boolean(el['appendFiles']), panelLayout: el['panelLayout'] === 'grid' ? 'grid'
|
|
224
|
-
: el['panelLayout'] === 'integrated' ? 'integrated'
|
|
225
|
-
: 'list', ...(el['automaticallyResize'] && typeof el['automaticallyResize'] === 'object'
|
|
226
|
-
? { automaticallyResize: el['automaticallyResize'] }
|
|
227
|
-
: {}), imageEditor: Boolean(el['imageEditor']), circleCropper: Boolean(el['circleCropper']), automaticallyCropImagesToAspectRatio: Boolean(el['automaticallyCropImagesToAspectRatio']), ...(Array.isArray(el['imageEditorAspectRatioOptions'])
|
|
228
|
-
? { imageEditorAspectRatioOptions: el['imageEditorAspectRatioOptions'] }
|
|
229
|
-
: {}) }));
|
|
230
|
-
}
|
|
231
|
-
case 'markdown': {
|
|
232
|
-
const toolbarButtons = el['toolbarButtons'] ?? [];
|
|
233
|
-
return (_jsx(MarkdownInput, { name: name, defaultValue: defaultValue, disabled: disabled, placeholder: placeholder, toolbarButtons: toolbarButtons, minHeight: typeof el['minHeight'] === 'string' ? el['minHeight'] : undefined, maxHeight: typeof el['maxHeight'] === 'string' ? el['maxHeight'] : undefined, fileAttachmentsDirectory: typeof el['fileAttachmentsDirectory'] === 'string' ? el['fileAttachmentsDirectory'] : undefined, fileAttachmentsVisibility: typeof el['fileAttachmentsVisibility'] === 'string' ? el['fileAttachmentsVisibility'] : undefined, uploadUrl: typeof el['uploadUrl'] === 'string' ? el['uploadUrl'] : undefined }));
|
|
234
|
-
}
|
|
235
|
-
case 'repeater':
|
|
236
|
-
return _jsx(RepeaterInput, { el: el, name: name, disabled: disabled });
|
|
237
|
-
case 'builder':
|
|
238
|
-
return _jsx(BuilderInput, { el: el, name: name, disabled: disabled });
|
|
239
|
-
case 'dateTime': {
|
|
240
|
-
// Normalize various input shapes to YYYY-MM-DDTHH:mm.
|
|
241
|
-
let local;
|
|
242
|
-
if (defaultValue instanceof Date) {
|
|
243
|
-
local = isNaN(defaultValue.getTime())
|
|
244
|
-
? undefined
|
|
245
|
-
: defaultValue.toISOString().slice(0, 16);
|
|
246
|
-
}
|
|
247
|
-
else if (typeof defaultValue === 'string' && defaultValue) {
|
|
248
|
-
const parsed = new Date(defaultValue);
|
|
249
|
-
local = isNaN(parsed.getTime()) ? undefined : parsed.toISOString().slice(0, 16);
|
|
250
|
-
}
|
|
251
|
-
return (_jsx(DateTimeInput, { name: name, defaultValue: local, disabled: disabled, placeholder: placeholder }));
|
|
252
|
-
}
|
|
253
|
-
case 'number': {
|
|
254
|
-
const numProps = {};
|
|
255
|
-
if (el['min'] !== undefined)
|
|
256
|
-
numProps['min'] = Number(el['min']);
|
|
257
|
-
if (el['max'] !== undefined)
|
|
258
|
-
numProps['max'] = Number(el['max']);
|
|
259
|
-
if (el['step'] !== undefined)
|
|
260
|
-
numProps['step'] = Number(el['step']);
|
|
261
|
-
return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "number", extraProps: numProps, multiline: false }));
|
|
262
|
-
}
|
|
263
|
-
case 'email':
|
|
264
|
-
return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "email", extraProps: {}, multiline: false }));
|
|
265
|
-
case 'date': {
|
|
266
|
-
// SSR may hand us a JS Date object directly; SPA JSON nav arrives as
|
|
267
|
-
// an ISO string. Normalize both into a `YYYY-MM-DD` slice — naive
|
|
268
|
-
// string slicing on `Date.toString()` ("Mon Apr 27 2026 ...") gives
|
|
269
|
-
// garbage when re-parsed, so handle the Date branch explicitly.
|
|
270
|
-
let iso;
|
|
271
|
-
if (defaultValue instanceof Date) {
|
|
272
|
-
iso = isNaN(defaultValue.getTime())
|
|
273
|
-
? undefined
|
|
274
|
-
: defaultValue.toISOString().slice(0, 10);
|
|
275
|
-
}
|
|
276
|
-
else if (typeof defaultValue === 'string' && defaultValue) {
|
|
277
|
-
const parsed = new Date(defaultValue);
|
|
278
|
-
iso = isNaN(parsed.getTime())
|
|
279
|
-
? undefined
|
|
280
|
-
: parsed.toISOString().slice(0, 10);
|
|
281
|
-
}
|
|
282
|
-
return (_jsx(DateFieldInput, { name: name, defaultValue: iso, disabled: disabled, placeholder: placeholder }));
|
|
283
|
-
}
|
|
284
|
-
case 'slug':
|
|
285
|
-
case 'text':
|
|
286
|
-
default: {
|
|
287
|
-
const textExtra = {};
|
|
288
|
-
if (el['maxLength'] !== undefined)
|
|
289
|
-
textExtra['maxLength'] = Number(el['maxLength']);
|
|
290
|
-
return (_jsx(TextLikeInput, { el: el, name: name, common: common, type: "text", extraProps: textExtra, multiline: false }));
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
/** Drain `notifications[]` from a JSON response into `useToast().notify`. */
|
|
295
|
-
function dispatchNotifications(data, notify) {
|
|
296
|
-
const notifs = data.notifications;
|
|
297
|
-
if (!notifs || notifs.length === 0)
|
|
298
|
-
return;
|
|
299
|
-
for (const n of notifs)
|
|
300
|
-
notify(n);
|
|
301
|
-
}
|
|
302
|
-
/**
|
|
303
|
-
* Fetch + JSON dispatch for form-method actions (Delete-style — no
|
|
304
|
-
* server-rendered <form>, no 303 redirect, no full page reload). Sends
|
|
305
|
-
* `_method` as a body field so Hono's POST handler dispatches the
|
|
306
|
-
* intended verb. On success: drain notifications, SPA-navigate to the
|
|
307
|
-
* server-supplied redirect (or stay on current path if none).
|
|
308
|
-
*
|
|
309
|
-
* Failure modes:
|
|
310
|
-
* - 4xx/5xx with `{ error }`: surfaced as an error toast.
|
|
311
|
-
* - Network errors: error toast with the exception message.
|
|
312
|
-
*/
|
|
313
|
-
async function dispatchMethodAction(url, method, navigate, notify) {
|
|
314
|
-
try {
|
|
315
|
-
const fd = new FormData();
|
|
316
|
-
if (method !== 'post')
|
|
317
|
-
fd.append('_method', method);
|
|
318
|
-
const res = await fetch(url, {
|
|
319
|
-
method: 'POST',
|
|
320
|
-
headers: { 'Accept': 'application/json' },
|
|
321
|
-
body: fd,
|
|
322
|
-
});
|
|
323
|
-
const data = await res.json().catch(() => ({}));
|
|
324
|
-
if (!res.ok) {
|
|
325
|
-
const message = String(data.error ?? `Request failed (${res.status})`);
|
|
326
|
-
notify({ type: 'error', title: 'Action failed', body: message });
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
dispatchNotifications(data, notify);
|
|
330
|
-
const redirect = String(data.redirect ?? '');
|
|
331
|
-
if (redirect)
|
|
332
|
-
navigate(redirect);
|
|
333
|
-
else if (typeof window !== 'undefined')
|
|
334
|
-
navigate(window.location.pathname + window.location.search);
|
|
335
|
-
}
|
|
336
|
-
catch (err) {
|
|
337
|
-
notify({ type: 'error', title: 'Action failed', body: err instanceof Error ? err.message : String(err) });
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
/**
|
|
341
|
-
* Fetch + JSON dispatch for handler-style actions (no schema, no modal,
|
|
342
|
-
* just a button). Sends `ids[]` plus arbitrary `values` fields. Server
|
|
343
|
-
* returns `{ ok, redirect, notifications }` (or `{ ok: false, error }` on
|
|
344
|
-
* failure). On success: drain notifications, SPA-navigate; on failure:
|
|
345
|
-
* surface the error as a toast. No full page reload in any case.
|
|
346
|
-
*/
|
|
347
|
-
export async function dispatchHandlerAction(url, ids, navigate, notify, values = {}, formSnapshot) {
|
|
348
|
-
try {
|
|
349
|
-
// When `formSnapshot` is set (Repeater / Builder `extraItemActions`
|
|
350
|
-
// dispatch), the snapshot already carries the form's full state — we
|
|
351
|
-
// just append `ids` / `values` on top so the server sees both the
|
|
352
|
-
// form body (for coerceFormValues + row hydration) and the action's
|
|
353
|
-
// own meta keys.
|
|
354
|
-
const fd = formSnapshot ?? new FormData();
|
|
355
|
-
for (const id of ids)
|
|
356
|
-
fd.append('ids', id);
|
|
357
|
-
for (const [k, v] of Object.entries(values))
|
|
358
|
-
fd.append(k, v);
|
|
359
|
-
const res = await fetch(url, {
|
|
360
|
-
method: 'POST',
|
|
361
|
-
headers: { 'Accept': 'application/json' },
|
|
362
|
-
body: fd,
|
|
363
|
-
});
|
|
364
|
-
// Download branch — handlers that return `{ download }` ask the server
|
|
365
|
-
// to write the body inline with `Content-Disposition: attachment`. Trip
|
|
366
|
-
// a browser download via a synthetic `<a download>` and exit early
|
|
367
|
-
// (no notify drain / no SPA-nav — the file IS the success signal).
|
|
368
|
-
if (res.ok && triggerDownloadIfAttachment(res)) {
|
|
369
|
-
await res.blob().then(triggerBlobDownload(res));
|
|
370
|
-
return;
|
|
371
|
-
}
|
|
372
|
-
const data = await res.json().catch(() => ({}));
|
|
373
|
-
if (!res.ok) {
|
|
374
|
-
const message = String(data.error ?? `Request failed (${res.status})`);
|
|
375
|
-
notify({ type: 'error', title: 'Action failed', body: message });
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
|
-
dispatchNotifications(data, notify);
|
|
379
|
-
const redirect = String(data.redirect ?? '');
|
|
380
|
-
if (redirect)
|
|
381
|
-
navigate(redirect);
|
|
382
|
-
else if (typeof window !== 'undefined')
|
|
383
|
-
navigate(window.location.pathname + window.location.search);
|
|
384
|
-
}
|
|
385
|
-
catch (err) {
|
|
386
|
-
notify({ type: 'error', title: 'Action failed', body: err instanceof Error ? err.message : String(err) });
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
/** Returns true when the response carries `Content-Disposition: attachment`,
|
|
390
|
-
* which is how the route layer signals a download payload. The header
|
|
391
|
-
* match is case-insensitive (different runtimes normalize differently). */
|
|
392
|
-
function triggerDownloadIfAttachment(res) {
|
|
393
|
-
const cd = res.headers.get('Content-Disposition') ?? res.headers.get('content-disposition') ?? '';
|
|
394
|
-
return cd.toLowerCase().includes('attachment');
|
|
395
|
-
}
|
|
396
|
-
/** Returns a closure that converts the blob into a download by clicking
|
|
397
|
-
* a synthetic `<a download="…">`. Filename is parsed from
|
|
398
|
-
* `Content-Disposition`'s `filename="…"` parameter; falls back to
|
|
399
|
-
* `'download'` when missing. Only mounted when `document` is present
|
|
400
|
-
* (no-op in SSR). */
|
|
401
|
-
function triggerBlobDownload(res) {
|
|
402
|
-
const cd = res.headers.get('Content-Disposition') ?? res.headers.get('content-disposition') ?? '';
|
|
403
|
-
const match = cd.match(/filename\*?=(?:UTF-8'')?["']?([^"';\r\n]+)["']?/i);
|
|
404
|
-
const filename = (match?.[1] ?? 'download').trim();
|
|
405
|
-
return (blob) => {
|
|
406
|
-
if (typeof document === 'undefined' || typeof URL === 'undefined')
|
|
407
|
-
return;
|
|
408
|
-
const objUrl = URL.createObjectURL(blob);
|
|
409
|
-
const a = document.createElement('a');
|
|
410
|
-
a.href = objUrl;
|
|
411
|
-
a.download = filename;
|
|
412
|
-
a.style.display = 'none';
|
|
413
|
-
document.body.appendChild(a);
|
|
414
|
-
a.click();
|
|
415
|
-
a.remove();
|
|
416
|
-
URL.revokeObjectURL(objUrl);
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
/**
|
|
420
|
-
* Modal-form action dialog. Opens a Dialog with an optional form schema
|
|
421
|
-
* (rendered from `meta.children`) plus header/footer chrome from
|
|
422
|
-
* `meta.modal`. On submit, fetches the dispatchUrl with `Accept:
|
|
423
|
-
* application/json` so the server can return:
|
|
424
|
-
* - 200 `{ ok: true, redirect }` → navigate (SPA via useNavigate)
|
|
425
|
-
* - 422 `{ ok: false, errors: { field: string[] } }` → inline errors
|
|
426
|
-
* - 500 `{ ok: false, error }` → server error banner
|
|
427
|
-
*
|
|
428
|
-
* Used for handler-style actions that have a schema and/or a modal config.
|
|
429
|
-
* Replaces the older ConfirmActionDialog for that path; confirm-only
|
|
430
|
-
* actions without a schema also flow through here (no fields rendered,
|
|
431
|
-
* just header + footer = same UX as the old confirm dialog).
|
|
432
|
-
*/
|
|
433
|
-
function ActionModalDialog({ trigger, meta, ids, initialValues = {}, open: controlledOpen, onOpenChange, }) {
|
|
434
|
-
const [internalOpen, setInternalOpen] = useState(false);
|
|
435
|
-
const isControlled = controlledOpen !== undefined;
|
|
436
|
-
const open = isControlled ? controlledOpen : internalOpen;
|
|
437
|
-
const setOpen = (o) => {
|
|
438
|
-
if (isControlled)
|
|
439
|
-
onOpenChange?.(o);
|
|
440
|
-
else
|
|
441
|
-
setInternalOpen(o);
|
|
442
|
-
};
|
|
443
|
-
const [errors, setErrors] = useState({});
|
|
444
|
-
const [serverError, setServerError] = useState(null);
|
|
445
|
-
const [submitting, setSubmitting] = useState(false);
|
|
446
|
-
const navigate = useNavigate();
|
|
447
|
-
const { notify } = useToast();
|
|
448
|
-
const modal = meta['modal'];
|
|
449
|
-
const confirm = meta['confirm'];
|
|
450
|
-
const destructive = Boolean(meta['destructive']);
|
|
451
|
-
const dispatchUrl = meta['dispatchUrl'];
|
|
452
|
-
const fields = (meta.children ?? []);
|
|
453
|
-
const hasForm = fields.length > 0;
|
|
454
|
-
// Filament v5 — auxiliary Elements stamped by the resolver between
|
|
455
|
-
// the body and the footer (Alert / Text / Heading / Action / …).
|
|
456
|
-
const contentFooter = (meta['modalContentFooter'] ?? []);
|
|
457
|
-
const heading = modal?.heading ?? confirm?.title ?? (hasForm ? String(meta['label'] ?? 'Submit') : 'Are you sure?');
|
|
458
|
-
const description = modal?.description ?? confirm?.message;
|
|
459
|
-
const submitLabel = modal?.submitLabel ?? (destructive ? 'Delete' : (hasForm ? 'Submit' : 'Confirm'));
|
|
460
|
-
const cancelLabel = modal?.cancelLabel ?? 'Cancel';
|
|
461
|
-
const widthClass = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl' }[modal?.width ?? 'md'];
|
|
462
|
-
// Modal chrome extras (Tier-2 audit gap #2). Defaults match the
|
|
463
|
-
// previous renderer behaviour exactly — sparse meta keys round-trip
|
|
464
|
-
// as `undefined` so existing modals are byte-identical.
|
|
465
|
-
const closeByClickingAway = modal?.closeByClickingAway !== false;
|
|
466
|
-
const closeByEscaping = modal?.closeByEscaping !== false;
|
|
467
|
-
const stickyHeader = modal?.stickyHeader === true;
|
|
468
|
-
const stickyFooter = modal?.stickyFooter === true;
|
|
469
|
-
const showCloseButton = modal?.closeButton === true;
|
|
470
|
-
const alignmentClass = { start: 'text-left', center: 'text-center sm:text-left', end: 'text-right' }[modal?.alignment ?? 'center'];
|
|
471
|
-
const iconColorClass = modal?.iconColor
|
|
472
|
-
? {
|
|
473
|
-
gray: 'text-muted-foreground',
|
|
474
|
-
primary: 'text-primary',
|
|
475
|
-
success: 'text-emerald-600 dark:text-emerald-300',
|
|
476
|
-
warning: 'text-amber-600 dark:text-amber-300',
|
|
477
|
-
destructive: 'text-destructive',
|
|
478
|
-
info: 'text-blue-600 dark:text-blue-300',
|
|
479
|
-
}[modal.iconColor]
|
|
480
|
-
: undefined;
|
|
481
|
-
// Existing default: only the submit button autofocuses (and only for
|
|
482
|
-
// confirm-only modals). When `modalAutofocus(false)` is set the user
|
|
483
|
-
// wants nothing to autofocus; `modalAutofocus(true)` shifts focus to
|
|
484
|
-
// the first form input via a mount-effect ref.
|
|
485
|
-
const explicitAutofocus = modal?.autofocus;
|
|
486
|
-
const submitAutofocus = explicitAutofocus === false ? false
|
|
487
|
-
: explicitAutofocus === true ? !hasForm
|
|
488
|
-
: !hasForm;
|
|
489
|
-
const formRef = useRef(null);
|
|
490
|
-
useEffect(() => {
|
|
491
|
-
if (!open || explicitAutofocus !== true || !hasForm)
|
|
492
|
-
return;
|
|
493
|
-
// Wait for the popup to mount + fields to render. Microtask is enough
|
|
494
|
-
// because Base UI's mount transition is decoupled from our render.
|
|
495
|
-
const id = window.requestAnimationFrame(() => {
|
|
496
|
-
const form = formRef.current;
|
|
497
|
-
if (!form)
|
|
498
|
-
return;
|
|
499
|
-
const target = form.querySelector('input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])');
|
|
500
|
-
if (target)
|
|
501
|
-
target.focus();
|
|
502
|
-
});
|
|
503
|
-
return () => window.cancelAnimationFrame(id);
|
|
504
|
-
}, [open, explicitAutofocus, hasForm]);
|
|
505
|
-
const reset = () => { setErrors({}); setServerError(null); setSubmitting(false); };
|
|
506
|
-
const onSubmit = async (e) => {
|
|
507
|
-
e.preventDefault();
|
|
508
|
-
if (!dispatchUrl)
|
|
509
|
-
return;
|
|
510
|
-
setSubmitting(true);
|
|
511
|
-
setServerError(null);
|
|
512
|
-
setErrors({});
|
|
513
|
-
const fd = new FormData(e.currentTarget);
|
|
514
|
-
for (const id of ids)
|
|
515
|
-
fd.append('ids', id);
|
|
516
|
-
try {
|
|
517
|
-
const res = await fetch(dispatchUrl, {
|
|
518
|
-
method: 'POST',
|
|
519
|
-
headers: { 'Accept': 'application/json' },
|
|
520
|
-
body: fd,
|
|
521
|
-
});
|
|
522
|
-
const data = await res.json().catch(() => ({}));
|
|
523
|
-
if (res.status === 422) {
|
|
524
|
-
setErrors(data.errors ?? {});
|
|
525
|
-
setSubmitting(false);
|
|
526
|
-
return;
|
|
527
|
-
}
|
|
528
|
-
if (!res.ok) {
|
|
529
|
-
setServerError(String(data.error ?? `Request failed (${res.status})`));
|
|
530
|
-
setSubmitting(false);
|
|
531
|
-
return;
|
|
532
|
-
}
|
|
533
|
-
setOpen(false);
|
|
534
|
-
reset();
|
|
535
|
-
// Server-emitted notifications come through the JSON response;
|
|
536
|
-
// surface them via the Toaster before navigating so the user
|
|
537
|
-
// sees the success/error toast even when navigation re-renders.
|
|
538
|
-
const notifs = data.notifications;
|
|
539
|
-
if (notifs && notifs.length > 0) {
|
|
540
|
-
for (const n of notifs)
|
|
541
|
-
notify(n);
|
|
542
|
-
}
|
|
543
|
-
const redirect = String(data.redirect ?? '');
|
|
544
|
-
if (redirect)
|
|
545
|
-
navigate(redirect);
|
|
546
|
-
else if (typeof window !== 'undefined')
|
|
547
|
-
navigate(window.location.pathname + window.location.search);
|
|
548
|
-
}
|
|
549
|
-
catch (err) {
|
|
550
|
-
setServerError(err instanceof Error ? err.message : 'Submit failed');
|
|
551
|
-
setSubmitting(false);
|
|
552
|
-
}
|
|
553
|
-
};
|
|
554
|
-
const cancelClass = 'inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground';
|
|
555
|
-
const confirmClass = destructive
|
|
556
|
-
? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50'
|
|
557
|
-
: 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50';
|
|
558
|
-
// Resolved icon component for the modal header (Filament-style chrome
|
|
559
|
-
// — leading glyph next to the heading). Passed through `useIconFor`
|
|
560
|
-
// for the same registry lookup used by Resource / Page / Action icons.
|
|
561
|
-
const HeaderIcon = useIconFor(modal?.icon);
|
|
562
|
-
// Build a className for the popup that respects width + sticky-chrome
|
|
563
|
-
// + slideOver. Sticky modes give the popup a max height + overflow so
|
|
564
|
-
// the inner scroll surface exists for sticky to bite onto.
|
|
565
|
-
const stickyMode = stickyHeader || stickyFooter;
|
|
566
|
-
const popupClass = [
|
|
567
|
-
widthClass,
|
|
568
|
-
stickyMode ? 'max-h-[90vh] overflow-hidden p-0' : '',
|
|
569
|
-
].filter(Boolean).join(' ');
|
|
570
|
-
// Inner scroll body (only used in sticky mode). When inactive the
|
|
571
|
-
// existing flat layout applies (header / fields / footer flow).
|
|
572
|
-
const headerCls = `${alignmentClass} ${stickyHeader ? 'sticky top-0 bg-background z-10 px-6 pt-6 pb-3 border-b' : ''}`.trim();
|
|
573
|
-
const footerCls = stickyFooter ? 'sticky bottom-0 bg-background z-10 px-6 py-3 border-t' : '';
|
|
574
|
-
const bodyCls = stickyMode ? 'flex-1 overflow-y-auto px-6 py-3' : '';
|
|
575
|
-
const formCls = stickyMode ? 'flex flex-col h-full' : '';
|
|
576
|
-
return (_jsxs(_Fragment, { children: [trigger?.(() => { reset(); setOpen(true); }), _jsx(Dialog, { open: open, disablePointerDismissal: !closeByClickingAway, onOpenChange: (o, details) => {
|
|
577
|
-
// Cancel Esc-triggered closes when the user has opted out.
|
|
578
|
-
// Base UI's `details.cancel()` aborts the open-state change.
|
|
579
|
-
if (!o && !closeByEscaping && details && details.reason === 'escapeKey') {
|
|
580
|
-
const cancel = details.cancel;
|
|
581
|
-
if (typeof cancel === 'function')
|
|
582
|
-
cancel();
|
|
583
|
-
return;
|
|
584
|
-
}
|
|
585
|
-
if (!o)
|
|
586
|
-
reset();
|
|
587
|
-
setOpen(o);
|
|
588
|
-
}, children: _jsxs(DialogContent, { className: popupClass, children: [showCloseButton && (_jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOpen(false), className: "absolute top-3 right-3 z-20 inline-flex items-center justify-center rounded-md h-8 w-8 text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: _jsx(XIcon, { className: "size-4" }) })), _jsxs("form", { ref: formRef, onSubmit: onSubmit, className: formCls, children: [_jsxs(DialogHeader, { className: headerCls, children: [_jsxs(DialogTitle, { className: modal?.icon ? 'flex items-center gap-2' : undefined, children: [HeaderIcon && (_jsx(HeaderIcon, { "aria-hidden": true, className: `size-5 shrink-0 ${iconColorClass ?? ''}`.trim() })), _jsx("span", { children: heading })] }), description && _jsx(DialogDescription, { children: description })] }), (hasForm || contentFooter.length > 0) && (_jsxs("div", { className: `flex flex-col gap-3 py-2 ${bodyCls}`.trim(), children: [fields.map((f, i) => renderFormChild(f, i, initialValues, errors)), contentFooter.map((c, i) => renderElement(c, fields.length + i))] })), !hasForm && contentFooter.length === 0 && stickyMode && _jsx("div", { className: bodyCls }), serverError && (_jsx("p", { className: `py-2 text-sm text-destructive ${stickyMode ? 'px-6' : ''}`.trim(), children: serverError })), _jsxs(DialogFooter, { className: footerCls, children: [_jsx("button", { type: "button", onClick: () => setOpen(false), className: cancelClass, children: cancelLabel }), _jsx("button", { type: "submit", disabled: submitting, autoFocus: submitAutofocus, className: confirmClass, children: submitting ? 'Working…' : submitLabel })] })] })] }) })] }));
|
|
589
|
-
}
|
|
590
|
-
/**
|
|
591
|
-
* Confirm-style dialog wrapping an action's button. The trigger button is
|
|
592
|
-
* rendered inline; clicking it opens the dialog. On confirm we run
|
|
593
|
-
* `onConfirm` (which is action-style-specific — submit a form, programmatic
|
|
594
|
-
* POST, etc.) and close the dialog. Used by submit-style and form-method
|
|
595
|
-
* actions; handler-style + confirm/modal flows through ActionModalDialog
|
|
596
|
-
* instead.
|
|
597
|
-
*/
|
|
598
|
-
function ConfirmActionDialog({ trigger, title, message, destructive, onConfirm, }) {
|
|
599
|
-
const [open, setOpen] = useState(false);
|
|
600
|
-
const confirmClass = destructive
|
|
601
|
-
? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90'
|
|
602
|
-
: 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90';
|
|
603
|
-
return (_jsxs(_Fragment, { children: [trigger(() => setOpen(true)), _jsx(Dialog, { open: open, onOpenChange: setOpen, children: _jsxs(DialogContent, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: title ?? 'Are you sure?' }), _jsx(DialogDescription, { children: message })] }), _jsxs(DialogFooter, { children: [_jsx("button", { type: "button", onClick: () => setOpen(false), className: "inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: "Cancel" }), _jsx("button", { type: "button", onClick: () => { setOpen(false); onConfirm(); }, className: confirmClass, autoFocus: true, children: destructive ? 'Delete' : 'Confirm' })] })] }) })] }));
|
|
604
|
-
}
|
|
605
|
-
/**
|
|
606
|
-
* Button + optional confirm dialog for a form-method action (Delete and
|
|
607
|
-
* the like). Click → fetch + JSON dispatch via `dispatchMethodAction` —
|
|
608
|
-
* no full page reload, no server-rendered form. Confirm dialog gates the
|
|
609
|
-
* dispatch when configured.
|
|
610
|
-
*/
|
|
611
|
-
function MethodActionButton({ url, method, confirm, destructive, className, name, ariaLabel, tooltip, inner, }) {
|
|
612
|
-
const navigate = useNavigate();
|
|
613
|
-
const { notify } = useToast();
|
|
614
|
-
const dispatch = () => {
|
|
615
|
-
if (!url)
|
|
616
|
-
return;
|
|
617
|
-
void dispatchMethodAction(url, method, navigate, notify);
|
|
618
|
-
};
|
|
619
|
-
if (confirm) {
|
|
620
|
-
return (_jsx(ConfirmActionDialog, { title: confirm.title, message: confirm.message, destructive: destructive, onConfirm: dispatch, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }));
|
|
621
|
-
}
|
|
622
|
-
return withTooltip(_jsx("button", { type: "button", onClick: dispatch, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip);
|
|
623
|
-
}
|
|
624
|
-
/**
|
|
625
|
-
* Button for a handler-style action without confirm/modal. Click →
|
|
626
|
-
* fetch + JSON via `dispatchHandlerAction`, then SPA-navigate +
|
|
627
|
-
* show notifications. No full page reload.
|
|
628
|
-
*/
|
|
629
|
-
function HandlerActionButton({ url, ids, className, name, ariaLabel, tooltip, inner, }) {
|
|
630
|
-
const navigate = useNavigate();
|
|
631
|
-
const { notify } = useToast();
|
|
632
|
-
return withTooltip(_jsx("button", { type: "button", onClick: () => void dispatchHandlerAction(url, ids, navigate, notify), className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip);
|
|
633
|
-
}
|
|
634
|
-
/** Render either a single Action or an ActionGroup based on `el.type`.
|
|
635
|
-
* Used by callsites that accept both (table header / bulk toolbars,
|
|
636
|
-
* heading actions, container schemas). */
|
|
42
|
+
/** Thin wrapper that binds the renderer-injected deps so call sites
|
|
43
|
+
* inside this file can keep the original three-arg signature. The
|
|
44
|
+
* action layer (Phase 3) lives behind `renderActionLikeImpl`; it needs
|
|
45
|
+
* `renderElement` + `renderFormChild` for nested schemas + modal-form
|
|
46
|
+
* bodies. Both are function declarations so hoisting handles the
|
|
47
|
+
* forward reference cleanly. */
|
|
637
48
|
function renderActionLike(el, index, opts = {}) {
|
|
638
|
-
|
|
639
|
-
// Plugin-contributed React mount — render through the main element
|
|
640
|
-
// dispatcher, which looks up the registered component and forwards
|
|
641
|
-
// its serialised props bag. Keeps every action-row slot (heading
|
|
642
|
-
// children, alert footer, empty-state footer, table-toolbar bulk
|
|
643
|
-
// strip) usable as a plugin extension point.
|
|
644
|
-
return renderElement(el, index);
|
|
645
|
-
}
|
|
646
|
-
if (el.type === 'actionGroup') {
|
|
647
|
-
return _jsx(ActionGroupTrigger, { el: el, ids: opts.ids ?? [] }, index);
|
|
648
|
-
}
|
|
649
|
-
return renderAction(el, index, opts);
|
|
650
|
-
}
|
|
651
|
-
/** Color preset → tailwind class group. `ghost` is bg-less and works
|
|
652
|
-
* with hover:bg-accent. `destructive` uses a soft tonal style (Filament-
|
|
653
|
-
* style) so per-row Delete buttons sit calmly next to primary actions
|
|
654
|
-
* instead of shouting in saturated red — the modal confirm CTA still
|
|
655
|
-
* renders solid red via its own hardcoded class. Others are solid + hover-
|
|
656
|
-
* darken. */
|
|
657
|
-
const COLOR_VARIANTS = {
|
|
658
|
-
primary: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
|
659
|
-
destructive: 'bg-red-50 text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-400 dark:hover:bg-red-950/60',
|
|
660
|
-
success: 'bg-emerald-600 text-white hover:bg-emerald-600/90',
|
|
661
|
-
warning: 'bg-amber-500 text-white hover:bg-amber-500/90',
|
|
662
|
-
info: 'bg-blue-600 text-white hover:bg-blue-600/90',
|
|
663
|
-
ghost: 'bg-transparent text-foreground hover:bg-accent hover:text-accent-foreground',
|
|
664
|
-
};
|
|
665
|
-
/** Outlined variant — replaces solid bg with a border + transparent bg. */
|
|
666
|
-
const OUTLINED_VARIANTS = {
|
|
667
|
-
primary: 'border border-primary/40 text-primary bg-transparent hover:bg-primary/10',
|
|
668
|
-
destructive: 'border border-destructive/40 text-destructive bg-transparent hover:bg-destructive/10',
|
|
669
|
-
success: 'border border-emerald-600/40 text-emerald-700 dark:text-emerald-400 bg-transparent hover:bg-emerald-600/10',
|
|
670
|
-
warning: 'border border-amber-500/40 text-amber-700 dark:text-amber-400 bg-transparent hover:bg-amber-500/10',
|
|
671
|
-
info: 'border border-blue-600/40 text-blue-700 dark:text-blue-400 bg-transparent hover:bg-blue-600/10',
|
|
672
|
-
ghost: 'border border-input text-foreground bg-transparent hover:bg-accent',
|
|
673
|
-
};
|
|
674
|
-
/** Size preset → tailwind sizing classes. Icon-only buttons use the
|
|
675
|
-
* width=height variants from the second map. */
|
|
676
|
-
const SIZE_CLASSES = {
|
|
677
|
-
sm: 'h-7 px-2 text-xs',
|
|
678
|
-
md: 'h-8 px-3 text-sm',
|
|
679
|
-
lg: 'h-10 px-4 text-base',
|
|
680
|
-
};
|
|
681
|
-
const ICON_SIZE_CLASSES = {
|
|
682
|
-
sm: 'h-7 w-7 text-xs',
|
|
683
|
-
md: 'h-8 w-8 text-sm',
|
|
684
|
-
lg: 'h-10 w-10 text-base',
|
|
685
|
-
};
|
|
686
|
-
/** Build the trigger button className from action meta + render context. */
|
|
687
|
-
function actionButtonClass(el, opts) {
|
|
688
|
-
const destructive = Boolean(el['destructive']);
|
|
689
|
-
const placement = String(el['placement'] ?? 'inline');
|
|
690
|
-
const outlined = Boolean(el['outlined']);
|
|
691
|
-
const iconOnly = Boolean(el['iconOnly']);
|
|
692
|
-
const explicitColor = el['color'];
|
|
693
|
-
const explicitSize = el['size'];
|
|
694
|
-
// Color: explicit `.color()` wins; `destructive` flag falls back to
|
|
695
|
-
// 'destructive'; otherwise 'primary'.
|
|
696
|
-
const color = explicitColor ?? (destructive ? 'destructive' : 'primary');
|
|
697
|
-
const variant = (outlined ? OUTLINED_VARIANTS[color] : COLOR_VARIANTS[color]) ?? COLOR_VARIANTS['primary'];
|
|
698
|
-
// Size: explicit `.size()` wins; otherwise small for row context, md elsewhere.
|
|
699
|
-
const size = explicitSize ?? (opts.size === 'sm' || placement === 'row' ? 'sm' : 'md');
|
|
700
|
-
const sizingMap = iconOnly ? ICON_SIZE_CLASSES : SIZE_CLASSES;
|
|
701
|
-
const sizing = sizingMap[size] ?? sizingMap['md'];
|
|
702
|
-
return `relative inline-flex items-center justify-center gap-1.5 rounded-md font-medium transition ${variant} ${sizing}`;
|
|
703
|
-
}
|
|
704
|
-
/** Render the action's icon (when set). String names resolve through the
|
|
705
|
-
* user-extensible icon registry; missing names render nothing rather
|
|
706
|
-
* than a fallback glyph (action icons are decorative, not load-bearing). */
|
|
707
|
-
function renderActionIcon(el) {
|
|
708
|
-
const name = typeof el['icon'] === 'string' ? el['icon'] : undefined;
|
|
709
|
-
const Icon = resolveIcon(name);
|
|
710
|
-
if (!Icon)
|
|
711
|
-
return null;
|
|
712
|
-
return _jsx(Icon, { className: "size-4", "aria-hidden": "true" });
|
|
713
|
-
}
|
|
714
|
-
/** Tiny corner badge for actions that set `.badge(...)`. */
|
|
715
|
-
function renderActionBadge(el) {
|
|
716
|
-
const value = el['badge'];
|
|
717
|
-
if (value === undefined || value === null || value === '')
|
|
718
|
-
return null;
|
|
719
|
-
const color = el['badgeColor'] ?? 'bg-primary text-primary-foreground';
|
|
720
|
-
return (_jsx("span", { className: `absolute -top-1 -right-1 inline-flex h-4 min-w-4 items-center justify-center rounded-full px-1 text-[10px] font-medium ${color}`, children: String(value) }));
|
|
721
|
-
}
|
|
722
|
-
/** If `meta.tooltip` is set, wrap the trigger in a Tooltip. The Tooltip's
|
|
723
|
-
* provider mounts on demand so multiple actions on a page don't share
|
|
724
|
-
* state. */
|
|
725
|
-
function withTooltip(node, tooltip) {
|
|
726
|
-
if (!tooltip)
|
|
727
|
-
return node;
|
|
728
|
-
return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: () => node }), _jsx(TooltipContent, { children: tooltip })] }) }));
|
|
729
|
-
}
|
|
730
|
-
function renderAction(el, index, opts = {}) {
|
|
731
|
-
const name = String(el['name'] ?? '');
|
|
732
|
-
const label = String(el['label'] ?? name);
|
|
733
|
-
const destructive = Boolean(el['destructive']);
|
|
734
|
-
const href = el['href'];
|
|
735
|
-
const method = el['method'];
|
|
736
|
-
const actionUrl = el['action'];
|
|
737
|
-
const dispatchUrl = el['dispatchUrl'];
|
|
738
|
-
const submit = Boolean(el['submit']);
|
|
739
|
-
const confirm = el['confirm'];
|
|
740
|
-
const tooltip = el['tooltip'];
|
|
741
|
-
const iconOnly = Boolean(el['iconOnly']);
|
|
742
|
-
const isDisabled = Boolean(el['disabled']);
|
|
743
|
-
const className = actionButtonClass(el, opts) + (isDisabled ? ' opacity-50 cursor-not-allowed pointer-events-none' : '');
|
|
744
|
-
const icon = renderActionIcon(el);
|
|
745
|
-
const badge = renderActionBadge(el);
|
|
746
|
-
// Icon-only buttons hide the label visually but expose it via aria-label.
|
|
747
|
-
const ariaLabel = iconOnly ? label : undefined;
|
|
748
|
-
const inner = iconOnly ? _jsxs(_Fragment, { children: [icon, badge] }) : _jsxs(_Fragment, { children: [icon, _jsx("span", { children: label }), badge] });
|
|
749
|
-
// Submit-style action — renders as <button type="submit">. Optionally
|
|
750
|
-
// targets a specific form via the HTML `form="<id>"` attribute so the
|
|
751
|
-
// button can submit a form it lives outside of (e.g. a page-header
|
|
752
|
-
// Save button driving a form below). When `formField` is set, the
|
|
753
|
-
// button posts a sentinel name/value pair (e.g. `_continueCreate=1`)
|
|
754
|
-
// so the server can branch on which submit was clicked.
|
|
755
|
-
if (submit) {
|
|
756
|
-
const formTarget = el['form'];
|
|
757
|
-
const formField = el['formField'];
|
|
758
|
-
if (confirm) {
|
|
759
|
-
// Confirm-gated submit: render as type="button" so click opens the
|
|
760
|
-
// dialog instead of submitting; on confirm, programmatically submit
|
|
761
|
-
// the targeted form (or the closest enclosing form if no formTarget).
|
|
762
|
-
// `formField` is intentionally not threaded here — programmatic
|
|
763
|
-
// `requestSubmit()` has no submitter, so the name/value pair would
|
|
764
|
-
// be lost anyway. Pair `.confirm()` with a hidden input on the form
|
|
765
|
-
// if you need a sentinel under a confirm flow.
|
|
766
|
-
return (_jsx(ConfirmActionDialog, { title: confirm.title, message: confirm.message, destructive: destructive, onConfirm: () => {
|
|
767
|
-
if (typeof document === 'undefined')
|
|
768
|
-
return;
|
|
769
|
-
const form = formTarget
|
|
770
|
-
? document.getElementById(formTarget)
|
|
771
|
-
: document.querySelector('form');
|
|
772
|
-
form?.requestSubmit();
|
|
773
|
-
}, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }, index));
|
|
774
|
-
}
|
|
775
|
-
return withTooltip(_jsx("button", { type: "submit", form: formTarget, className: className, "data-action-name": name, "aria-label": ariaLabel, ...(formField ? { name: formField.name, value: formField.value } : {}), children: inner }, index), tooltip);
|
|
776
|
-
}
|
|
777
|
-
// Substitute the `:id` placeholder with the current row id when this
|
|
778
|
-
// action is rendered in a row context. Lets row-level link/form actions
|
|
779
|
-
// ship a single template URL like `/admin/articles/:id/edit`.
|
|
780
|
-
const rowId = opts.ids?.length === 1 ? opts.ids[0] : undefined;
|
|
781
|
-
const resolveTemplate = (s) => s && rowId ? s.replace(':id', rowId) : s;
|
|
782
|
-
// Link-style action.
|
|
783
|
-
if (href) {
|
|
784
|
-
return withTooltip(_jsx("a", { href: resolveTemplate(href), className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }, index), tooltip);
|
|
785
|
-
}
|
|
786
|
-
// Form-style action (POST/PUT/PATCH/DELETE) — fetch + JSON, no full reload.
|
|
787
|
-
if (method) {
|
|
788
|
-
const resolvedUrl = resolveTemplate(actionUrl);
|
|
789
|
-
return (_jsx(MethodActionButton, { url: resolvedUrl, method: method, confirm: confirm, destructive: destructive, className: className, name: name, ariaLabel: ariaLabel, tooltip: tooltip, inner: inner }, index));
|
|
790
|
-
}
|
|
791
|
-
// Handler-style action — fetch + JSON dispatch with `ids[]` body.
|
|
792
|
-
if (dispatchUrl) {
|
|
793
|
-
const ids = opts.ids ?? [];
|
|
794
|
-
const modal = el['modal'];
|
|
795
|
-
if (confirm || modal) {
|
|
796
|
-
return (_jsx(ActionModalDialog, { meta: el, ids: ids, trigger: (open) => withTooltip(_jsx("button", { type: "button", onClick: open, className: className, "data-action-name": name, "aria-label": ariaLabel, children: inner }), tooltip) }, index));
|
|
797
|
-
}
|
|
798
|
-
return (_jsx(HandlerActionButton, { url: dispatchUrl, ids: ids, className: className, name: name, ariaLabel: ariaLabel, tooltip: tooltip, inner: inner }, index));
|
|
799
|
-
}
|
|
800
|
-
// No dispatch wired (no href / method / dispatchUrl). Render a disabled
|
|
801
|
-
// placeholder so the user sees the button, but it does nothing.
|
|
802
|
-
return withTooltip(_jsx("button", { type: "button", disabled: true, className: className + ' opacity-50 cursor-not-allowed', "data-action-name": name, "aria-label": ariaLabel, children: inner }, index), tooltip);
|
|
803
|
-
}
|
|
804
|
-
// ─── Layout helpers ─────────────────────────────────────────
|
|
805
|
-
/**
|
|
806
|
-
* Map `meta._layout` (Plan #8 — `columnSpan / columnStart / columnOrder`)
|
|
807
|
-
* onto Tailwind utility classes. Returns an empty string when the
|
|
808
|
-
* element has no layout hints. Outside of a parent Grid/Split the
|
|
809
|
-
* classes have no effect — Tailwind generates `col-span-*` /
|
|
810
|
-
* `col-start-*` / `order-*` regardless of context.
|
|
811
|
-
*
|
|
812
|
-
* Tailwind's JIT only ships utilities up to a fixed range; clamp here
|
|
813
|
-
* to the safe defaults (1..12 for col, 1..12 for order).
|
|
814
|
-
*/
|
|
815
|
-
function layoutClasses(el) {
|
|
816
|
-
const layout = el['_layout'];
|
|
817
|
-
if (!layout)
|
|
818
|
-
return '';
|
|
819
|
-
const out = [];
|
|
820
|
-
if (typeof layout.columnSpan === 'number') {
|
|
821
|
-
const span = Math.max(1, Math.min(12, layout.columnSpan));
|
|
822
|
-
out.push(`col-span-${span}`);
|
|
823
|
-
}
|
|
824
|
-
if (typeof layout.columnStart === 'number') {
|
|
825
|
-
const start = Math.max(1, Math.min(12, layout.columnStart));
|
|
826
|
-
out.push(`col-start-${start}`);
|
|
827
|
-
}
|
|
828
|
-
if (typeof layout.columnOrder === 'number') {
|
|
829
|
-
const order = Math.max(1, Math.min(12, layout.columnOrder));
|
|
830
|
-
out.push(`order-${order}`);
|
|
831
|
-
}
|
|
832
|
-
return out.join(' ');
|
|
833
|
-
}
|
|
834
|
-
// ─── Container helpers ──────────────────────────────────────
|
|
835
|
-
function renderChildren(children, gap = 'gap-4') {
|
|
836
|
-
if (!children || children.length === 0)
|
|
837
|
-
return null;
|
|
838
|
-
return (_jsx("div", { className: `flex flex-col ${gap}`, children: children.map((child, i) => renderElement(child, i)) }));
|
|
839
|
-
}
|
|
840
|
-
// ─── Tabs (stateful — needs useState) ────────────────────────
|
|
841
|
-
/**
|
|
842
|
-
* Active-filters bar — pill row above the table summarising every filter
|
|
843
|
-
* with a current value. Each pill shows the filter's `indicator` text
|
|
844
|
-
* (server-formatted via `Filter.indicator()` / per-subclass defaults) and
|
|
845
|
-
* an `×` button that clears that filter's URL key in place. Clicking ×
|
|
846
|
-
* also drops `?page` so users land on the first page of the relaxed set.
|
|
847
|
-
*
|
|
848
|
-
* Renders nothing when no filter has an indicator.
|
|
849
|
-
*/
|
|
850
|
-
function ActiveFiltersBar({ filters, prefix }) {
|
|
851
|
-
const navigate = useNavigate();
|
|
852
|
-
const active = filters.filter(f => typeof f['indicator'] === 'string' && f['indicator'] !== '');
|
|
853
|
-
if (active.length === 0)
|
|
854
|
-
return null;
|
|
855
|
-
const clear = (name) => {
|
|
856
|
-
if (typeof window === 'undefined')
|
|
857
|
-
return;
|
|
858
|
-
const url = new URL(window.location.href);
|
|
859
|
-
url.searchParams.delete(prefixK(prefix, name));
|
|
860
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
861
|
-
void navigate(url.pathname + url.search);
|
|
862
|
-
};
|
|
863
|
-
const clearAll = () => {
|
|
864
|
-
if (typeof window === 'undefined')
|
|
865
|
-
return;
|
|
866
|
-
const url = new URL(window.location.href);
|
|
867
|
-
for (const f of active)
|
|
868
|
-
url.searchParams.delete(prefixK(prefix, String(f['name'] ?? '')));
|
|
869
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
870
|
-
void navigate(url.pathname + url.search);
|
|
871
|
-
};
|
|
872
|
-
return (_jsxs("div", { className: "flex flex-wrap items-center gap-2 text-xs", children: [active.map((f, i) => {
|
|
873
|
-
const name = String(f['name'] ?? '');
|
|
874
|
-
const indicator = String(f['indicator'] ?? '');
|
|
875
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-1 rounded-full border border-border bg-muted/40 pl-2.5 pr-1 py-0.5", children: [_jsx("span", { children: indicator }), _jsx("button", { type: "button", onClick: () => clear(name), "aria-label": `Clear filter ${indicator}`, className: "inline-flex size-4 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground", children: "\u00D7" })] }, i));
|
|
876
|
-
}), active.length > 1 && (_jsx("button", { type: "button", onClick: clearAll, className: "text-muted-foreground hover:text-foreground underline-offset-2 hover:underline", children: "Clear all" }))] }));
|
|
877
|
-
}
|
|
878
|
-
/**
|
|
879
|
-
* Filter icon button + Popover containing every filter control.
|
|
880
|
-
* Opens on click; the inner Selects don't dismiss the outer Popover when
|
|
881
|
-
* an option is chosen (Base UI Popover doesn't auto-close on inner clicks).
|
|
882
|
-
*
|
|
883
|
-
* Each FilterSelect navigates the page on change (window.location), so the
|
|
884
|
-
* filter form is no longer needed — keeps the search input in its own
|
|
885
|
-
* lightweight form for native Enter-to-submit.
|
|
886
|
-
*/
|
|
887
|
-
function FilterPopover({ filters, prefix }) {
|
|
888
|
-
const activeCount = filters.filter(f => {
|
|
889
|
-
const v = f['value'];
|
|
890
|
-
return typeof v === 'string' && v !== '';
|
|
891
|
-
}).length;
|
|
892
|
-
return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { render: (props) => (_jsxs("button", { ...props, type: "button", "aria-label": "Filters", className: "relative inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: [_jsx(FilterIcon, { className: "size-4" }), _jsx("span", { children: "Filters" }), activeCount > 0 && (_jsx("span", { className: "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground", children: activeCount }))] })) }), _jsx(PopoverContent, { align: "start", className: filters.some(f => f['kind'] === 'queryBuilder')
|
|
893
|
-
? 'w-[36rem] max-w-[calc(100vw-2rem)] p-3'
|
|
894
|
-
: 'w-72 p-3', children: _jsx("div", { className: "flex flex-col gap-3", children: filters.map((f, i) => renderFilterControl(f, i, prefix)) }) })] }));
|
|
895
|
-
}
|
|
896
|
-
/**
|
|
897
|
-
* Inline strip of filter controls — used by `Table.filtersLayout('above-content'
|
|
898
|
-
* | 'above-content-collapsible' | 'below-content')`. Mirrors `FilterPopover`'s
|
|
899
|
-
* inner body but lays the controls out in a wrapping row instead of a
|
|
900
|
-
* vertical stack inside a popover.
|
|
901
|
-
*/
|
|
902
|
-
function FilterStrip({ filters, prefix }) {
|
|
903
|
-
if (filters.length === 0)
|
|
904
|
-
return null;
|
|
905
|
-
return (_jsx("div", { className: "flex flex-col gap-3 rounded-md border bg-muted/30 p-3 sm:flex-row sm:flex-wrap sm:items-end", children: filters.map((f, i) => (_jsx("div", { className: "min-w-[12rem] flex-1 sm:max-w-xs", children: renderFilterControl(f, i, prefix) }, i))) }));
|
|
906
|
-
}
|
|
907
|
-
/**
|
|
908
|
-
* Toolbar button paired with `FilterStrip` for `Table.filtersLayout(
|
|
909
|
-
* 'above-content-collapsible')`. Visually matches the modal-mode trigger
|
|
910
|
-
* (filter icon + "Filters" label + active-count badge) but flips a parent-
|
|
911
|
-
* owned `open` state instead of opening a Popover.
|
|
912
|
-
*/
|
|
913
|
-
function FilterStripToggle({ filters, open, onToggle, }) {
|
|
914
|
-
const activeCount = filters.filter(f => {
|
|
915
|
-
const v = f['value'];
|
|
916
|
-
return typeof v === 'string' && v !== '';
|
|
917
|
-
}).length;
|
|
918
|
-
return (_jsxs("button", { type: "button", "aria-label": "Filters", "aria-expanded": open, onClick: onToggle, className: "relative inline-flex h-9 items-center justify-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: [_jsx(FilterIcon, { className: "size-4" }), _jsx("span", { children: "Filters" }), activeCount > 0 && (_jsx("span", { className: "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-xs font-medium text-primary-foreground", children: activeCount }))] }));
|
|
919
|
-
}
|
|
920
|
-
/**
|
|
921
|
-
* Render row actions inline. Each Action becomes a small button next to
|
|
922
|
-
* the others; an `ActionGroup` placed in row position keeps its dropdown
|
|
923
|
-
* via `ActionGroupTrigger` (the dropdown UX is opt-in via grouping, not
|
|
924
|
-
* a default). Per-row visibility and disabled state come from the
|
|
925
|
-
* server-side eval inside `dispatchTable` (`_visibleActions` /
|
|
926
|
-
* `_disabledActions` keys on the row).
|
|
927
|
-
*
|
|
928
|
-
* Each Action's dispatch (link / fetch+JSON / modal / confirm) is handled
|
|
929
|
-
* by `renderActionLike` → `renderAction`, same path as header / inline /
|
|
930
|
-
* bulk placements. The `:id` substitution comes from `opts.ids = [rowId]`.
|
|
931
|
-
*/
|
|
932
|
-
function renderRowActions(rowId, rowRecord, actions) {
|
|
933
|
-
const rowVisibleSet = new Set(rowRecord?.['_visibleActions'] ?? []);
|
|
934
|
-
const rowDisabledSet = new Set(rowRecord?.['_disabledActions'] ?? []);
|
|
935
|
-
const visible = actions.filter(a => {
|
|
936
|
-
if (!a['conditional'])
|
|
937
|
-
return true;
|
|
938
|
-
return rowVisibleSet.has(String(a['name'] ?? ''));
|
|
939
|
-
});
|
|
940
|
-
const decorate = (a) => {
|
|
941
|
-
const name = String(a['name'] ?? '');
|
|
942
|
-
if (rowDisabledSet.has(name)) {
|
|
943
|
-
return { ...a, disabled: true };
|
|
944
|
-
}
|
|
945
|
-
return a;
|
|
946
|
-
};
|
|
947
|
-
return (_jsx("div", { className: "flex items-center justify-end gap-1", children: visible.map((a, i) => renderActionLike(decorate(a), i, { ids: [rowId], size: 'sm' })) }));
|
|
948
|
-
}
|
|
949
|
-
/**
|
|
950
|
-
* Trigger button + dropdown menu for an `ActionGroup` meta. Reuses the
|
|
951
|
-
* action button styling helpers so a group's chrome (color/size/outlined/
|
|
952
|
-
* tooltip/iconButton) matches a regular Action. Each child Action
|
|
953
|
-
* dispatches via the same logic as `renderAction` — link/method/handler/
|
|
954
|
-
* confirm/modal — but routed through a `pending` state so the dropdown
|
|
955
|
-
* closes before any dialog opens (shadcn pattern: one popup at a time).
|
|
956
|
-
*/
|
|
957
|
-
function ActionGroupTrigger({ el, ids = [], }) {
|
|
958
|
-
const [pending, setPending] = useState(null);
|
|
959
|
-
const navigate = useNavigate();
|
|
960
|
-
const { notify } = useToast();
|
|
961
|
-
const name = String(el['name'] ?? '');
|
|
962
|
-
const label = String(el['label'] ?? name);
|
|
963
|
-
const tooltip = el['tooltip'];
|
|
964
|
-
const iconOnly = Boolean(el['iconOnly']);
|
|
965
|
-
const isDisabled = Boolean(el['disabled']);
|
|
966
|
-
const childActions = (el.children ?? []).filter(c => c.type === 'action');
|
|
967
|
-
const className = actionButtonClass(el, {}) + (isDisabled ? ' opacity-50 cursor-not-allowed pointer-events-none' : '');
|
|
968
|
-
const ariaLabel = iconOnly ? label : undefined;
|
|
969
|
-
// Direct-dispatch path mirrors renderAction's branches but skipping
|
|
970
|
-
// confirm/modal (those queue into `pending` so the dropdown can close).
|
|
971
|
-
const dispatch = (action) => {
|
|
972
|
-
const href = action['href'];
|
|
973
|
-
const method = action['method'];
|
|
974
|
-
const actionUrl = action['action'];
|
|
975
|
-
const dispatchUrl = action['dispatchUrl'];
|
|
976
|
-
if (href) {
|
|
977
|
-
navigate(href);
|
|
978
|
-
return;
|
|
979
|
-
}
|
|
980
|
-
if (method && actionUrl) {
|
|
981
|
-
void dispatchMethodAction(actionUrl, method, navigate, notify);
|
|
982
|
-
return;
|
|
983
|
-
}
|
|
984
|
-
if (dispatchUrl) {
|
|
985
|
-
void dispatchHandlerAction(dispatchUrl, ids, navigate, notify);
|
|
986
|
-
return;
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
const onItemClick = (action) => {
|
|
990
|
-
if (action['modal'] || action['confirm']) {
|
|
991
|
-
setPending(action);
|
|
992
|
-
return;
|
|
993
|
-
}
|
|
994
|
-
dispatch(action);
|
|
995
|
-
};
|
|
996
|
-
const pendingHandler = pending && pending['dispatchUrl'];
|
|
997
|
-
const pendingConfirmOnly = pending && !pendingHandler && pending['confirm'];
|
|
998
|
-
const pendingConfirm = pendingConfirmOnly || pending?.['confirm'];
|
|
999
|
-
return (_jsxs(_Fragment, { children: [_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: (props) => withTooltip(_jsx("button", { ...props, type: "button", className: className, "data-action-group-name": name, "aria-label": ariaLabel, children: iconOnly ? null : _jsx("span", { children: label }) }), tooltip) }), _jsx(DropdownMenuContent, { align: "end", children: childActions.map((a, i) => {
|
|
1000
|
-
const itemLabel = String(a['label'] ?? a['name'] ?? '');
|
|
1001
|
-
const destructive = Boolean(a['destructive']);
|
|
1002
|
-
const itemDisabled = Boolean(a['disabled']);
|
|
1003
|
-
return (_jsx(DropdownMenuItem, { destructive: destructive, disabled: itemDisabled, onClick: () => { if (!itemDisabled)
|
|
1004
|
-
onItemClick(a); }, children: itemLabel }, i));
|
|
1005
|
-
}) })] }), pendingHandler && pending && (_jsx(ActionModalDialog, { meta: pending, ids: ids, open: true, onOpenChange: (o) => { if (!o)
|
|
1006
|
-
setPending(null); } })), _jsx(Dialog, { open: Boolean(pendingConfirmOnly), onOpenChange: (o) => { if (!o)
|
|
1007
|
-
setPending(null); }, children: _jsx(DialogContent, { children: pendingConfirmOnly && pendingConfirm && (_jsxs(_Fragment, { children: [_jsxs(DialogHeader, { children: [_jsx(DialogTitle, { children: pendingConfirm.title ?? 'Are you sure?' }), _jsx(DialogDescription, { children: pendingConfirm.message })] }), _jsxs(DialogFooter, { children: [_jsx("button", { type: "button", onClick: () => setPending(null), className: "inline-flex items-center justify-center rounded-md border border-input bg-background px-3 h-9 text-sm font-medium hover:bg-accent hover:text-accent-foreground", children: "Cancel" }), _jsx("button", { type: "button", autoFocus: true, onClick: () => {
|
|
1008
|
-
const action = pending;
|
|
1009
|
-
setPending(null);
|
|
1010
|
-
if (action)
|
|
1011
|
-
dispatch(action);
|
|
1012
|
-
}, className: pending && pending['destructive']
|
|
1013
|
-
? 'inline-flex items-center justify-center rounded-md bg-destructive px-3 h-9 text-sm font-medium text-destructive-foreground hover:bg-destructive/90'
|
|
1014
|
-
: 'inline-flex items-center justify-center rounded-md bg-primary px-3 h-9 text-sm font-medium text-primary-foreground hover:bg-primary/90', children: pending && pending['destructive'] ? 'Delete' : 'Confirm' })] })] })) }) })] }));
|
|
1015
|
-
}
|
|
1016
|
-
function TabsRenderer({ el, index }) {
|
|
1017
|
-
const tabs = (el.children ?? []).filter(c => c.type === 'tab');
|
|
1018
|
-
if (tabs.length === 0)
|
|
1019
|
-
return null;
|
|
1020
|
-
const variant = el['variant'] === 'underline' ? 'underline' : 'pills';
|
|
1021
|
-
const tabValues = tabs.map((_, i) => `tab-${i}`);
|
|
1022
|
-
const defaultValue = tabValues[0];
|
|
1023
|
-
// Underline variant overrides the primitive's pill chrome with a bottom
|
|
1024
|
-
// border on the list and per-trigger underline-on-selected. No
|
|
1025
|
-
// `<TabsIndicator>` is rendered, so there's no sliding pill to hide.
|
|
1026
|
-
const listClass = variant === 'underline'
|
|
1027
|
-
? 'relative flex h-auto w-fit justify-start gap-0 rounded-none bg-transparent p-0 text-muted-foreground border-b border-border'
|
|
1028
|
-
: undefined;
|
|
1029
|
-
const triggerClass = variant === 'underline'
|
|
1030
|
-
? 'rounded-none border-0 border-b-2 border-transparent bg-transparent px-4 py-2 text-sm font-medium -mb-px data-[active]:border-primary data-[active]:text-foreground data-[active]:bg-transparent data-[active]:shadow-none'
|
|
1031
|
-
: undefined;
|
|
1032
|
-
return (_jsxs(Tabs, { defaultValue: defaultValue, children: [_jsx(TabsList, { className: listClass, children: tabs.map((tab, i) => (_jsxs(TabsTrigger, { value: tabValues[i], className: triggerClass, children: [String(tab['label'] ?? ''), tab['badge'] ? (_jsx("span", { className: "ml-2 text-xs px-1.5 py-0.5 rounded-full bg-muted", children: String(tab['badge']) })) : null] }, i))) }), tabs.map((tab, i) => (_jsx(TabsContent, { value: tabValues[i], className: "pt-2", children: renderChildren(tab['children']) }, i)))] }, index));
|
|
1033
|
-
}
|
|
1034
|
-
// ─── Section (stateful when collapsible) ────────────────────
|
|
1035
|
-
function SectionRenderer({ el, index }) {
|
|
1036
|
-
const title = el['title'] ? String(el['title']) : undefined;
|
|
1037
|
-
const description = el['description'] ? String(el['description']) : undefined;
|
|
1038
|
-
const iconName = el['icon'] ? String(el['icon']) : undefined;
|
|
1039
|
-
const badge = el['badge'] ? String(el['badge']) : undefined;
|
|
1040
|
-
const columns = Number(el['columns'] ?? 1);
|
|
1041
|
-
const collapsible = Boolean(el['collapsible']);
|
|
1042
|
-
const compact = Boolean(el['compact']);
|
|
1043
|
-
const dense = Boolean(el['dense']);
|
|
1044
|
-
const secondary = Boolean(el['secondary']);
|
|
1045
|
-
const afterHeader = el['afterHeader'] ?? [];
|
|
1046
|
-
const persist = Boolean(el['persistCollapsed']);
|
|
1047
|
-
const persistKey = el['persistKey']
|
|
1048
|
-
? `pilotiq.section.${String(el['persistKey'])}`
|
|
1049
|
-
: title
|
|
1050
|
-
? `pilotiq.section.${title.toLowerCase().replace(/\s+/g, '-')}`
|
|
1051
|
-
: undefined;
|
|
1052
|
-
const [collapsed, setCollapsed] = useState(Boolean(el['defaultCollapsed']));
|
|
1053
|
-
// Plan #8 — persist open/closed state to localStorage. Hydration-safe:
|
|
1054
|
-
// initial render uses `defaultCollapsed`; effect overrides from storage
|
|
1055
|
-
// after mount so server + client first paint agree.
|
|
1056
|
-
useEffect(() => {
|
|
1057
|
-
if (!persist || !persistKey)
|
|
1058
|
-
return;
|
|
1059
|
-
if (typeof window === 'undefined')
|
|
1060
|
-
return;
|
|
1061
|
-
try {
|
|
1062
|
-
const stored = window.localStorage.getItem(persistKey);
|
|
1063
|
-
if (stored === '0')
|
|
1064
|
-
setCollapsed(false);
|
|
1065
|
-
if (stored === '1')
|
|
1066
|
-
setCollapsed(true);
|
|
1067
|
-
}
|
|
1068
|
-
catch { /* localStorage may be unavailable (private mode) */ }
|
|
1069
|
-
}, [persist, persistKey]);
|
|
1070
|
-
useEffect(() => {
|
|
1071
|
-
if (!persist || !persistKey)
|
|
1072
|
-
return;
|
|
1073
|
-
if (typeof window === 'undefined')
|
|
1074
|
-
return;
|
|
1075
|
-
try {
|
|
1076
|
-
window.localStorage.setItem(persistKey, collapsed ? '1' : '0');
|
|
1077
|
-
}
|
|
1078
|
-
catch { /* ignore */ }
|
|
1079
|
-
}, [persist, persistKey, collapsed]);
|
|
1080
|
-
// `dense` tightens the inner spacing between the section's children
|
|
1081
|
-
// (orthogonal to `compact`, which trims the section's outer padding /
|
|
1082
|
-
// heading). gap-2 ≈ 8px vs gap-4 ≈ 16px.
|
|
1083
|
-
const innerGap = dense ? 'gap-2' : 'gap-4';
|
|
1084
|
-
const gridClass = columns === 2 ? `grid grid-cols-2 ${innerGap}` : columns === 3 ? `grid grid-cols-3 ${innerGap}` : `flex flex-col ${innerGap}`;
|
|
1085
|
-
const padding = compact ? 'p-3' : 'p-4';
|
|
1086
|
-
const titleSize = compact ? 'text-sm' : 'text-base';
|
|
1087
|
-
const Icon = resolveIcon(iconName);
|
|
1088
|
-
// `secondary()` flips the section background to the muted token so it
|
|
1089
|
-
// visually recedes beneath a primary section. The border thins to the
|
|
1090
|
-
// same muted tone for the same reason — a sharp `border-input` line
|
|
1091
|
-
// around a muted block looks like a typographic ledger rather than a
|
|
1092
|
-
// grouping container.
|
|
1093
|
-
const surfaceClass = secondary ? 'bg-muted/40 border-muted' : 'bg-card';
|
|
1094
|
-
return (_jsxs("section", { className: `flex flex-col ${compact ? 'gap-2' : 'gap-3'} rounded-lg border ${surfaceClass} ${padding} ${layoutClasses(el)}`.trim(), children: [(title || description || collapsible || badge || afterHeader.length > 0) && (_jsxs("header", { className: "flex items-start justify-between gap-2", children: [_jsxs("div", { className: "flex items-start gap-2", children: [Icon && _jsx(Icon, { className: "size-4 mt-0.5 text-muted-foreground", "aria-hidden": "true" }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center gap-2", children: [title && _jsx("h3", { className: `${titleSize} font-semibold`, children: title }), badge && (_jsx("span", { className: "rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground", children: badge }))] }), description && _jsx("p", { className: "text-xs text-muted-foreground mt-0.5", children: description })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [afterHeader.length > 0 && (_jsx("div", { className: "flex items-center gap-1", children: afterHeader.map((a, i) => renderElement(a, i)) })), collapsible && (_jsx("button", { type: "button", onClick: () => setCollapsed(c => !c), className: "text-xs text-muted-foreground hover:text-foreground", children: collapsed ? 'Expand' : 'Collapse' }))] })] })), !collapsed && el.children && el.children.length > 0 && (_jsx("div", { className: gridClass, children: el.children.map((c, i) => renderElement(c, i)) }))] }, index));
|
|
1095
|
-
}
|
|
1096
|
-
// ─── Wizard (Plan #8) ───────────────────────────────────────
|
|
1097
|
-
/**
|
|
1098
|
-
* Resolve the initial active step for `WizardRenderer`. Priority:
|
|
1099
|
-
* 1. URL `?<queryKey>=N` (1-based — wizards expose human-friendly indexes
|
|
1100
|
-
* when `Wizard.persistStepInQueryString()` is enabled).
|
|
1101
|
-
* 2. `localStorage[<storageKey>]` (0-based, set by the persist effect).
|
|
1102
|
-
* 3. `startOnStep` configured on the Wizard.
|
|
1103
|
-
*
|
|
1104
|
-
* SSR-safe: returns `startOnStep` when `window` is undefined.
|
|
1105
|
-
*/
|
|
1106
|
-
function readInitialWizardStep(total, startOnStep, storageKey, queryKey) {
|
|
1107
|
-
if (typeof window === 'undefined')
|
|
1108
|
-
return startOnStep;
|
|
1109
|
-
if (queryKey) {
|
|
1110
|
-
try {
|
|
1111
|
-
const raw = new URL(window.location.href).searchParams.get(queryKey);
|
|
1112
|
-
if (raw !== null && raw !== '') {
|
|
1113
|
-
const n = Number(raw) - 1;
|
|
1114
|
-
if (Number.isFinite(n) && n >= 0 && n < total)
|
|
1115
|
-
return n;
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
catch { /* ignore */ }
|
|
1119
|
-
}
|
|
1120
|
-
if (storageKey) {
|
|
1121
|
-
try {
|
|
1122
|
-
const stored = window.localStorage.getItem(storageKey);
|
|
1123
|
-
if (stored !== null) {
|
|
1124
|
-
const n = Number(stored);
|
|
1125
|
-
if (Number.isFinite(n) && n >= 0 && n < total)
|
|
1126
|
-
return n;
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
catch { /* ignore */ }
|
|
1130
|
-
}
|
|
1131
|
-
return startOnStep;
|
|
1132
|
-
}
|
|
1133
|
-
/**
|
|
1134
|
-
* Multi-step form layout. Tracks active step in `useState`, optionally
|
|
1135
|
-
* persisted to localStorage and/or the URL query string. On Next click,
|
|
1136
|
-
* POSTs `{ step, values }` to the form's `wizardUrl` (stamped by the
|
|
1137
|
-
* route handler when the form has a Wizard descendant). 200 → advance;
|
|
1138
|
-
* 422 → stamp inline errors; absent `wizardUrl` → advance immediately
|
|
1139
|
-
* (no validation).
|
|
1140
|
-
*
|
|
1141
|
-
* Inactive steps render hidden (display:none) rather than unmounted so
|
|
1142
|
-
* controlled inputs preserve their values across step transitions and
|
|
1143
|
-
* cross-step `$get` works on the resolved meta.
|
|
1144
|
-
*
|
|
1145
|
-
* Nav buttons honor `Wizard.submitAction() / nextAction() / previousAction()`
|
|
1146
|
-
* — chrome (label / icon / color / size / outlined / iconOnly / tooltip /
|
|
1147
|
-
* disabled rules) carries through to the rendered button while the click
|
|
1148
|
-
* behavior stays hardwired (advance / recede / submit-form). Bare wizards
|
|
1149
|
-
* keep the built-in defaults.
|
|
1150
|
-
*/
|
|
1151
|
-
function WizardRenderer({ el, index }) {
|
|
1152
|
-
const formState = useFormState();
|
|
1153
|
-
const formId = formState?.formMeta['formId'] ? String(formState.formMeta['formId']) : undefined;
|
|
1154
|
-
const wizardUrl = formState?.formMeta['wizardUrl'] ? String(formState.formMeta['wizardUrl']) : undefined;
|
|
1155
|
-
const steps = (el.children ?? []).filter(c => c.type === 'step');
|
|
1156
|
-
const skippable = Boolean(el['skippable']);
|
|
1157
|
-
const startOnStep = Math.max(0, Math.min(Math.max(0, steps.length - 1), Number(el['startOnStep'] ?? 0)));
|
|
1158
|
-
const persist = el['persist'] !== false;
|
|
1159
|
-
const storageKey = persist && formId ? `pilotiq.wizard.${formId}.step` : undefined;
|
|
1160
|
-
const queryKey = typeof el['persistStepInQueryString'] === 'string'
|
|
1161
|
-
? String(el['persistStepInQueryString'])
|
|
1162
|
-
: undefined;
|
|
1163
|
-
const submitActionMeta = el['submitAction'];
|
|
1164
|
-
const nextActionMeta = el['nextAction'];
|
|
1165
|
-
const previousActionMeta = el['previousAction'];
|
|
1166
|
-
// Initial-step resolution priority: URL (?<key>=N, 1-based) > localStorage >
|
|
1167
|
-
// startOnStep. URL wins on first paint so deep links land on the right step
|
|
1168
|
-
// before localStorage can override. Lazy initializer — resolution runs once.
|
|
1169
|
-
const [active, setActive] = useState(() => readInitialWizardStep(steps.length, startOnStep, storageKey, queryKey));
|
|
1170
|
-
const [advancing, setAdvancing] = useState(false);
|
|
1171
|
-
const [advanceError, setAdvanceError] = useState(null);
|
|
1172
|
-
// Persist active step changes to localStorage (when enabled).
|
|
1173
|
-
useEffect(() => {
|
|
1174
|
-
if (!storageKey)
|
|
1175
|
-
return;
|
|
1176
|
-
if (typeof window === 'undefined')
|
|
1177
|
-
return;
|
|
1178
|
-
try {
|
|
1179
|
-
window.localStorage.setItem(storageKey, String(active));
|
|
1180
|
-
}
|
|
1181
|
-
catch { /* ignore */ }
|
|
1182
|
-
}, [storageKey, active]);
|
|
1183
|
-
// Mirror active step to the URL via replaceState — purely client-side state
|
|
1184
|
-
// sync, no SPA re-fetch. 1-based externally; cleared when on the first step
|
|
1185
|
-
// so bare URLs don't grow ?step=1 noise.
|
|
1186
|
-
useEffect(() => {
|
|
1187
|
-
if (!queryKey)
|
|
1188
|
-
return;
|
|
1189
|
-
if (typeof window === 'undefined')
|
|
1190
|
-
return;
|
|
1191
|
-
try {
|
|
1192
|
-
const url = new URL(window.location.href);
|
|
1193
|
-
if (active === 0)
|
|
1194
|
-
url.searchParams.delete(queryKey);
|
|
1195
|
-
else
|
|
1196
|
-
url.searchParams.set(queryKey, String(active + 1));
|
|
1197
|
-
window.history.replaceState(window.history.state, '', url.toString());
|
|
1198
|
-
}
|
|
1199
|
-
catch { /* ignore */ }
|
|
1200
|
-
}, [queryKey, active]);
|
|
1201
|
-
if (steps.length === 0) {
|
|
1202
|
-
return (_jsx("div", { className: "rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground", children: "No steps configured." }, index));
|
|
1203
|
-
}
|
|
1204
|
-
const isLast = active === steps.length - 1;
|
|
1205
|
-
const isFirst = active === 0;
|
|
1206
|
-
const advance = async (target) => {
|
|
1207
|
-
setAdvanceError(null);
|
|
1208
|
-
if (!wizardUrl) {
|
|
1209
|
-
setActive(target);
|
|
1210
|
-
return;
|
|
1211
|
-
}
|
|
1212
|
-
setAdvancing(true);
|
|
1213
|
-
try {
|
|
1214
|
-
const values = formState?.values ?? {};
|
|
1215
|
-
// Validate intermediate steps in order when jumping ahead.
|
|
1216
|
-
const path = target > active
|
|
1217
|
-
? Array.from({ length: target - active }, (_, k) => active + k)
|
|
1218
|
-
: [active]; // jumping back is unconstrained
|
|
1219
|
-
let landed = active;
|
|
1220
|
-
for (const stepIdx of path) {
|
|
1221
|
-
const res = await fetch(wizardUrl, {
|
|
1222
|
-
method: 'POST',
|
|
1223
|
-
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
1224
|
-
body: JSON.stringify({ step: stepIdx, values }),
|
|
1225
|
-
});
|
|
1226
|
-
if (res.status === 422) {
|
|
1227
|
-
const data = await res.json().catch(() => ({}));
|
|
1228
|
-
const errors = (data?.errors ?? {});
|
|
1229
|
-
if (formState?.applyErrors)
|
|
1230
|
-
formState.applyErrors(errors);
|
|
1231
|
-
landed = stepIdx;
|
|
1232
|
-
setAdvanceError('Please fix the highlighted fields.');
|
|
1233
|
-
break;
|
|
1234
|
-
}
|
|
1235
|
-
if (!res.ok) {
|
|
1236
|
-
setAdvanceError('Step validation failed.');
|
|
1237
|
-
break;
|
|
1238
|
-
}
|
|
1239
|
-
landed = stepIdx + 1;
|
|
1240
|
-
}
|
|
1241
|
-
setActive(target > active ? landed : target);
|
|
1242
|
-
}
|
|
1243
|
-
catch {
|
|
1244
|
-
setAdvanceError('Step validation failed.');
|
|
1245
|
-
}
|
|
1246
|
-
finally {
|
|
1247
|
-
setAdvancing(false);
|
|
1248
|
-
}
|
|
1249
|
-
};
|
|
1250
|
-
return (_jsxs("div", { className: `flex flex-col gap-6 ${layoutClasses(el)}`.trim(), children: [_jsx("ol", { className: "flex items-center gap-3 overflow-x-auto", "aria-label": "Wizard progress", children: steps.map((s, i) => {
|
|
1251
|
-
const Icon = resolveIcon(s['icon'] ? String(s['icon']) : undefined);
|
|
1252
|
-
const reachable = skippable || i <= active;
|
|
1253
|
-
const isActive = i === active;
|
|
1254
|
-
const isDone = i < active;
|
|
1255
|
-
return (_jsxs("li", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("button", { type: "button", disabled: !reachable || advancing, onClick: () => reachable && advance(i), className: [
|
|
1256
|
-
'flex items-center gap-2 rounded-md border px-3 py-1.5 text-sm transition',
|
|
1257
|
-
isActive ? 'border-primary bg-primary/10 text-foreground'
|
|
1258
|
-
: isDone ? 'border-border text-muted-foreground hover:bg-muted'
|
|
1259
|
-
: 'border-border text-muted-foreground',
|
|
1260
|
-
reachable ? 'cursor-pointer' : 'opacity-50 cursor-not-allowed',
|
|
1261
|
-
].join(' '), "aria-current": isActive ? 'step' : undefined, children: [_jsx("span", { className: [
|
|
1262
|
-
'flex size-5 items-center justify-center rounded-full text-[11px] font-semibold',
|
|
1263
|
-
isActive ? 'bg-primary text-primary-foreground'
|
|
1264
|
-
: isDone ? 'bg-muted-foreground/20 text-foreground'
|
|
1265
|
-
: 'bg-muted text-muted-foreground',
|
|
1266
|
-
].join(' '), children: Icon ? _jsx(Icon, { className: "size-3", "aria-hidden": "true" }) : i + 1 }), _jsx("span", { className: "font-medium", children: String(s['label'] ?? `Step ${i + 1}`) })] }), i < steps.length - 1 && _jsx("span", { className: "h-px w-6 bg-border", "aria-hidden": "true" })] }, i));
|
|
1267
|
-
}) }), Boolean(steps[active]?.['description']) && (_jsx("p", { className: "text-sm text-muted-foreground", children: String(steps[active]['description']) })), steps.map((s, i) => (_jsx("div", { className: i === active ? 'flex flex-col gap-4' : 'hidden', "aria-hidden": i === active ? undefined : true, children: (s.children ?? []).map((c, ci) => renderElement(c, ci)) }, i))), advanceError && (_jsx("p", { className: "text-sm text-destructive", role: "alert", children: advanceError })), _jsxs("div", { className: "flex items-center justify-between gap-2", children: [_jsx(WizardNavButton, { actionMeta: previousActionMeta, fallbackLabel: "Back", disabled: isFirst || advancing, onClick: () => advance(active - 1) }), isLast
|
|
1268
|
-
? (submitActionMeta
|
|
1269
|
-
? _jsx(WizardNavButton, { actionMeta: submitActionMeta, fallbackLabel: "Submit", type: "submit", disabled: advancing })
|
|
1270
|
-
: _jsx("span", { className: "text-xs text-muted-foreground", children: "Submit the form to finish." }))
|
|
1271
|
-
: _jsx(WizardNavButton, { actionMeta: nextActionMeta, fallbackLabel: advancing ? 'Validating…' : 'Next', disabled: advancing, onClick: () => advance(active + 1) })] })] }, index));
|
|
1272
|
-
}
|
|
1273
|
-
/**
|
|
1274
|
-
* Renders one wizard nav slot (Back / Next / Submit). Falls back to plain
|
|
1275
|
-
* built-in chrome (border button for Back, primary button for Next/Submit)
|
|
1276
|
-
* when no `actionMeta` is supplied; otherwise reads the resolved Action's
|
|
1277
|
-
* chrome (`label / icon / color / size / outlined / iconOnly / tooltip /
|
|
1278
|
-
* disabled`) and applies it to a button whose click is hardwired by the
|
|
1279
|
-
* surrounding wizard. `type="submit"` lets the Submit slot trigger the
|
|
1280
|
-
* surrounding form's onSubmit dispatcher (no `onClick` needed).
|
|
1281
|
-
*
|
|
1282
|
-
* Hidden actions (`.visible(false)` resolved-away) drop the slot entirely
|
|
1283
|
-
* — the resolver returns `undefined` for hidden Action elements, which
|
|
1284
|
-
* arrives here as `actionMeta == null` so we fall through to the default
|
|
1285
|
-
* chrome. Use `Wizard.skippable()` semantics to hide nav buttons when
|
|
1286
|
-
* appropriate; for permanent removal subclass the wizard.
|
|
1287
|
-
*/
|
|
1288
|
-
function WizardNavButton({ actionMeta, fallbackLabel, type = 'button', disabled, onClick, }) {
|
|
1289
|
-
// Bare default — keep historical chrome for back-compat (un-customized
|
|
1290
|
-
// wizards look identical to before this change).
|
|
1291
|
-
if (!actionMeta) {
|
|
1292
|
-
const isPrimary = type === 'submit' || fallbackLabel !== 'Back';
|
|
1293
|
-
return (_jsx("button", { type: type, disabled: disabled, onClick: onClick, className: isPrimary
|
|
1294
|
-
? 'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50 disabled:cursor-not-allowed'
|
|
1295
|
-
: 'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50 disabled:cursor-not-allowed', children: fallbackLabel }));
|
|
1296
|
-
}
|
|
1297
|
-
const ownDisabled = Boolean(actionMeta['disabled']);
|
|
1298
|
-
const label = String(actionMeta['label'] ?? fallbackLabel);
|
|
1299
|
-
const tooltip = actionMeta['tooltip'] ? String(actionMeta['tooltip']) : undefined;
|
|
1300
|
-
const iconOnly = Boolean(actionMeta['iconOnly']);
|
|
1301
|
-
const className = actionButtonClass(actionMeta, {});
|
|
1302
|
-
const node = (_jsxs("button", { type: type, disabled: disabled || ownDisabled, onClick: onClick, className: `${className} disabled:opacity-50 disabled:cursor-not-allowed`, "aria-label": iconOnly ? label : undefined, children: [renderActionIcon(actionMeta), !iconOnly && _jsx("span", { children: label }), renderActionBadge(actionMeta)] }));
|
|
1303
|
-
return _jsx(_Fragment, { children: withTooltip(node, tooltip) });
|
|
1304
|
-
}
|
|
1305
|
-
// ─── Top-level dispatch ─────────────────────────────────────
|
|
1306
|
-
const TEXT_COLOR_CLASSES = {
|
|
1307
|
-
default: '',
|
|
1308
|
-
muted: 'text-muted-foreground',
|
|
1309
|
-
primary: 'text-primary',
|
|
1310
|
-
destructive: 'text-destructive',
|
|
1311
|
-
success: 'text-emerald-600 dark:text-emerald-400',
|
|
1312
|
-
warning: 'text-amber-600 dark:text-amber-400',
|
|
1313
|
-
info: 'text-blue-600 dark:text-blue-400',
|
|
1314
|
-
};
|
|
1315
|
-
const TEXT_SIZE_CLASSES = {
|
|
1316
|
-
xs: 'text-xs',
|
|
1317
|
-
sm: 'text-sm',
|
|
1318
|
-
base: 'text-base',
|
|
1319
|
-
lg: 'text-lg',
|
|
1320
|
-
xl: 'text-xl',
|
|
1321
|
-
};
|
|
1322
|
-
const TEXT_WEIGHT_CLASSES = {
|
|
1323
|
-
normal: 'font-normal',
|
|
1324
|
-
medium: 'font-medium',
|
|
1325
|
-
semibold: 'font-semibold',
|
|
1326
|
-
bold: 'font-bold',
|
|
1327
|
-
};
|
|
1328
|
-
function renderText(el, index) {
|
|
1329
|
-
const content = String(el['content'] ?? '');
|
|
1330
|
-
const color = el['color'] ? String(el['color']) : undefined;
|
|
1331
|
-
const size = el['size'] ? String(el['size']) : undefined;
|
|
1332
|
-
const weight = el['weight'] ? String(el['weight']) : undefined;
|
|
1333
|
-
const isBadge = el['badge'] === true;
|
|
1334
|
-
if (isBadge) {
|
|
1335
|
-
const badgeKey = el['badgeColor'] ? String(el['badgeColor']) : 'gray';
|
|
1336
|
-
const cls = BADGE_COLOR_CLASSES[badgeKey] ?? BADGE_COLOR_CLASSES['gray'];
|
|
1337
|
-
return (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: content }, index));
|
|
1338
|
-
}
|
|
1339
|
-
// Defaults match the previous bare `<p>` for back-compat: text-sm + muted.
|
|
1340
|
-
const sizeCls = size ? (TEXT_SIZE_CLASSES[size] ?? '') : 'text-sm';
|
|
1341
|
-
const colorCls = color ? (TEXT_COLOR_CLASSES[color] ?? '') : 'text-muted-foreground';
|
|
1342
|
-
const weightCls = weight ? (TEXT_WEIGHT_CLASSES[weight] ?? '') : '';
|
|
1343
|
-
return (_jsx("p", { className: `${sizeCls} ${colorCls} ${weightCls}`.trim(), children: content }, index));
|
|
1344
|
-
}
|
|
1345
|
-
/** Coerce a `KeyValueEntry` state value (object | JSON string | …) into a
|
|
1346
|
-
* flat record. Returns `null` when the value is empty or non-decodable. */
|
|
1347
|
-
function normalizeKeyValueValue(value) {
|
|
1348
|
-
if (value === null || value === undefined || value === '')
|
|
1349
|
-
return null;
|
|
1350
|
-
if (typeof value === 'string') {
|
|
1351
|
-
try {
|
|
1352
|
-
const parsed = JSON.parse(value);
|
|
1353
|
-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
1354
|
-
return parsed;
|
|
1355
|
-
}
|
|
1356
|
-
}
|
|
1357
|
-
catch {
|
|
1358
|
-
// Non-JSON string — fall through to null so the renderer shows the
|
|
1359
|
-
// fallback rather than misrepresenting it as a one-row map.
|
|
1360
|
-
}
|
|
1361
|
-
return null;
|
|
1362
|
-
}
|
|
1363
|
-
if (Array.isArray(value))
|
|
1364
|
-
return null;
|
|
1365
|
-
if (typeof value === 'object')
|
|
1366
|
-
return value;
|
|
1367
|
-
return null;
|
|
1368
|
-
}
|
|
1369
|
-
/** Render a single kv cell value — primitives become their string form;
|
|
1370
|
-
* nested objects/arrays JSON-stringify for compactness. */
|
|
1371
|
-
function formatKeyValueCell(value) {
|
|
1372
|
-
if (value === null || value === undefined)
|
|
1373
|
-
return '';
|
|
1374
|
-
if (typeof value === 'object')
|
|
1375
|
-
return JSON.stringify(value);
|
|
1376
|
-
return String(value);
|
|
1377
|
-
}
|
|
1378
|
-
/**
|
|
1379
|
-
* Plan #16 — read-only label-value pair for `Resource.detail()` schemas.
|
|
1380
|
-
* Dispatches on `meta.entryType` (`'text' | 'badge' | 'icon' | 'image' | 'keyValue' | 'color'`).
|
|
1381
|
-
* Wraps the rendered value in `<EntryShell>` for the shared chrome
|
|
1382
|
-
* (label / helperText / tooltip / copyable trigger).
|
|
1383
|
-
*/
|
|
1384
|
-
function renderEntry(el, index) {
|
|
1385
|
-
const entryType = String(el['entryType'] ?? 'text');
|
|
1386
|
-
const value = el['value'];
|
|
1387
|
-
const fallback = el['default'] ? String(el['default']) : '—';
|
|
1388
|
-
let body;
|
|
1389
|
-
switch (entryType) {
|
|
1390
|
-
case 'text': {
|
|
1391
|
-
const formatted = el['_formatted'] !== undefined
|
|
1392
|
-
? String(el['_formatted'])
|
|
1393
|
-
: (el['format']
|
|
1394
|
-
? applyColumnFormat(value, el['format'])
|
|
1395
|
-
: (value === null || value === undefined || value === '' ? '' : String(value)));
|
|
1396
|
-
const display = formatted === '' ? fallback : formatted;
|
|
1397
|
-
const isFallback = formatted === '';
|
|
1398
|
-
const isRichText = el['richtext'] === true && !isFallback;
|
|
1399
|
-
const sizeKey = el['size'] ? String(el['size']) : 'sm';
|
|
1400
|
-
const colorKey = el['color'] ? String(el['color']) : (isFallback ? 'muted' : 'default');
|
|
1401
|
-
const weightKey = el['weight'] ? String(el['weight']) : 'normal';
|
|
1402
|
-
const sizeCls = TEXT_SIZE_CLASSES[sizeKey] ?? 'text-sm';
|
|
1403
|
-
const colorCls = TEXT_COLOR_CLASSES[colorKey] ?? '';
|
|
1404
|
-
const weightCls = TEXT_WEIGHT_CLASSES[weightKey] ?? '';
|
|
1405
|
-
const lineClamp = el['lineClamp'];
|
|
1406
|
-
const wrap = el['wrap'] === true;
|
|
1407
|
-
const style = {};
|
|
1408
|
-
if (lineClamp !== undefined) {
|
|
1409
|
-
style.display = '-webkit-box';
|
|
1410
|
-
style.WebkitLineClamp = lineClamp;
|
|
1411
|
-
style.WebkitBoxOrient = 'vertical';
|
|
1412
|
-
style.overflow = 'hidden';
|
|
1413
|
-
}
|
|
1414
|
-
const wrapCls = wrap ? 'whitespace-pre-wrap' : (lineClamp !== undefined ? '' : 'whitespace-nowrap');
|
|
1415
|
-
if (isRichText) {
|
|
1416
|
-
// Server-rendered HTML from a registered richtext renderer (e.g.
|
|
1417
|
-
// `@pilotiq/tiptap`). Wrap in `prose` for sensible default
|
|
1418
|
-
// styling — matches the read-only `Markdown` / `Html` primes.
|
|
1419
|
-
const proseSize = sizeKey === 'lg' || sizeKey === 'xl'
|
|
1420
|
-
? 'prose-lg'
|
|
1421
|
-
: sizeKey === 'sm' || sizeKey === 'xs'
|
|
1422
|
-
? 'prose-sm'
|
|
1423
|
-
: '';
|
|
1424
|
-
body = (_jsx("div", { className: `prose max-w-none dark:prose-invert ${proseSize} ${colorCls} ${weightCls}`.trim(), style: style, dangerouslySetInnerHTML: { __html: display } }));
|
|
1425
|
-
break;
|
|
1426
|
-
}
|
|
1427
|
-
body = (_jsx("span", { className: `${sizeCls} ${colorCls} ${weightCls} ${wrapCls}`.trim(), style: style, children: display }));
|
|
1428
|
-
break;
|
|
1429
|
-
}
|
|
1430
|
-
case 'badge': {
|
|
1431
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
1432
|
-
if (isBlank) {
|
|
1433
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1434
|
-
break;
|
|
1435
|
-
}
|
|
1436
|
-
const map = el['colors'] ?? {};
|
|
1437
|
-
const colorKey = map[String(value)] ?? 'gray';
|
|
1438
|
-
const cls = BADGE_COLOR_CLASSES[colorKey] ?? BADGE_COLOR_CLASSES['gray'];
|
|
1439
|
-
body = (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: String(value) }));
|
|
1440
|
-
break;
|
|
1441
|
-
}
|
|
1442
|
-
case 'icon': {
|
|
1443
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
1444
|
-
const map = el['options'] ?? {};
|
|
1445
|
-
const opt = isBlank ? undefined : map[String(value)];
|
|
1446
|
-
if (!opt) {
|
|
1447
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1448
|
-
break;
|
|
1449
|
-
}
|
|
1450
|
-
const Icon = resolveIcon(opt.icon) ?? CircleIcon;
|
|
1451
|
-
const colorClass = opt.color ? (COLUMN_COLOR_CLASSES[opt.color] ?? '') : '';
|
|
1452
|
-
const ariaLabel = opt.label ?? String(value);
|
|
1453
|
-
body = _jsx(Icon, { className: `inline size-5 ${colorClass}`.trim(), "aria-label": ariaLabel });
|
|
1454
|
-
break;
|
|
1455
|
-
}
|
|
1456
|
-
case 'image': {
|
|
1457
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
1458
|
-
if (isBlank) {
|
|
1459
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1460
|
-
break;
|
|
1461
|
-
}
|
|
1462
|
-
const url = String(value);
|
|
1463
|
-
const width = el['imageWidth'] ?? el['imageSize'] ?? 64;
|
|
1464
|
-
const height = el['imageHeight'] ?? el['imageSize'] ?? 64;
|
|
1465
|
-
const shape = String(el['imageShape'] ?? 'rounded');
|
|
1466
|
-
const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'square' ? '' : 'rounded-md';
|
|
1467
|
-
body = (_jsx("img", { src: url, alt: "", width: width, height: height, className: `inline-block object-cover ${shapeCls}`.trim() }));
|
|
1468
|
-
break;
|
|
1469
|
-
}
|
|
1470
|
-
case 'keyValue': {
|
|
1471
|
-
const parsed = normalizeKeyValueValue(value);
|
|
1472
|
-
const keys = parsed ? Object.keys(parsed) : [];
|
|
1473
|
-
if (!parsed || keys.length === 0) {
|
|
1474
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1475
|
-
break;
|
|
1476
|
-
}
|
|
1477
|
-
const keyLabel = el['keyLabel'] ? String(el['keyLabel']) : 'Key';
|
|
1478
|
-
const valueLabel = el['valueLabel'] ? String(el['valueLabel']) : 'Value';
|
|
1479
|
-
body = (_jsxs("table", { className: "w-full border border-border text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "bg-muted/50 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground", children: [_jsx("th", { className: "border-b border-border px-2 py-1", children: keyLabel }), _jsx("th", { className: "border-b border-border px-2 py-1", children: valueLabel })] }) }), _jsx("tbody", { children: keys.map(k => (_jsxs("tr", { className: "border-t border-border first:border-t-0", children: [_jsx("td", { className: "px-2 py-1 align-top font-mono text-xs", children: k }), _jsx("td", { className: "px-2 py-1 align-top font-mono text-xs break-all", children: formatKeyValueCell(parsed[k]) })] }, k))) })] }));
|
|
1480
|
-
break;
|
|
1481
|
-
}
|
|
1482
|
-
case 'color': {
|
|
1483
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
1484
|
-
if (isBlank) {
|
|
1485
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1486
|
-
break;
|
|
1487
|
-
}
|
|
1488
|
-
const hex = String(value);
|
|
1489
|
-
const width = el['colorWidth'] ?? el['colorSize'] ?? 24;
|
|
1490
|
-
const height = el['colorHeight'] ?? el['colorSize'] ?? 24;
|
|
1491
|
-
const shape = String(el['colorShape'] ?? 'rounded');
|
|
1492
|
-
const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'square' ? '' : 'rounded-md';
|
|
1493
|
-
const showValue = el['showValue'] !== false;
|
|
1494
|
-
body = (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: `inline-block border border-border ${shapeCls}`.trim(), style: { width, height, backgroundColor: hex }, "aria-label": hex }), showValue && (_jsx("span", { className: "font-mono text-xs text-muted-foreground", children: hex }))] }));
|
|
1495
|
-
break;
|
|
1496
|
-
}
|
|
1497
|
-
case 'code': {
|
|
1498
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
1499
|
-
if (isBlank) {
|
|
1500
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1501
|
-
break;
|
|
1502
|
-
}
|
|
1503
|
-
const text = typeof value === 'string' ? value : String(value);
|
|
1504
|
-
const lang = el['language'] ? String(el['language']) : undefined;
|
|
1505
|
-
body = (_jsx("pre", { className: "rounded-md border border-border bg-muted/40 p-3 text-xs overflow-x-auto", "data-language": lang, children: _jsx("code", { className: "font-mono", children: text }) }));
|
|
1506
|
-
break;
|
|
1507
|
-
}
|
|
1508
|
-
case 'component': {
|
|
1509
|
-
const componentName = String(el['component'] ?? '');
|
|
1510
|
-
if (!componentName) {
|
|
1511
|
-
body = (_jsxs(EntryComponentError, { children: ["ComponentEntry is missing its ", _jsx("code", { className: "font-mono", children: "component" }), " name \u2014 set ", _jsx("code", { className: "font-mono", children: "static componentName = '...'" }), " on the subclass or call ", _jsx("code", { className: "font-mono", children: ".component('...')" }), " in the fluent form."] }));
|
|
1512
|
-
break;
|
|
1513
|
-
}
|
|
1514
|
-
const Component = getEntryComponent(componentName);
|
|
1515
|
-
if (!Component) {
|
|
1516
|
-
body = (_jsxs(EntryComponentError, { children: ["No component registered under name ", _jsx("code", { className: "font-mono", children: componentName }), ". Register it at app boot:", _jsx("pre", { className: "mt-2 overflow-x-auto rounded bg-amber-100/60 p-2 text-xs dark:bg-amber-900/30", children: `import { registerEntryComponents } from '@pilotiq/pilotiq/entries'\nregisterEntryComponents({ ${componentName}: ${componentName} })` })] }));
|
|
1517
|
-
break;
|
|
1518
|
-
}
|
|
1519
|
-
// Render-time errors propagate to React's nearest error boundary —
|
|
1520
|
-
// surfacing them inline here would require wrapping every entry in
|
|
1521
|
-
// its own boundary, which v1 doesn't ship. The two pre-render
|
|
1522
|
-
// sentinels above (missing name / missing registration) cover the
|
|
1523
|
-
// typical wiring mistakes.
|
|
1524
|
-
body = _jsx(Component, { value: value });
|
|
1525
|
-
break;
|
|
1526
|
-
}
|
|
1527
|
-
case 'repeatable': {
|
|
1528
|
-
// Read-only sibling of `Repeater`. Reads `meta.rows` (resolved by
|
|
1529
|
-
// `resolveRepeatableRows`) and dispatches on the chosen layout —
|
|
1530
|
-
// `table > grid > stack`. Empty / non-array state falls through to
|
|
1531
|
-
// the inherited `default()` placeholder, same as every other entry.
|
|
1532
|
-
const rows = el['rows'] ?? [];
|
|
1533
|
-
if (rows.length === 0) {
|
|
1534
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1535
|
-
break;
|
|
1536
|
-
}
|
|
1537
|
-
const tableCfg = el['table'];
|
|
1538
|
-
const gridN = el['grid'];
|
|
1539
|
-
const innerCols = el['columns'];
|
|
1540
|
-
const contained = el['contained'] !== false;
|
|
1541
|
-
if (tableCfg && tableCfg.columns.length > 0) {
|
|
1542
|
-
const cols = tableCfg.columns;
|
|
1543
|
-
body = (_jsxs("table", { className: "w-full border border-border text-sm", children: [cols.some(c => c.width) && (_jsx("colgroup", { children: cols.map((c, i) => (_jsx("col", { style: c.width ? { width: c.width } : undefined }, i))) })), _jsx("thead", { children: _jsx("tr", { className: "bg-muted/50 text-left text-xs font-medium uppercase tracking-wide text-muted-foreground", children: cols.map((c, i) => (_jsx("th", { className: `border-b border-border px-2 py-1 ${c.alignment === 'right' ? 'text-right' : c.alignment === 'center' ? 'text-center' : ''}`.trim(), children: c.label }, i))) }) }), _jsx("tbody", { children: rows.map(row => (_jsx("tr", { className: "border-t border-border first:border-t-0 align-top", children: row.children.map((child, i) => {
|
|
1544
|
-
const align = cols[i]?.alignment;
|
|
1545
|
-
const alignCls = align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : '';
|
|
1546
|
-
return (_jsx("td", { className: `px-2 py-1 ${alignCls}`.trim(), children: renderElement(child, i) }, i));
|
|
1547
|
-
}) }, row.id))) })] }));
|
|
1548
|
-
break;
|
|
1549
|
-
}
|
|
1550
|
-
const cardCls = contained
|
|
1551
|
-
? 'rounded-md border border-border p-3 bg-background'
|
|
1552
|
-
: '';
|
|
1553
|
-
const innerColsCls = innerCols && innerCols >= 2
|
|
1554
|
-
? `grid gap-3 grid-cols-1 md:grid-cols-${Math.min(innerCols, 6)}`
|
|
1555
|
-
: 'space-y-2';
|
|
1556
|
-
const cards = rows.map(row => (_jsx("div", { className: `${cardCls} ${innerColsCls}`.trim(), children: row.children.map((child, i) => renderElement(child, i)) }, row.id)));
|
|
1557
|
-
if (gridN && gridN >= 2) {
|
|
1558
|
-
const cap = Math.min(gridN, 6);
|
|
1559
|
-
body = (_jsx("div", { className: `w-full grid gap-3 grid-cols-1 md:grid-cols-${cap}`, children: cards }));
|
|
1560
|
-
break;
|
|
1561
|
-
}
|
|
1562
|
-
body = _jsx("div", { className: "w-full space-y-3", children: cards });
|
|
1563
|
-
break;
|
|
1564
|
-
}
|
|
1565
|
-
default:
|
|
1566
|
-
body = _jsx("span", { className: "text-sm text-muted-foreground", children: fallback });
|
|
1567
|
-
}
|
|
1568
|
-
const copyable = el['copyable'];
|
|
1569
|
-
const copyValue = el['_formatted'] !== undefined
|
|
1570
|
-
? String(el['_formatted'])
|
|
1571
|
-
: value === null || value === undefined
|
|
1572
|
-
? ''
|
|
1573
|
-
: typeof value === 'object'
|
|
1574
|
-
? JSON.stringify(value)
|
|
1575
|
-
: String(value);
|
|
1576
|
-
return (_jsx(EntryShell, { el: el, copyValue: copyable !== undefined ? copyValue : undefined, copyableLabel: copyable?.label, children: body }, index));
|
|
1577
|
-
}
|
|
1578
|
-
function EntryShell({ el, copyValue, copyableLabel, children }) {
|
|
1579
|
-
const label = String(el['label'] ?? '');
|
|
1580
|
-
const helperText = el['helperText'] ? String(el['helperText']) : undefined;
|
|
1581
|
-
const tooltipText = el['tooltip'] ? String(el['tooltip']) : undefined;
|
|
1582
|
-
const inline = el['inlineLabel'] === true;
|
|
1583
|
-
const labelNode = label ? (_jsxs("div", { className: "flex items-center gap-1.5 text-sm font-medium text-muted-foreground", children: [_jsx("span", { children: label }), tooltipText && _jsx(EntryTooltip, { text: tooltipText })] })) : null;
|
|
1584
|
-
const valueRow = (_jsxs("div", { className: "flex items-center gap-2", children: [children, copyValue !== undefined && (_jsx(EntryCopyButton, { text: copyValue, label: copyableLabel ?? 'Copy' }))] }));
|
|
1585
|
-
if (inline) {
|
|
1586
|
-
return (_jsxs("div", { className: "flex items-baseline gap-3", children: [labelNode && _jsx("div", { className: "min-w-32", children: labelNode }), _jsxs("div", { className: "min-w-0 flex-1", children: [valueRow, helperText && _jsx("p", { className: "mt-1 text-xs text-muted-foreground", children: helperText })] })] }));
|
|
1587
|
-
}
|
|
1588
|
-
return (_jsxs("div", { className: "space-y-1", children: [labelNode, valueRow, helperText && _jsx("p", { className: "text-xs text-muted-foreground", children: helperText })] }));
|
|
49
|
+
return renderActionLikeImpl(el, index, opts, { renderElement, renderFormChild });
|
|
1589
50
|
}
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
function
|
|
1594
|
-
|
|
1595
|
-
return (_jsx(TooltipProvider, { children: _jsxs(Tooltip, { children: [_jsx(TooltipTrigger, { render: () => trigger }), _jsx(TooltipContent, { children: text })] }) }));
|
|
51
|
+
/** Thin wrapper around `renderFieldImpl` that pre-binds `renderElement`.
|
|
52
|
+
* Lets `FormFields`, `renderFormChild`, and the renderElement switch
|
|
53
|
+
* call the form-layer field renderer with the original two-arg signature. */
|
|
54
|
+
function renderField(el, index) {
|
|
55
|
+
return renderFieldImpl(el, index, renderElement);
|
|
1596
56
|
}
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
}).catch(() => { });
|
|
1605
|
-
}
|
|
1606
|
-
};
|
|
1607
|
-
return (_jsx("button", { type: "button", onClick: handleClick, "aria-label": label, title: label, className: "inline-flex h-6 w-6 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted", children: copied ? _jsx(CheckIcon, { className: "size-3.5" }) : _jsx(CopyIcon, { className: "size-3.5" }) }));
|
|
57
|
+
/** Re-export the form-layer `renderFormChild` with `renderElement`
|
|
58
|
+
* pre-bound, so external consumers (e.g. `SelectFieldInput.tsx`) keep
|
|
59
|
+
* importing it from `SchemaRenderer.js` with the same four-arg signature.
|
|
60
|
+
* Internal callers (action layer dialogs, ActionGroupTrigger) get the
|
|
61
|
+
* same closure through prop injection. */
|
|
62
|
+
export function renderFormChild(child, index, values, errors) {
|
|
63
|
+
return renderFormChildImpl(child, index, values, errors, renderElement);
|
|
1608
64
|
}
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
};
|
|
1623
|
-
const ALERT_TYPE_DEFAULT_ICON_COLOR = {
|
|
1624
|
-
info: 'info',
|
|
1625
|
-
warning: 'warning',
|
|
1626
|
-
success: 'success',
|
|
1627
|
-
danger: 'destructive',
|
|
1628
|
-
};
|
|
1629
|
-
const ALERT_ACTIONS_ALIGNMENT = {
|
|
1630
|
-
start: 'justify-start',
|
|
1631
|
-
center: 'justify-center',
|
|
1632
|
-
end: 'justify-end',
|
|
65
|
+
/** Local wrapper around the form-layer `FormRenderer` that pre-binds
|
|
66
|
+
* `renderElement`. Kept thin so the switch case below stays a one-liner. */
|
|
67
|
+
function FormRenderer({ el }) {
|
|
68
|
+
return _jsx(FormRendererImpl, { el: el, renderElement: renderElement });
|
|
69
|
+
}
|
|
70
|
+
/** Pre-bind the three injected deps that `TableRendererBody` needs:
|
|
71
|
+
* - `renderElement` for column cells holding Element-typed children
|
|
72
|
+
* - `renderActionLike` for row + bulk action dispatch
|
|
73
|
+
* - `renderFormChild` for the inline-edit modal's form schema body */
|
|
74
|
+
const tableBodyDeps = {
|
|
75
|
+
get renderElement() { return renderElement; },
|
|
76
|
+
get renderActionLike() { return renderActionLike; },
|
|
77
|
+
get renderFormChild() { return renderFormChild; },
|
|
1633
78
|
};
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
function
|
|
1638
|
-
|
|
1639
|
-
const [dismissed, setDismissed] = useState(false);
|
|
1640
|
-
// Hydrate persisted-dismissal on first paint. `useState(false)` keeps
|
|
1641
|
-
// SSR + first client paint identical (Hydration safe); the effect
|
|
1642
|
-
// flips to dismissed if localStorage has the flag set.
|
|
1643
|
-
useEffect(() => {
|
|
1644
|
-
if (!persistDismissal)
|
|
1645
|
-
return;
|
|
1646
|
-
if (typeof window === 'undefined')
|
|
1647
|
-
return;
|
|
1648
|
-
try {
|
|
1649
|
-
if (window.localStorage.getItem(alertPersistKey(persistDismissal)) === '1') {
|
|
1650
|
-
setDismissed(true);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
catch { /* localStorage blocked (Safari ITP / SSR) — render visible */ }
|
|
1654
|
-
}, [persistDismissal]);
|
|
1655
|
-
if (dismissed)
|
|
1656
|
-
return null;
|
|
1657
|
-
const styles = alertStyles[alertType] ?? alertStyles['info'];
|
|
1658
|
-
const Icon = ALERT_TYPE_ICONS[alertType] ?? InfoIcon;
|
|
1659
|
-
const iconColorKey = iconColor ?? ALERT_TYPE_DEFAULT_ICON_COLOR[alertType] ?? 'info';
|
|
1660
|
-
const iconColorCls = TEXT_COLOR_CLASSES[iconColorKey] ?? '';
|
|
1661
|
-
const alignCls = ALERT_ACTIONS_ALIGNMENT[actionsAlignment ?? 'start'] ?? 'justify-start';
|
|
1662
|
-
const handleDismiss = () => {
|
|
1663
|
-
setDismissed(true);
|
|
1664
|
-
if (persistDismissal && typeof window !== 'undefined') {
|
|
1665
|
-
try {
|
|
1666
|
-
window.localStorage.setItem(alertPersistKey(persistDismissal), '1');
|
|
1667
|
-
}
|
|
1668
|
-
catch { /* localStorage blocked — dismiss is per-mount only */ }
|
|
1669
|
-
}
|
|
1670
|
-
};
|
|
1671
|
-
return (_jsxs("div", { className: `relative rounded-lg border p-4 ${styles} ${dismissible ? 'pr-9' : ''}`, children: [_jsxs("div", { className: "flex gap-3", children: [_jsx(Icon, { className: `size-5 shrink-0 mt-0.5 ${iconColorCls}`, "aria-hidden": "true" }), _jsxs("div", { className: "flex-1 min-w-0", children: [title !== undefined && _jsx("p", { className: "font-medium mb-1", children: title }), _jsx("p", { className: "text-sm", children: content }), footer.length > 0 && (_jsx("div", { className: `flex items-center gap-2 mt-3 ${alignCls}`, children: footer }))] })] }), dismissible && (_jsx("button", { type: "button", onClick: handleDismiss, "aria-label": "Dismiss", title: "Dismiss", className: "absolute top-3 right-3 inline-flex h-6 w-6 items-center justify-center rounded opacity-70 hover:opacity-100 transition-opacity", children: _jsx(XIcon, { className: "size-4", "aria-hidden": "true" }) }))] }));
|
|
79
|
+
/** Local wrapper around the table-layer `TableRenderer` that injects the
|
|
80
|
+
* three renderer deps. The body lives behind a separate import so the
|
|
81
|
+
* module cycle stays clean. */
|
|
82
|
+
function TableRenderer({ el }) {
|
|
83
|
+
return _jsx(TableRendererImpl, { el: el, deps: tableBodyDeps });
|
|
1672
84
|
}
|
|
1673
85
|
function renderElement(el, index) {
|
|
86
|
+
// Stateless leaves + layout primitives — text/image/icon/markdown/html/
|
|
87
|
+
// heading/emptyState/divider/unorderedList/card/grid/group/split/fieldset.
|
|
88
|
+
// Returns undefined for unhandled types so the switch below picks them up.
|
|
89
|
+
const simple = renderSimpleElement(el, index, { renderElement, renderActionLike });
|
|
90
|
+
if (simple !== undefined)
|
|
91
|
+
return simple;
|
|
1674
92
|
switch (el.type) {
|
|
1675
|
-
case 'text':
|
|
1676
|
-
return renderText(el, index);
|
|
1677
|
-
case 'image': {
|
|
1678
|
-
const url = String(el['url'] ?? '');
|
|
1679
|
-
const alt = String(el['alt'] ?? '');
|
|
1680
|
-
const width = el['width'];
|
|
1681
|
-
const height = el['height'];
|
|
1682
|
-
const shape = String(el['shape'] ?? 'square');
|
|
1683
|
-
const shapeCls = shape === 'circle' ? 'rounded-full' : shape === 'rounded' ? 'rounded-md' : '';
|
|
1684
|
-
return (_jsx("img", { src: url, alt: alt, ...(width !== undefined ? { width } : {}), ...(height !== undefined ? { height } : {}), className: `inline-block object-cover ${shapeCls}` }, index));
|
|
1685
|
-
}
|
|
1686
|
-
case 'icon': {
|
|
1687
|
-
const name = el['name'] ? String(el['name']) : undefined;
|
|
1688
|
-
const size = el['size'] ?? 16;
|
|
1689
|
-
const color = String(el['color'] ?? 'default');
|
|
1690
|
-
const label = el['label'] ? String(el['label']) : undefined;
|
|
1691
|
-
const Cmp = resolveIcon(name);
|
|
1692
|
-
if (!Cmp)
|
|
1693
|
-
return null;
|
|
1694
|
-
const colorClass = COLUMN_COLOR_CLASSES[color] ?? '';
|
|
1695
|
-
return (_jsx(Cmp, { className: `inline ${colorClass}`, ...(label ? { 'aria-label': label } : { 'aria-hidden': true }), style: { width: size, height: size } }, index));
|
|
1696
|
-
}
|
|
1697
|
-
case 'markdown':
|
|
1698
|
-
case 'html': {
|
|
1699
|
-
const html = String(el['html'] ?? '');
|
|
1700
|
-
const prose = el['prose'] !== false;
|
|
1701
|
-
const size = el['size'] ? String(el['size']) : undefined;
|
|
1702
|
-
const proseCls = prose
|
|
1703
|
-
? `prose max-w-none ${size === 'sm' ? 'prose-sm' : size === 'lg' ? 'prose-lg' : ''}`.trim()
|
|
1704
|
-
: '';
|
|
1705
|
-
return (_jsx("div", { className: proseCls || undefined, dangerouslySetInnerHTML: { __html: html } }, index));
|
|
1706
|
-
}
|
|
1707
|
-
case 'heading': {
|
|
1708
|
-
const level = el['level'] ?? 1;
|
|
1709
|
-
const content = String(el['content'] ?? '');
|
|
1710
|
-
const description = el['description'] ? String(el['description']) : undefined;
|
|
1711
|
-
const headerActions = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
|
|
1712
|
-
const Tag = level === 1 ? 'h1' : level === 2 ? 'h2' : 'h3';
|
|
1713
|
-
const sizes = { 1: 'text-2xl', 2: 'text-xl', 3: 'text-lg' };
|
|
1714
|
-
const titleBlock = (_jsxs("div", { children: [_jsx(Tag, { className: `${sizes[level]} font-bold tracking-tight`, children: content }), description && (_jsx("p", { className: "text-sm text-muted-foreground mt-1", children: description }))] }));
|
|
1715
|
-
if (headerActions.length === 0) {
|
|
1716
|
-
return _jsx("div", { children: titleBlock }, index);
|
|
1717
|
-
}
|
|
1718
|
-
return (_jsxs("div", { className: "flex items-start justify-between gap-4", children: [titleBlock, _jsx("div", { className: "flex items-center gap-2 shrink-0", children: headerActions.map((a, i) => renderActionLike(a, i)) })] }, index));
|
|
1719
|
-
}
|
|
1720
93
|
case 'alert': {
|
|
1721
94
|
const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
|
|
1722
95
|
return (_jsx(AlertRenderer, { alertType: String(el['alertType'] ?? 'info'), content: String(el['content'] ?? ''), ...(el['title'] !== undefined ? { title: String(el['title']) } : {}), ...(el['dismissible'] ? { dismissible: Boolean(el['dismissible']) } : {}), ...(el['persistDismissal'] !== undefined ? { persistDismissal: String(el['persistDismissal']) } : {}), ...(el['iconColor'] !== undefined ? { iconColor: String(el['iconColor']) } : {}), ...(el['actionsAlignment'] !== undefined ? { actionsAlignment: String(el['actionsAlignment']) } : {}), footer: footer.map((a, i) => renderActionLike(a, i)) }, index));
|
|
1723
96
|
}
|
|
1724
|
-
case 'emptyState': {
|
|
1725
|
-
const heading = String(el['heading'] ?? '');
|
|
1726
|
-
const description = el['description'] ? String(el['description']) : undefined;
|
|
1727
|
-
const iconName = el['icon'] ? String(el['icon']) : undefined;
|
|
1728
|
-
const contained = el['contained'] !== false;
|
|
1729
|
-
const Icon = iconName ? resolveIcon(iconName) : undefined;
|
|
1730
|
-
const footer = (el.children ?? []).filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
|
|
1731
|
-
const wrapper = contained
|
|
1732
|
-
? 'rounded-lg border border-border bg-card text-card-foreground py-12 px-6'
|
|
1733
|
-
: 'py-8';
|
|
1734
|
-
return (_jsxs("div", { className: `${wrapper} flex flex-col items-center text-center gap-3`, children: [Icon && _jsx(Icon, { className: "size-10 text-muted-foreground", "aria-hidden": "true" }), _jsx("h3", { className: "text-lg font-semibold", children: heading }), description && _jsx("p", { className: "text-sm text-muted-foreground max-w-md", children: description }), footer.length > 0 && (_jsx("div", { className: "flex items-center gap-2 mt-2", children: footer.map((a, i) => renderActionLike(a, i)) }))] }, index));
|
|
1735
|
-
}
|
|
1736
|
-
case 'divider': {
|
|
1737
|
-
const label = el['label'] ? String(el['label']) : undefined;
|
|
1738
|
-
return label
|
|
1739
|
-
? _jsxs("div", { className: "relative py-2", children: [_jsx("div", { className: "absolute inset-0 flex items-center", children: _jsx("span", { className: "w-full border-t border-border" }) }), _jsx("div", { className: "relative flex justify-center", children: _jsx("span", { className: "bg-background px-2 text-xs text-muted-foreground", children: label }) })] }, index)
|
|
1740
|
-
: _jsx("hr", { className: "border-border" }, index);
|
|
1741
|
-
}
|
|
1742
|
-
case 'unorderedList': {
|
|
1743
|
-
const items = el['items'] ?? [];
|
|
1744
|
-
const color = el['color'] ? String(el['color']) : undefined;
|
|
1745
|
-
const size = el['size'] ? String(el['size']) : undefined;
|
|
1746
|
-
const weight = el['weight'] ? String(el['weight']) : undefined;
|
|
1747
|
-
const sizeCls = size ? (TEXT_SIZE_CLASSES[size] ?? '') : 'text-sm';
|
|
1748
|
-
const colorCls = color ? (TEXT_COLOR_CLASSES[color] ?? '') : '';
|
|
1749
|
-
const weightCls = weight ? (TEXT_WEIGHT_CLASSES[weight] ?? '') : '';
|
|
1750
|
-
return (_jsx("ul", { className: `list-disc list-inside space-y-1 ${sizeCls} ${colorCls} ${weightCls}`.trim(), children: items.map((item, i) => (_jsx("li", { children: String(item) }, i))) }, index));
|
|
1751
|
-
}
|
|
1752
|
-
case 'card': {
|
|
1753
|
-
const title = el['title'] ? String(el['title']) : undefined;
|
|
1754
|
-
const description = el['description'] ? String(el['description']) : undefined;
|
|
1755
|
-
return (_jsxs("div", { className: "rounded-xl border bg-card p-6 shadow-sm", children: [title && _jsx("h3", { className: "font-semibold mb-1", children: title }), description && _jsx("p", { className: "text-sm text-muted-foreground mb-4", children: description }), renderChildren(el.children)] }, index));
|
|
1756
|
-
}
|
|
1757
97
|
case 'section':
|
|
1758
|
-
return _jsx(SectionRenderer, { el: el, index: index }, index);
|
|
98
|
+
return _jsx(SectionRenderer, { el: el, index: index, renderElement: renderElement }, index);
|
|
1759
99
|
case 'tabs':
|
|
1760
|
-
return _jsx(TabsRenderer, { el: el, index: index }, index);
|
|
100
|
+
return _jsx(TabsRenderer, { el: el, index: index, renderElement: renderElement }, index);
|
|
1761
101
|
case 'tab':
|
|
1762
102
|
// Tabs are rendered by their parent `tabs` element; standalone Tab is a no-op.
|
|
1763
103
|
return null;
|
|
@@ -1770,55 +110,19 @@ function renderElement(el, index) {
|
|
|
1770
110
|
case 'listTab':
|
|
1771
111
|
// List tabs are rendered by their parent `listTabs` strip; standalone is a no-op.
|
|
1772
112
|
return null;
|
|
1773
|
-
case 'grid': {
|
|
1774
|
-
const columns = Math.max(1, Math.min(12, Number(el['columns'] ?? 2)));
|
|
1775
|
-
const gapPx = el['gap'] !== undefined ? `${Number(el['gap'])}px` : undefined;
|
|
1776
|
-
return (_jsx("div", { className: `grid gap-4 ${layoutClasses(el)}`.trim(), style: {
|
|
1777
|
-
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
1778
|
-
...(gapPx ? { gap: gapPx } : {}),
|
|
1779
|
-
}, children: (el.children ?? []).map((c, i) => renderElement(c, i)) }, index));
|
|
1780
|
-
}
|
|
1781
|
-
case 'group': {
|
|
1782
|
-
const layout = layoutClasses(el);
|
|
1783
|
-
return (_jsx("div", { className: layout || undefined, children: renderChildren(el.children) }, index));
|
|
1784
|
-
}
|
|
1785
|
-
case 'split': {
|
|
1786
|
-
const from = el['from'] === 'left' ? 'left' : 'right';
|
|
1787
|
-
const gap = Math.max(0, Math.min(12, Number(el['gap'] ?? 6)));
|
|
1788
|
-
const children = el.children ?? [];
|
|
1789
|
-
// Find the explicit aside child first; fall back to "second child is
|
|
1790
|
-
// aside" so terse Split.make().schema([main, aside]) still works.
|
|
1791
|
-
let asideIdx = children.findIndex(c => c['aside'] === true);
|
|
1792
|
-
if (asideIdx === -1 && children.length >= 2)
|
|
1793
|
-
asideIdx = 1;
|
|
1794
|
-
const mainChildren = children.filter((_, i) => i !== asideIdx);
|
|
1795
|
-
const asideChild = asideIdx >= 0 ? children[asideIdx] : undefined;
|
|
1796
|
-
const orderClasses = from === 'left'
|
|
1797
|
-
? { aside: '@md:order-first', main: '@md:order-last' }
|
|
1798
|
-
: { aside: '@md:order-last', main: '@md:order-first' };
|
|
1799
|
-
return (_jsxs("div", { className: `@container flex flex-col @md:flex-row gap-${gap} ${layoutClasses(el)}`.trim(), children: [_jsx("div", { className: `flex flex-col gap-4 flex-1 min-w-0 ${orderClasses.main}`, children: mainChildren.map((c, i) => renderElement(c, i)) }), asideChild && (_jsx("aside", { className: `flex flex-col gap-4 @md:w-80 @md:shrink-0 ${orderClasses.aside}`, children: renderElement(asideChild, asideIdx) }))] }, index));
|
|
1800
|
-
}
|
|
1801
|
-
case 'fieldset': {
|
|
1802
|
-
const label = String(el['label'] ?? '');
|
|
1803
|
-
const columns = Math.max(1, Math.min(3, Number(el['columns'] ?? 1)));
|
|
1804
|
-
const gridStyle = columns > 1
|
|
1805
|
-
? { display: 'grid', gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`, gap: '1rem' }
|
|
1806
|
-
: undefined;
|
|
1807
|
-
return (_jsxs("fieldset", { className: `rounded-md border border-border px-4 pt-3 pb-4 ${layoutClasses(el)}`.trim(), children: [label && _jsx("legend", { className: "px-1 text-xs font-medium text-muted-foreground", children: label }), _jsx("div", { className: columns === 1 ? 'flex flex-col gap-3' : undefined, style: gridStyle, children: (el.children ?? []).map((c, i) => renderElement(c, i)) })] }, index));
|
|
1808
|
-
}
|
|
1809
113
|
case 'wizard':
|
|
1810
|
-
return _jsx(WizardRenderer, { el: el, index: index }, index);
|
|
114
|
+
return (_jsx(WizardRenderer, { el: el, index: index, deps: { renderElement } }, index));
|
|
1811
115
|
case 'step':
|
|
1812
116
|
// Steps are rendered by their parent Wizard; standalone Step is a no-op.
|
|
1813
117
|
return null;
|
|
1814
118
|
case 'field':
|
|
1815
119
|
return renderField(el, index);
|
|
1816
120
|
case 'entry':
|
|
1817
|
-
return renderEntry(el, index);
|
|
121
|
+
return renderEntry(el, index, renderElement);
|
|
1818
122
|
case 'action':
|
|
1819
|
-
return renderAction(el, index);
|
|
123
|
+
return renderAction(el, index, {}, { renderElement, renderFormChild });
|
|
1820
124
|
case 'actionGroup':
|
|
1821
|
-
return _jsx(ActionGroupTrigger, { el: el }, index);
|
|
125
|
+
return (_jsx(ActionGroupTrigger, { el: el, renderFormChild: renderFormChild, renderElement: renderElement }, index));
|
|
1822
126
|
case 'form': {
|
|
1823
127
|
// Key on formId so SPA navigation between pages with different
|
|
1824
128
|
// forms (list → edit, edit → edit-of-different-record, etc.)
|
|
@@ -1870,954 +174,6 @@ function renderElement(el, index) {
|
|
|
1870
174
|
}
|
|
1871
175
|
}
|
|
1872
176
|
}
|
|
1873
|
-
// ─── Form ───────────────────────────────────────────────────
|
|
1874
|
-
function FormRenderer({ el }) {
|
|
1875
|
-
const formId = String(el['formId'] ?? '');
|
|
1876
|
-
const method = String(el['method'] ?? 'post').toLowerCase();
|
|
1877
|
-
const action = el['action'] ? String(el['action']) : undefined;
|
|
1878
|
-
const stateUrl = el['stateUrl'] ? String(el['stateUrl']) : undefined;
|
|
1879
|
-
const serverValues = el['values'] ?? {};
|
|
1880
|
-
const serverErrors = el['errors'] ?? {};
|
|
1881
|
-
// Methods other than GET/POST are spoofed via _method, mirroring Laravel.
|
|
1882
|
-
const httpMethod = method === 'get' ? 'get' : 'post';
|
|
1883
|
-
const spoofedMethod = method !== 'get' && method !== 'post' ? method : undefined;
|
|
1884
|
-
const navigate = useNavigate();
|
|
1885
|
-
const { notify } = useToast();
|
|
1886
|
-
// Client-side errors override server-rendered ones after a fetch-mode
|
|
1887
|
-
// 422 response. Field values stay uncontrolled — the inputs in the DOM
|
|
1888
|
-
// still hold whatever the user typed, so we don't need to mirror them.
|
|
1889
|
-
const [clientErrors, setClientErrors] = useState(null);
|
|
1890
|
-
const [submitting, setSubmitting] = useState(false);
|
|
1891
|
-
const errors = clientErrors ?? serverErrors;
|
|
1892
|
-
// Plan #14 — formRef is threaded into FormStateProvider so live triggers
|
|
1893
|
-
// can snapshot the form's full DOM state via FormData (captures
|
|
1894
|
-
// uncontrolled inner-Repeater inputs that don't participate in the
|
|
1895
|
-
// controlled values map).
|
|
1896
|
-
const formRef = useRef(null);
|
|
1897
|
-
const formErrors = errors['_form'] ?? [];
|
|
1898
|
-
const hasFieldErrors = Object.keys(errors).some(k => k !== '_form');
|
|
1899
|
-
const onSubmit = async (e) => {
|
|
1900
|
-
if (!action)
|
|
1901
|
-
return; // no action URL → fall through to native submit
|
|
1902
|
-
e.preventDefault();
|
|
1903
|
-
if (submitting)
|
|
1904
|
-
return;
|
|
1905
|
-
setSubmitting(true);
|
|
1906
|
-
setClientErrors(null);
|
|
1907
|
-
try {
|
|
1908
|
-
// Thread `event.submitter` so the clicked submit button's
|
|
1909
|
-
// name/value pair lands in the FormData. Without this, secondary
|
|
1910
|
-
// submits like "Create & create another" can't signal which
|
|
1911
|
-
// button fired through the body. Supported in all evergreen
|
|
1912
|
-
// browsers since 2022; cast through `as any` because TS lib.dom
|
|
1913
|
-
// hasn't picked up the optional submitter argument on every
|
|
1914
|
-
// version.
|
|
1915
|
-
const submitter = e.nativeEvent.submitter;
|
|
1916
|
-
const fd = new FormData(e.currentTarget, submitter ?? undefined);
|
|
1917
|
-
const res = await fetch(action, {
|
|
1918
|
-
method: 'POST',
|
|
1919
|
-
headers: { 'Accept': 'application/json' },
|
|
1920
|
-
body: fd,
|
|
1921
|
-
});
|
|
1922
|
-
const data = await res.json().catch(() => ({}));
|
|
1923
|
-
if (res.status === 422) {
|
|
1924
|
-
const next = data.errors ?? {};
|
|
1925
|
-
setClientErrors(next);
|
|
1926
|
-
// Surface a banner-level message if no field errors were returned
|
|
1927
|
-
// — the form-level _form key lights up the existing banner.
|
|
1928
|
-
setSubmitting(false);
|
|
1929
|
-
return;
|
|
1930
|
-
}
|
|
1931
|
-
if (!res.ok) {
|
|
1932
|
-
const message = String(data.error ?? `Request failed (${res.status})`);
|
|
1933
|
-
notify({ type: 'error', title: 'Save failed', body: message });
|
|
1934
|
-
setSubmitting(false);
|
|
1935
|
-
return;
|
|
1936
|
-
}
|
|
1937
|
-
// Success — drain notifications and SPA-navigate to the redirect.
|
|
1938
|
-
const notifs = data.notifications;
|
|
1939
|
-
if (notifs && notifs.length > 0)
|
|
1940
|
-
for (const n of notifs)
|
|
1941
|
-
notify(n);
|
|
1942
|
-
const redirect = String(data.redirect ?? '');
|
|
1943
|
-
// The server may force a navigate even when the redirect equals
|
|
1944
|
-
// the current URL — used by "Create & create another" so the
|
|
1945
|
-
// form remounts with empty defaults instead of preserving the
|
|
1946
|
-
// just-submitted values. Otherwise: skip navigate when the
|
|
1947
|
-
// redirect matches the current URL, since re-fetching the same
|
|
1948
|
-
// page would force a form remount and reset scroll.
|
|
1949
|
-
const force = Boolean(data.force);
|
|
1950
|
-
const currentUrl = typeof window !== 'undefined'
|
|
1951
|
-
? window.location.pathname + window.location.search
|
|
1952
|
-
: '';
|
|
1953
|
-
if (redirect && (force || redirect !== currentUrl)) {
|
|
1954
|
-
navigate(redirect);
|
|
1955
|
-
// Don't reset submitting on success — the navigation will unmount us.
|
|
1956
|
-
}
|
|
1957
|
-
else {
|
|
1958
|
-
setSubmitting(false);
|
|
1959
|
-
}
|
|
1960
|
-
}
|
|
1961
|
-
catch (err) {
|
|
1962
|
-
notify({ type: 'error', title: 'Save failed', body: err instanceof Error ? err.message : String(err) });
|
|
1963
|
-
setSubmitting(false);
|
|
1964
|
-
}
|
|
1965
|
-
};
|
|
1966
|
-
return (_jsxs("form", { ref: formRef, id: formId || undefined, "data-form-id": formId || undefined, method: httpMethod, action: action, onSubmit: onSubmit, className: "flex flex-col gap-6", children: [formId && _jsx("input", { type: "hidden", name: "_formId", value: formId }), spoofedMethod && _jsx("input", { type: "hidden", name: "_method", value: spoofedMethod }), (formErrors.length > 0 || hasFieldErrors) && (_jsx("div", { className: "rounded-lg border border-destructive/40 bg-destructive/5 text-destructive p-3 text-sm", children: formErrors.length > 0 ? (_jsx("ul", { className: "list-disc pl-4", children: formErrors.map((msg, i) => _jsx("li", { children: msg }, i)) })) : ('Please correct the errors below.') })), _jsx(FormIdContext.Provider, { value: formId, children: stateUrl ? (_jsx(FormStateProvider, { initialMeta: el, initialErrors: errors, formRef: formRef, children: _jsx(FormBody, { fallbackChildren: el.children ?? [], fallbackValues: serverValues, fallbackErrors: errors }) })) : ((el.children ?? []).map((child, i) => renderFormChild(child, i, serverValues, errors))) })] }));
|
|
1967
|
-
}
|
|
1968
|
-
/**
|
|
1969
|
-
* Renders the controlled-form's children, sourcing them from the
|
|
1970
|
-
* `FormStateProvider`'s current `formMeta` (which gets replaced after
|
|
1971
|
-
* each live POST). Falls back to the props if (somehow) used outside a
|
|
1972
|
-
* provider — the shell only mounts this when `stateUrl` is set so the
|
|
1973
|
-
* fallback path is dead code in practice, but keeping it defensive.
|
|
1974
|
-
*/
|
|
1975
|
-
function FormBody({ fallbackChildren, fallbackValues, fallbackErrors, }) {
|
|
1976
|
-
const ctx = useFormState();
|
|
1977
|
-
if (!ctx) {
|
|
1978
|
-
return _jsx(_Fragment, { children: fallbackChildren.map((child, i) => renderFormChild(child, i, fallbackValues, fallbackErrors)) });
|
|
1979
|
-
}
|
|
1980
|
-
const children = (ctx.formMeta.children ?? []);
|
|
1981
|
-
return _jsx(_Fragment, { children: children.map((child, i) => renderFormChild(child, i, ctx.values, ctx.errors)) });
|
|
1982
|
-
}
|
|
1983
|
-
/**
|
|
1984
|
-
* Render one child of a form's resolved schema with per-field values + errors.
|
|
1985
|
-
*
|
|
1986
|
-
* Exported so sibling renderers (e.g. `SelectFieldInput`'s inline-create
|
|
1987
|
-
* modal) can render a sub-schema with the same FieldShell + error-stamping
|
|
1988
|
-
* conventions as the parent form. Public surface beyond the file boundary
|
|
1989
|
-
* stays narrow — callers should pass `child.type === 'field'` elements;
|
|
1990
|
-
* non-field elements fall through to `renderElement`.
|
|
1991
|
-
*/
|
|
1992
|
-
export function renderFormChild(child, index, values, errors) {
|
|
1993
|
-
if (child.type === 'field') {
|
|
1994
|
-
const name = String(child['name'] ?? '');
|
|
1995
|
-
const fieldErrors = errors[name] ?? [];
|
|
1996
|
-
const value = values[name];
|
|
1997
|
-
return (_jsxs("div", { className: "flex flex-col gap-1", children: [renderFieldWithValue(child, index, value), fieldErrors.map((msg, i) => (_jsx("p", { className: "text-xs text-destructive", children: msg }, i)))] }, index));
|
|
1998
|
-
}
|
|
1999
|
-
return renderElement(child, index);
|
|
2000
|
-
}
|
|
2001
|
-
function renderFieldWithValue(el, index, value) {
|
|
2002
|
-
// The form-state value (from `withValues` / record-fill) wins when present;
|
|
2003
|
-
// otherwise the meta's own `defaultValue` (Plan #6 `Field.default()`) survives.
|
|
2004
|
-
const enriched = value !== undefined
|
|
2005
|
-
? { ...el, defaultValue: value }
|
|
2006
|
-
: el;
|
|
2007
|
-
return renderField(enriched, index);
|
|
2008
|
-
}
|
|
2009
|
-
// Mirror of `prefixedKey` in `elements/dispatchTable.ts`. Kept inline so
|
|
2010
|
-
// SchemaRenderer doesn't drag the server-side dispatcher into the client
|
|
2011
|
-
// bundle.
|
|
2012
|
-
function prefixK(prefix, key) {
|
|
2013
|
-
return prefix === undefined || prefix === '' ? key : `${prefix}_${key}`;
|
|
2014
|
-
}
|
|
2015
|
-
let cachedSearchString = null;
|
|
2016
|
-
let cachedSearchParams = null;
|
|
2017
|
-
function getCurrentSearchParams() {
|
|
2018
|
-
if (typeof window === 'undefined')
|
|
2019
|
-
return null;
|
|
2020
|
-
const s = window.location.search;
|
|
2021
|
-
if (s === cachedSearchString && cachedSearchParams)
|
|
2022
|
-
return cachedSearchParams;
|
|
2023
|
-
cachedSearchString = s;
|
|
2024
|
-
cachedSearchParams = new URLSearchParams(s);
|
|
2025
|
-
return cachedSearchParams;
|
|
2026
|
-
}
|
|
2027
|
-
function SearchFormHiddenInputs({ prefix }) {
|
|
2028
|
-
const sp = getCurrentSearchParams();
|
|
2029
|
-
if (!sp)
|
|
2030
|
-
return _jsx(_Fragment, {});
|
|
2031
|
-
const searchKey = prefixK(prefix, 'search');
|
|
2032
|
-
const pageKey = prefixK(prefix, 'page');
|
|
2033
|
-
const inputs = [];
|
|
2034
|
-
let i = 0;
|
|
2035
|
-
for (const [k, v] of sp) {
|
|
2036
|
-
if (k === searchKey || k === pageKey)
|
|
2037
|
-
continue;
|
|
2038
|
-
inputs.push(_jsx("input", { type: "hidden", name: k, value: v }, i++));
|
|
2039
|
-
}
|
|
2040
|
-
return _jsx(_Fragment, { children: inputs });
|
|
2041
|
-
}
|
|
2042
|
-
function buildTableQuery(state, override, pathname, filterValues = {}, prefix) {
|
|
2043
|
-
const merged = { ...state, ...override };
|
|
2044
|
-
const params = new URLSearchParams();
|
|
2045
|
-
// Foreign URL params (other tables' state, app-level params) round-trip
|
|
2046
|
-
// verbatim so this builder only ever rewrites its own slice.
|
|
2047
|
-
const currentParams = getCurrentSearchParams();
|
|
2048
|
-
if (currentParams) {
|
|
2049
|
-
const ours = new Set([
|
|
2050
|
-
prefixK(prefix, 'search'),
|
|
2051
|
-
prefixK(prefix, 'sort'),
|
|
2052
|
-
prefixK(prefix, 'page'),
|
|
2053
|
-
prefixK(prefix, 'perPage'),
|
|
2054
|
-
prefixK(prefix, 'group'),
|
|
2055
|
-
prefixK(prefix, 'groupKey'),
|
|
2056
|
-
...Object.keys(filterValues).map(n => prefixK(prefix, n)),
|
|
2057
|
-
]);
|
|
2058
|
-
for (const [k, v] of currentParams) {
|
|
2059
|
-
if (ours.has(k))
|
|
2060
|
-
continue;
|
|
2061
|
-
params.set(k, v);
|
|
2062
|
-
}
|
|
2063
|
-
}
|
|
2064
|
-
// Carry forward active filter values so sort/pagination links don't
|
|
2065
|
-
// accidentally clear them. Filter names can't collide with reserved
|
|
2066
|
-
// keys (search/sort/page/perPage/group) — that's enforced upstream.
|
|
2067
|
-
for (const [name, val] of Object.entries(filterValues)) {
|
|
2068
|
-
if (val)
|
|
2069
|
-
params.set(prefixK(prefix, name), val);
|
|
2070
|
-
}
|
|
2071
|
-
if (merged.search)
|
|
2072
|
-
params.set(prefixK(prefix, 'search'), merged.search);
|
|
2073
|
-
if (merged.sort)
|
|
2074
|
-
params.set(prefixK(prefix, 'sort'), `${merged.sort.column}:${merged.sort.direction}`);
|
|
2075
|
-
if (merged.page && merged.page > 1)
|
|
2076
|
-
params.set(prefixK(prefix, 'page'), String(merged.page));
|
|
2077
|
-
if (merged.group !== undefined)
|
|
2078
|
-
params.set(prefixK(prefix, 'group'), merged.group);
|
|
2079
|
-
// groupKey is sparse — only writes when the override sets a non-empty
|
|
2080
|
-
// value. Drill-out (chip ×) passes `''` to clear; the foreign-param
|
|
2081
|
-
// dedupe set above already filtered the stale value out, so an empty
|
|
2082
|
-
// override produces a URL without the key.
|
|
2083
|
-
if (merged.groupKey)
|
|
2084
|
-
params.set(prefixK(prefix, 'groupKey'), merged.groupKey);
|
|
2085
|
-
const qs = params.toString();
|
|
2086
|
-
// Always anchor to a real pathname — Vike's client-side router treats
|
|
2087
|
-
// a bare `?qs` href as a fresh URL with empty pathname, which routes
|
|
2088
|
-
// to the dashboard and blanks the page during SPA navigation.
|
|
2089
|
-
const base = pathname || (typeof window !== 'undefined' ? window.location.pathname : '');
|
|
2090
|
-
return qs ? `${base}?${qs}` : (base || '#');
|
|
2091
|
-
}
|
|
2092
|
-
function nextSortDir(current, column) {
|
|
2093
|
-
if (current?.column === column) {
|
|
2094
|
-
return { column, direction: current.direction === 'asc' ? 'desc' : 'asc' };
|
|
2095
|
-
}
|
|
2096
|
-
return { column, direction: 'asc' };
|
|
2097
|
-
}
|
|
2098
|
-
/** Map ColumnColor → tailwind text-color class. Used by TextColumn and
|
|
2099
|
-
* IconColumn alike. */
|
|
2100
|
-
const COLUMN_COLOR_CLASSES = {
|
|
2101
|
-
default: '',
|
|
2102
|
-
muted: 'text-muted-foreground',
|
|
2103
|
-
primary: 'text-primary',
|
|
2104
|
-
destructive: 'text-destructive',
|
|
2105
|
-
success: 'text-emerald-600 dark:text-emerald-400',
|
|
2106
|
-
warning: 'text-amber-600 dark:text-amber-400',
|
|
2107
|
-
info: 'text-blue-600 dark:text-blue-400',
|
|
2108
|
-
};
|
|
2109
|
-
const COLUMN_WEIGHT_CLASSES = {
|
|
2110
|
-
normal: 'font-normal',
|
|
2111
|
-
medium: 'font-medium',
|
|
2112
|
-
semibold: 'font-semibold',
|
|
2113
|
-
bold: 'font-bold',
|
|
2114
|
-
};
|
|
2115
|
-
const BADGE_COLOR_CLASSES = {
|
|
2116
|
-
gray: 'bg-muted text-muted-foreground',
|
|
2117
|
-
primary: 'bg-primary/10 text-primary',
|
|
2118
|
-
success: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200',
|
|
2119
|
-
warning: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200',
|
|
2120
|
-
destructive: 'bg-destructive/10 text-destructive',
|
|
2121
|
-
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200',
|
|
2122
|
-
};
|
|
2123
|
-
/** Apply a built-in `ColumnFormat` to a raw value; returns a string. */
|
|
2124
|
-
function applyColumnFormat(value, format) {
|
|
2125
|
-
if (value === null || value === undefined || value === '')
|
|
2126
|
-
return '';
|
|
2127
|
-
switch (format['kind']) {
|
|
2128
|
-
case 'dateTime': {
|
|
2129
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
2130
|
-
if (isNaN(d.getTime()))
|
|
2131
|
-
return String(value);
|
|
2132
|
-
// Default — locale-aware short date+time. Custom patterns aren't
|
|
2133
|
-
// supported (no date-fns dep); pattern is kept on meta for future use.
|
|
2134
|
-
return d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
2135
|
-
}
|
|
2136
|
-
case 'since': {
|
|
2137
|
-
const d = value instanceof Date ? value : new Date(String(value));
|
|
2138
|
-
if (isNaN(d.getTime()))
|
|
2139
|
-
return String(value);
|
|
2140
|
-
const seconds = Math.round((Date.now() - d.getTime()) / 1000);
|
|
2141
|
-
const abs = Math.abs(seconds);
|
|
2142
|
-
const past = seconds >= 0;
|
|
2143
|
-
const fmt = (n, unit) => past ? `${n} ${unit}${n === 1 ? '' : 's'} ago` : `in ${n} ${unit}${n === 1 ? '' : 's'}`;
|
|
2144
|
-
if (abs < 60)
|
|
2145
|
-
return past ? 'just now' : 'in a moment';
|
|
2146
|
-
if (abs < 3600)
|
|
2147
|
-
return fmt(Math.floor(abs / 60), 'minute');
|
|
2148
|
-
if (abs < 86400)
|
|
2149
|
-
return fmt(Math.floor(abs / 3600), 'hour');
|
|
2150
|
-
if (abs < 2592000)
|
|
2151
|
-
return fmt(Math.floor(abs / 86400), 'day');
|
|
2152
|
-
if (abs < 31536000)
|
|
2153
|
-
return fmt(Math.floor(abs / 2592000), 'month');
|
|
2154
|
-
return fmt(Math.floor(abs / 31536000), 'year');
|
|
2155
|
-
}
|
|
2156
|
-
case 'money': {
|
|
2157
|
-
const n = typeof value === 'number' ? value : Number(value);
|
|
2158
|
-
if (isNaN(n))
|
|
2159
|
-
return String(value);
|
|
2160
|
-
const currency = String(format['currency'] ?? 'USD');
|
|
2161
|
-
const locale = format['locale'];
|
|
2162
|
-
return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(n);
|
|
2163
|
-
}
|
|
2164
|
-
case 'numeric': {
|
|
2165
|
-
const n = typeof value === 'number' ? value : Number(value);
|
|
2166
|
-
if (isNaN(n))
|
|
2167
|
-
return String(value);
|
|
2168
|
-
const decimals = format['decimals'];
|
|
2169
|
-
const locale = format['locale'];
|
|
2170
|
-
const opts = {};
|
|
2171
|
-
if (decimals !== undefined) {
|
|
2172
|
-
opts.minimumFractionDigits = decimals;
|
|
2173
|
-
opts.maximumFractionDigits = decimals;
|
|
2174
|
-
}
|
|
2175
|
-
return new Intl.NumberFormat(locale, opts).format(n);
|
|
2176
|
-
}
|
|
2177
|
-
case 'limit': {
|
|
2178
|
-
const s = String(value);
|
|
2179
|
-
const n = format['chars'];
|
|
2180
|
-
return s.length > n ? s.slice(0, n) + '…' : s;
|
|
2181
|
-
}
|
|
2182
|
-
case 'words': {
|
|
2183
|
-
const s = String(value).trim();
|
|
2184
|
-
if (s.length === 0)
|
|
2185
|
-
return s;
|
|
2186
|
-
const tokens = s.split(/\s+/);
|
|
2187
|
-
const n = format['words'];
|
|
2188
|
-
return tokens.length > n ? tokens.slice(0, n).join(' ') + '…' : s;
|
|
2189
|
-
}
|
|
2190
|
-
default:
|
|
2191
|
-
return String(value);
|
|
2192
|
-
}
|
|
2193
|
-
}
|
|
2194
|
-
/** Render a cell. Honors the column's `columnType` (badge/icon/boolean/
|
|
2195
|
-
* image), built-in `format` spec, and per-row `_formatted[name]`
|
|
2196
|
-
* overrides from server-side `formatStateUsing` callbacks. */
|
|
2197
|
-
function formatCell(value, col, row) {
|
|
2198
|
-
if (col === undefined) {
|
|
2199
|
-
// Legacy raw-value fallback for non-column callsites.
|
|
2200
|
-
if (value === null || value === undefined)
|
|
2201
|
-
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
2202
|
-
if (value instanceof Date)
|
|
2203
|
-
return value.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
|
2204
|
-
if (typeof value === 'boolean')
|
|
2205
|
-
return value ? 'Yes' : 'No';
|
|
2206
|
-
if (typeof value === 'object')
|
|
2207
|
-
return JSON.stringify(value);
|
|
2208
|
-
return String(value);
|
|
2209
|
-
}
|
|
2210
|
-
const columnType = String(col['columnType'] ?? 'text');
|
|
2211
|
-
const fallback = col['default'];
|
|
2212
|
-
// Per-row server-eval result wins over everything.
|
|
2213
|
-
const colName = String(col['name'] ?? '');
|
|
2214
|
-
const formatted = row?.['_formatted']?.[colName];
|
|
2215
|
-
const richtext = row?.['_richtextCells']?.[colName] === true;
|
|
2216
|
-
const isBlank = value === null || value === undefined || value === '';
|
|
2217
|
-
if (formatted !== undefined && formatted !== '') {
|
|
2218
|
-
return wrapCell(formatted, col, richtext);
|
|
2219
|
-
}
|
|
2220
|
-
if (isBlank) {
|
|
2221
|
-
return _jsx("span", { className: "text-muted-foreground", children: fallback ?? '—' });
|
|
2222
|
-
}
|
|
2223
|
-
switch (columnType) {
|
|
2224
|
-
case 'badge': {
|
|
2225
|
-
const map = col['badgeColors'] ?? {};
|
|
2226
|
-
const color = map[String(value)] ?? 'gray';
|
|
2227
|
-
const cls = BADGE_COLOR_CLASSES[color] ?? BADGE_COLOR_CLASSES['gray'];
|
|
2228
|
-
return (_jsx("span", { className: `inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cls}`, children: String(value) }));
|
|
2229
|
-
}
|
|
2230
|
-
case 'icon':
|
|
2231
|
-
case 'boolean': {
|
|
2232
|
-
const map = col['iconOptions'] ?? {};
|
|
2233
|
-
const opt = map[String(value)];
|
|
2234
|
-
if (!opt)
|
|
2235
|
-
return _jsx("span", { className: "text-muted-foreground", children: "\u2014" });
|
|
2236
|
-
const Icon = resolveIcon(opt.icon) ?? CircleIcon;
|
|
2237
|
-
const colorClass = opt.color ? (COLUMN_COLOR_CLASSES[opt.color] ?? '') : '';
|
|
2238
|
-
return _jsx(Icon, { className: `size-4 inline ${colorClass}`, "aria-label": String(value) });
|
|
2239
|
-
}
|
|
2240
|
-
case 'image': {
|
|
2241
|
-
const url = String(value);
|
|
2242
|
-
const size = col['imageSize'] ?? 32;
|
|
2243
|
-
const shape = col['imageShape'] === 'circle' ? 'rounded-full' : 'rounded-md';
|
|
2244
|
-
return (_jsx("img", { src: url, alt: "", width: size, height: size, className: `${shape} object-cover` }));
|
|
2245
|
-
}
|
|
2246
|
-
case 'color': {
|
|
2247
|
-
const css = String(value);
|
|
2248
|
-
const shape = col['colorShape'];
|
|
2249
|
-
const shapeClass = shape === 'circle' ? 'rounded-full' :
|
|
2250
|
-
shape === 'square' ? 'rounded-none' : 'rounded';
|
|
2251
|
-
const hideValue = col['colorHideValue'] === true;
|
|
2252
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-2", children: [_jsx("span", { className: `size-4 border border-border ${shapeClass}`, style: { backgroundColor: css }, "aria-hidden": "true" }), !hideValue && _jsx("span", { className: "text-sm", children: css })] }));
|
|
2253
|
-
}
|
|
2254
|
-
default: {
|
|
2255
|
-
// Array-valued cells — `bulleted()` wins over `listWithLineBreaks()`
|
|
2256
|
-
// when both are set. Falls through to the standard string path for
|
|
2257
|
-
// non-array values so the per-cell formatters keep working.
|
|
2258
|
-
if (Array.isArray(value)) {
|
|
2259
|
-
const items = value.map(v => String(v));
|
|
2260
|
-
if (col['bulleted'] === true) {
|
|
2261
|
-
return wrapCellList(items, col, 'bulleted');
|
|
2262
|
-
}
|
|
2263
|
-
if (col['listWithLineBreaks'] === true) {
|
|
2264
|
-
return wrapCellList(items, col, 'lines');
|
|
2265
|
-
}
|
|
2266
|
-
// Bare array — comma-join (matches the existing legacy fallback).
|
|
2267
|
-
return wrapCell(items.join(', '), col);
|
|
2268
|
-
}
|
|
2269
|
-
// Text column — apply built-in format, then wrapper.
|
|
2270
|
-
const fmt = col['format'];
|
|
2271
|
-
const display = fmt ? applyColumnFormat(value, fmt) : String(value);
|
|
2272
|
-
return wrapCell(display, col);
|
|
2273
|
-
}
|
|
2274
|
-
}
|
|
2275
|
-
}
|
|
2276
|
-
/** Apply text-rendering chrome (color, weight, line-clamp, wrap, tooltip)
|
|
2277
|
-
* to a stringified cell value. Used by the text and per-row formatter
|
|
2278
|
-
* paths so styling stays consistent. When `asHtml` is true the content
|
|
2279
|
-
* is server-rendered HTML (e.g. from the registered richtext renderer)
|
|
2280
|
-
* and gets injected via `dangerouslySetInnerHTML`. */
|
|
2281
|
-
function wrapCell(content, col, asHtml = false) {
|
|
2282
|
-
const color = col['color'];
|
|
2283
|
-
const weight = col['weight'];
|
|
2284
|
-
const tooltip = col['tooltip'];
|
|
2285
|
-
const wrapping = Boolean(col['wrap']);
|
|
2286
|
-
const clamp = col['lineClamp'];
|
|
2287
|
-
const copyMsg = col['copyMessage'];
|
|
2288
|
-
const colorCls = color ? (COLUMN_COLOR_CLASSES[color] ?? '') : '';
|
|
2289
|
-
const weightCls = weight ? (COLUMN_WEIGHT_CLASSES[weight] ?? '') : '';
|
|
2290
|
-
const wrapCls = wrapping ? 'whitespace-normal' : '';
|
|
2291
|
-
const clampStyle = clamp !== undefined
|
|
2292
|
-
? { display: '-webkit-box', WebkitLineClamp: String(clamp), WebkitBoxOrient: 'vertical', overflow: 'hidden' }
|
|
2293
|
-
: undefined;
|
|
2294
|
-
const valueNode = asHtml
|
|
2295
|
-
? (_jsx("span", { className: `prose prose-sm max-w-none dark:prose-invert ${colorCls} ${weightCls} ${wrapCls}`.trim(), title: tooltip, style: clampStyle, dangerouslySetInnerHTML: { __html: content } }))
|
|
2296
|
-
: (_jsx("span", { className: `${colorCls} ${weightCls} ${wrapCls}`.trim(), title: tooltip, style: clampStyle, children: content }));
|
|
2297
|
-
if (copyMsg === undefined)
|
|
2298
|
-
return valueNode;
|
|
2299
|
-
// Copy-to-clipboard trigger — copies the rendered text. For richtext
|
|
2300
|
-
// cells the underlying source isn't separately stamped on the wire
|
|
2301
|
-
// (would double the row payload), so the rendered HTML is what gets
|
|
2302
|
-
// copied; admins comfortable with HTML still get something usable.
|
|
2303
|
-
return (_jsxs("span", { className: "inline-flex items-center gap-1.5", children: [valueNode, _jsx(CellCopyButton, { text: content, label: copyMsg })] }));
|
|
2304
|
-
}
|
|
2305
|
-
/** Tabular-list rendering used by `Column.bulleted()` /
|
|
2306
|
-
* `Column.listWithLineBreaks()`. `mode='bulleted'` mounts a `<ul>` with
|
|
2307
|
-
* bullet markers; `mode='lines'` separates entries with `<br>`. Both
|
|
2308
|
-
* inherit the same color / weight / wrap / tooltip / clamp chrome as
|
|
2309
|
-
* the text path. Empty arrays fall through to the muted dash. */
|
|
2310
|
-
function wrapCellList(items, col, mode) {
|
|
2311
|
-
if (items.length === 0) {
|
|
2312
|
-
const fallback = col['default'] ?? '—';
|
|
2313
|
-
return _jsx("span", { className: "text-muted-foreground", children: fallback });
|
|
2314
|
-
}
|
|
2315
|
-
const color = col['color'];
|
|
2316
|
-
const weight = col['weight'];
|
|
2317
|
-
const tooltip = col['tooltip'];
|
|
2318
|
-
const colorCls = color ? (COLUMN_COLOR_CLASSES[color] ?? '') : '';
|
|
2319
|
-
const weightCls = weight ? (COLUMN_WEIGHT_CLASSES[weight] ?? '') : '';
|
|
2320
|
-
if (mode === 'bulleted') {
|
|
2321
|
-
return (_jsx("ul", { className: `list-disc pl-4 space-y-0.5 ${colorCls} ${weightCls}`.trim(), title: tooltip, children: items.map((s, i) => _jsx("li", { children: s }, i)) }));
|
|
2322
|
-
}
|
|
2323
|
-
return (_jsx("span", { className: `${colorCls} ${weightCls}`.trim(), title: tooltip, children: items.map((s, i) => (_jsxs(React.Fragment, { children: [i > 0 && _jsx("br", {}), s] }, i))) }));
|
|
2324
|
-
}
|
|
2325
|
-
/** Slim copy-to-clipboard button used by `Column.copyMessage()`. The
|
|
2326
|
-
* label doubles as the toast text. Mirrors `EntryCopyButton`'s shape
|
|
2327
|
-
* but compact enough to live inline next to a cell value. */
|
|
2328
|
-
function CellCopyButton({ text, label }) {
|
|
2329
|
-
const [copied, setCopied] = useState(false);
|
|
2330
|
-
const handleClick = (e) => {
|
|
2331
|
-
e.stopPropagation();
|
|
2332
|
-
e.preventDefault();
|
|
2333
|
-
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
|
2334
|
-
navigator.clipboard.writeText(text).then(() => {
|
|
2335
|
-
setCopied(true);
|
|
2336
|
-
setTimeout(() => setCopied(false), 1500);
|
|
2337
|
-
}).catch(() => { });
|
|
2338
|
-
}
|
|
2339
|
-
};
|
|
2340
|
-
return (_jsx("button", { type: "button", onClick: handleClick, "aria-label": copied ? label : 'Copy', title: copied ? label : 'Copy', "data-no-row-nav": true, className: "inline-flex h-5 w-5 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted", children: copied ? _jsx(CheckIcon, { className: "size-3" }) : _jsx(CopyIcon, { className: "size-3" }) }));
|
|
2341
|
-
}
|
|
2342
|
-
function rowId(row, index) {
|
|
2343
|
-
if (row && typeof row === 'object' && 'id' in row) {
|
|
2344
|
-
const id = row.id;
|
|
2345
|
-
if (id !== undefined && id !== null)
|
|
2346
|
-
return String(id);
|
|
2347
|
-
}
|
|
2348
|
-
return String(index);
|
|
2349
|
-
}
|
|
2350
|
-
/**
|
|
2351
|
-
* Filter dropdown that updates the URL directly on change. We don't rely
|
|
2352
|
-
* on a wrapping `<form>` because filters now live inside a portaled
|
|
2353
|
-
* Popover (the search input keeps its own form for Enter-to-submit).
|
|
2354
|
-
*
|
|
2355
|
-
* Empty value (`''`) is the "All" sentinel — the param is removed from
|
|
2356
|
-
* the URL rather than serialized as `&name=`.
|
|
2357
|
-
*/
|
|
2358
|
-
function FilterSelect({ name, label, defaultValue, placeholder, options, prefix, }) {
|
|
2359
|
-
const [value, setValue] = useState(defaultValue);
|
|
2360
|
-
const navigate = useNavigate();
|
|
2361
|
-
const onChange = (next) => {
|
|
2362
|
-
const v = typeof next === 'string' ? next : '';
|
|
2363
|
-
setValue(v);
|
|
2364
|
-
if (typeof window === 'undefined')
|
|
2365
|
-
return;
|
|
2366
|
-
const url = new URL(window.location.href);
|
|
2367
|
-
const k = prefixK(prefix, name);
|
|
2368
|
-
if (v === '')
|
|
2369
|
-
url.searchParams.delete(k);
|
|
2370
|
-
else
|
|
2371
|
-
url.searchParams.set(k, v);
|
|
2372
|
-
// Filter changes reset pagination — first page of the new result set.
|
|
2373
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2374
|
-
// SPA navigate via context (vike's navigate when mounted under the
|
|
2375
|
-
// Vike-generated +Layout). Fallback is full reload — see useNavigate.
|
|
2376
|
-
void navigate(url.pathname + url.search);
|
|
2377
|
-
};
|
|
2378
|
-
return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsxs(Select, { value: value, onValueChange: onChange, children: [_jsx(SelectTrigger, { size: "sm", className: "w-full", children: _jsx(SelectValue, { placeholder: placeholder }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "", children: placeholder }), options.map(o => (_jsx(SelectItem, { value: o.value, children: o.label }, o.value)))] })] })] }));
|
|
2379
|
-
}
|
|
2380
|
-
/**
|
|
2381
|
-
* Heading-row text for a group band. Shows `<label>: <value-or-title>`
|
|
2382
|
-
* with an optional description below. Reused for both collapsible and
|
|
2383
|
-
* static heading rows.
|
|
2384
|
-
*/
|
|
2385
|
-
function GroupHeaderText({ label, value, title, description, }) {
|
|
2386
|
-
const display = title ?? value ?? '';
|
|
2387
|
-
return (_jsxs("span", { className: "flex flex-col gap-0.5", children: [_jsxs("span", { children: [label && _jsxs("span", { className: "text-muted-foreground/70", children: [label, ": "] }), _jsx("span", { className: "text-foreground", children: display || 'Ungrouped' })] }), description && (_jsx("span", { className: "text-[10px] font-normal normal-case text-muted-foreground/80", children: description }))] }));
|
|
2388
|
-
}
|
|
2389
|
-
/**
|
|
2390
|
-
* "Group by" dropdown rendered above the table when 2+ TableGroups
|
|
2391
|
-
* are registered (or 1 group with rich metadata). Selecting "None"
|
|
2392
|
-
* sets `?group=` (empty) which explicitly overrides `defaultGroup`.
|
|
2393
|
-
*
|
|
2394
|
-
* URL-driven — `onChange` builds the next href via `buildTableQuery`
|
|
2395
|
-
* and SPA-navigates; the page re-renders with the new active group.
|
|
2396
|
-
*/
|
|
2397
|
-
function TableGroupPicker({ options, active, onChange, }) {
|
|
2398
|
-
const value = active ?? '';
|
|
2399
|
-
return (_jsxs(Select, { value: value, onValueChange: (v) => onChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-9 w-44", children: _jsx(SelectValue, { placeholder: "Group by\u2026" }) }), _jsxs(SelectContent, { children: [_jsx(SelectItem, { value: "", children: "No grouping" }), options.map(o => (_jsx(SelectItem, { value: o.column, children: o.label }, o.column)))] })] }));
|
|
2400
|
-
}
|
|
2401
|
-
/**
|
|
2402
|
-
* Pair-of-date-inputs filter for `kind === 'dateRange'`. Each side
|
|
2403
|
-
* navigates the URL on change, encoding the pair as `from..to` keyed
|
|
2404
|
-
* off the filter name. Empty pair drops the URL key.
|
|
2405
|
-
*/
|
|
2406
|
-
function FilterDateRange({ name, label, defaultValue, placeholder, includesTime, minDate, maxDate, prefix, }) {
|
|
2407
|
-
const initial = parseDateRangeValue(defaultValue);
|
|
2408
|
-
const [from, setFrom] = useState(initial.from ?? '');
|
|
2409
|
-
const [to, setTo] = useState(initial.to ?? '');
|
|
2410
|
-
const navigate = useNavigate();
|
|
2411
|
-
const inputType = includesTime ? 'datetime-local' : 'date';
|
|
2412
|
-
const navigateTo = (nextFrom, nextTo) => {
|
|
2413
|
-
if (typeof window === 'undefined')
|
|
2414
|
-
return;
|
|
2415
|
-
const url = new URL(window.location.href);
|
|
2416
|
-
const encoded = encodeDateRangeValue({ from: nextFrom, to: nextTo });
|
|
2417
|
-
const k = prefixK(prefix, name);
|
|
2418
|
-
if (encoded === '')
|
|
2419
|
-
url.searchParams.delete(k);
|
|
2420
|
-
else
|
|
2421
|
-
url.searchParams.set(k, encoded);
|
|
2422
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2423
|
-
void navigate(url.pathname + url.search);
|
|
2424
|
-
};
|
|
2425
|
-
const onFromChange = (e) => {
|
|
2426
|
-
const v = e.target.value;
|
|
2427
|
-
setFrom(v);
|
|
2428
|
-
navigateTo(v, to);
|
|
2429
|
-
};
|
|
2430
|
-
const onToChange = (e) => {
|
|
2431
|
-
const v = e.target.value;
|
|
2432
|
-
setTo(v);
|
|
2433
|
-
navigateTo(from, v);
|
|
2434
|
-
};
|
|
2435
|
-
const onClear = () => {
|
|
2436
|
-
setFrom('');
|
|
2437
|
-
setTo('');
|
|
2438
|
-
navigateTo('', '');
|
|
2439
|
-
};
|
|
2440
|
-
const hasValue = from !== '' || to !== '';
|
|
2441
|
-
return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: inputType, value: from, onChange: onFromChange, placeholder: placeholder, "aria-label": `${label} from`, ...(minDate !== undefined ? { min: minDate } : {}), ...(maxDate !== undefined ? { max: maxDate } : {}), className: "h-8 text-xs" }), _jsx("span", { className: "text-muted-foreground", children: "\u2192" }), _jsx(Input, { type: inputType, value: to, onChange: onToChange, placeholder: placeholder, "aria-label": `${label} to`, ...(minDate !== undefined ? { min: minDate } : {}), ...(maxDate !== undefined ? { max: maxDate } : {}), className: "h-8 text-xs" }), hasValue && (_jsx("button", { type: "button", onClick: onClear, "aria-label": `Clear ${label}`, className: "text-muted-foreground hover:text-foreground px-1", children: "\u00D7" }))] })] }));
|
|
2442
|
-
}
|
|
2443
|
-
/**
|
|
2444
|
-
* Multi-value filter for `kind === 'multiSelect'`. Renders a checkbox
|
|
2445
|
-
* stack inside the popover; toggling a box patches the comma-separated
|
|
2446
|
-
* URL value for the filter's name. Empty selection drops the URL key.
|
|
2447
|
-
*/
|
|
2448
|
-
function FilterMultiSelect({ name, label, defaultValue, options, prefix, }) {
|
|
2449
|
-
const [selected, setSelected] = useState(() => parseMultiSelectValue(defaultValue));
|
|
2450
|
-
const navigate = useNavigate();
|
|
2451
|
-
const apply = (next) => {
|
|
2452
|
-
setSelected(next);
|
|
2453
|
-
if (typeof window === 'undefined')
|
|
2454
|
-
return;
|
|
2455
|
-
const url = new URL(window.location.href);
|
|
2456
|
-
const encoded = encodeMultiSelectValue(next);
|
|
2457
|
-
const k = prefixK(prefix, name);
|
|
2458
|
-
if (encoded === '')
|
|
2459
|
-
url.searchParams.delete(k);
|
|
2460
|
-
else
|
|
2461
|
-
url.searchParams.set(k, encoded);
|
|
2462
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2463
|
-
void navigate(url.pathname + url.search);
|
|
2464
|
-
};
|
|
2465
|
-
const toggle = (value, checked) => {
|
|
2466
|
-
const next = checked
|
|
2467
|
-
? [...selected.filter(v => v !== value), value]
|
|
2468
|
-
: selected.filter(v => v !== value);
|
|
2469
|
-
apply(next);
|
|
2470
|
-
};
|
|
2471
|
-
return (_jsxs("div", { className: "flex flex-col gap-1 text-xs", children: [_jsx("span", { className: "text-muted-foreground", children: label }), _jsx("div", { className: "flex flex-col gap-1.5", children: options.map(o => {
|
|
2472
|
-
const checked = selected.includes(o.value);
|
|
2473
|
-
return (_jsxs("label", { className: "flex items-center gap-2 text-sm cursor-pointer", children: [_jsx(Checkbox, { checked: checked, onCheckedChange: (c) => toggle(o.value, c === true) }), _jsx("span", { children: o.label })] }, o.value));
|
|
2474
|
-
}) })] }));
|
|
2475
|
-
}
|
|
2476
|
-
/**
|
|
2477
|
-
* Multi-field filter for `kind === 'form'`. The popover renders an inner
|
|
2478
|
-
* sub-form with the user-declared schema; submitting bundles all named
|
|
2479
|
-
* inputs into a `Record<string, unknown>`, JSON-encodes the non-empty
|
|
2480
|
-
* subset under the filter's URL key, and SPA-navigates. Empty submit
|
|
2481
|
-
* drops the URL key entirely.
|
|
2482
|
-
*
|
|
2483
|
-
* The fields' `defaultValue` were pre-hydrated server-side from the
|
|
2484
|
-
* active URL value (see `FormFilter.toMeta`), so an existing filter
|
|
2485
|
-
* round-trips into the form on render. Inputs are uncontrolled — we
|
|
2486
|
-
* read state via `new FormData(form)` on submit, matching how the
|
|
2487
|
-
* outer page-level Form works on full submit.
|
|
2488
|
-
*/
|
|
2489
|
-
function FilterForm({ name, label, defaultValue, formSchema, prefix, }) {
|
|
2490
|
-
const formRef = useRef(null);
|
|
2491
|
-
const navigate = useNavigate();
|
|
2492
|
-
const hasValue = defaultValue !== '' && defaultValue !== '{}';
|
|
2493
|
-
const onApply = (e) => {
|
|
2494
|
-
e?.preventDefault();
|
|
2495
|
-
if (!formRef.current)
|
|
2496
|
-
return;
|
|
2497
|
-
const fd = new FormData(formRef.current);
|
|
2498
|
-
const values = {};
|
|
2499
|
-
for (const [key, val] of fd.entries()) {
|
|
2500
|
-
const existing = values[key];
|
|
2501
|
-
if (existing === undefined) {
|
|
2502
|
-
values[key] = val;
|
|
2503
|
-
}
|
|
2504
|
-
else if (Array.isArray(existing)) {
|
|
2505
|
-
existing.push(val);
|
|
2506
|
-
}
|
|
2507
|
-
else {
|
|
2508
|
-
values[key] = [existing, val];
|
|
2509
|
-
}
|
|
2510
|
-
}
|
|
2511
|
-
if (typeof window === 'undefined')
|
|
2512
|
-
return;
|
|
2513
|
-
const url = new URL(window.location.href);
|
|
2514
|
-
const encoded = encodeFormFilterValue(values);
|
|
2515
|
-
const k = prefixK(prefix, name);
|
|
2516
|
-
if (encoded === '')
|
|
2517
|
-
url.searchParams.delete(k);
|
|
2518
|
-
else
|
|
2519
|
-
url.searchParams.set(k, encoded);
|
|
2520
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2521
|
-
void navigate(url.pathname + url.search);
|
|
2522
|
-
};
|
|
2523
|
-
const onClear = () => {
|
|
2524
|
-
if (typeof window === 'undefined')
|
|
2525
|
-
return;
|
|
2526
|
-
const url = new URL(window.location.href);
|
|
2527
|
-
url.searchParams.delete(prefixK(prefix, name));
|
|
2528
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2529
|
-
void navigate(url.pathname + url.search);
|
|
2530
|
-
};
|
|
2531
|
-
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("span", { className: "text-muted-foreground text-xs", children: label }), _jsxs("form", { ref: formRef, onSubmit: onApply, className: "flex flex-col gap-2", children: [formSchema.map((child, i) => renderFormChild(child, i, {}, {})), _jsxs("div", { className: "flex gap-2 pt-1", children: [_jsx("button", { type: "submit", className: "inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90", children: "Apply" }), hasValue && (_jsx("button", { type: "button", onClick: onClear, className: "inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "Clear" }))] })] })] }));
|
|
2532
|
-
}
|
|
2533
|
-
/**
|
|
2534
|
-
* Composable advanced filter for `kind === 'queryBuilder'`. v2 emits a
|
|
2535
|
-
* full tree — root AND/OR connector + nested groups arbitrarily deep —
|
|
2536
|
-
* JSON-encoded into a single URL key on Apply (see
|
|
2537
|
-
* `encodeQueryBuilderValue`).
|
|
2538
|
-
*
|
|
2539
|
-
* State is local — typing into a value input doesn't navigate. Only the
|
|
2540
|
-
* Apply button writes the URL. This mirrors `FilterForm`'s behavior and
|
|
2541
|
-
* keeps the popover quiet under the cursor.
|
|
2542
|
-
*/
|
|
2543
|
-
function FilterQueryBuilder({ name, label, defaultValue, constraints, prefix, }) {
|
|
2544
|
-
const navigate = useNavigate();
|
|
2545
|
-
const initialTree = parseQueryBuilderValue(defaultValue);
|
|
2546
|
-
const [tree, setTree] = useState(initialTree);
|
|
2547
|
-
const hasValue = defaultValue !== '' && initialTree.rules.length > 0;
|
|
2548
|
-
const onApply = (e) => {
|
|
2549
|
-
e?.preventDefault();
|
|
2550
|
-
if (typeof window === 'undefined')
|
|
2551
|
-
return;
|
|
2552
|
-
const encoded = encodeQueryBuilderValue(tree);
|
|
2553
|
-
const url = new URL(window.location.href);
|
|
2554
|
-
const k = prefixK(prefix, name);
|
|
2555
|
-
if (encoded === '')
|
|
2556
|
-
url.searchParams.delete(k);
|
|
2557
|
-
else
|
|
2558
|
-
url.searchParams.set(k, encoded);
|
|
2559
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2560
|
-
void navigate(url.pathname + url.search);
|
|
2561
|
-
};
|
|
2562
|
-
const onClear = () => {
|
|
2563
|
-
setTree({ operator: 'and', rules: [] });
|
|
2564
|
-
if (typeof window === 'undefined')
|
|
2565
|
-
return;
|
|
2566
|
-
const url = new URL(window.location.href);
|
|
2567
|
-
url.searchParams.delete(prefixK(prefix, name));
|
|
2568
|
-
url.searchParams.delete(prefixK(prefix, 'page'));
|
|
2569
|
-
void navigate(url.pathname + url.search);
|
|
2570
|
-
};
|
|
2571
|
-
if (constraints.length === 0) {
|
|
2572
|
-
return (_jsxs("div", { className: "text-muted-foreground text-xs", children: [label, ": no constraints declared."] }));
|
|
2573
|
-
}
|
|
2574
|
-
return (_jsxs("div", { className: "flex flex-col gap-2 min-w-[24rem]", children: [_jsx("span", { className: "text-muted-foreground text-xs", children: label }), _jsxs("form", { onSubmit: onApply, className: "flex flex-col gap-2", children: [_jsx(QueryBuilderGroup, { tree: tree, constraints: constraints, isRoot: true, onChange: setTree }), _jsxs("div", { className: "flex items-center gap-2 pt-1", children: [_jsx("div", { className: "flex-1" }), _jsx("button", { type: "submit", className: "inline-flex h-8 items-center justify-center rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground hover:bg-primary/90", children: "Apply" }), (hasValue || tree.rules.length > 0) && (_jsx("button", { type: "button", onClick: onClear, className: "inline-flex h-8 items-center justify-center rounded-md border border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "Clear" }))] })] })] }));
|
|
2575
|
-
}
|
|
2576
|
-
/**
|
|
2577
|
-
* Recursive group renderer — emits a connector picker (AND / OR) at the
|
|
2578
|
-
* top, a vertical stack of children (rules and sub-groups), and footer
|
|
2579
|
-
* buttons for "+ Add condition" and "+ Add group". Calls `onChange` with
|
|
2580
|
-
* the updated sub-tree so parents can splice it back into their own
|
|
2581
|
-
* `rules` array. Root groups skip the outer border so the popover doesn't
|
|
2582
|
-
* carry a redundant frame; nested groups draw a faint left rule + soft
|
|
2583
|
-
* background so the nesting is visible without blowing up the width.
|
|
2584
|
-
*/
|
|
2585
|
-
function QueryBuilderGroup({ tree, constraints, isRoot, onChange, onRemove, }) {
|
|
2586
|
-
const constraintMap = new Map();
|
|
2587
|
-
for (const c of constraints)
|
|
2588
|
-
constraintMap.set(c.name, c);
|
|
2589
|
-
const setOperator = (op) => {
|
|
2590
|
-
onChange({ ...tree, operator: op });
|
|
2591
|
-
};
|
|
2592
|
-
const updateChildAt = (index, next) => {
|
|
2593
|
-
onChange({ ...tree, rules: tree.rules.map((r, i) => i === index ? next : r) });
|
|
2594
|
-
};
|
|
2595
|
-
const removeChildAt = (index) => {
|
|
2596
|
-
onChange({ ...tree, rules: tree.rules.filter((_, i) => i !== index) });
|
|
2597
|
-
};
|
|
2598
|
-
const addRule = () => {
|
|
2599
|
-
const first = constraints[0];
|
|
2600
|
-
if (!first)
|
|
2601
|
-
return;
|
|
2602
|
-
onChange({
|
|
2603
|
-
...tree,
|
|
2604
|
-
rules: [...tree.rules, {
|
|
2605
|
-
constraint: first.name,
|
|
2606
|
-
operator: first.defaultOperator ?? first.operators[0]?.name ?? 'equals',
|
|
2607
|
-
value: undefined,
|
|
2608
|
-
}],
|
|
2609
|
-
});
|
|
2610
|
-
};
|
|
2611
|
-
const addGroup = () => {
|
|
2612
|
-
onChange({
|
|
2613
|
-
...tree,
|
|
2614
|
-
rules: [...tree.rules, { operator: 'and', rules: [] }],
|
|
2615
|
-
});
|
|
2616
|
-
};
|
|
2617
|
-
const wrapper = isRoot
|
|
2618
|
-
? 'flex flex-col gap-2'
|
|
2619
|
-
: 'flex flex-col gap-2 rounded-md border-l-2 border-primary/40 bg-muted/30 pl-2 py-2 pr-2';
|
|
2620
|
-
return (_jsxs("div", { className: wrapper, children: [_jsxs("div", { className: "flex items-center gap-2", children: [_jsx(ConnectorToggle, { value: tree.operator, onChange: setOperator }), _jsx("span", { className: "text-muted-foreground text-[11px]", children: tree.operator === 'and' ? 'Match all of the following' : 'Match any of the following' }), !isRoot && onRemove && (_jsxs(_Fragment, { children: [_jsx("div", { className: "flex-1" }), _jsx("button", { type: "button", onClick: onRemove, "aria-label": "Remove group", className: "inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "\u00D7" })] }))] }), tree.rules.length === 0 && (_jsx("div", { className: "text-muted-foreground text-xs italic", children: "No conditions yet." })), tree.rules.map((child, i) => {
|
|
2621
|
-
if (isQueryBuilderTree(child)) {
|
|
2622
|
-
return (_jsx(QueryBuilderGroup, { tree: child, constraints: constraints, isRoot: false, onChange: (next) => updateChildAt(i, next), onRemove: () => removeChildAt(i) }, i));
|
|
2623
|
-
}
|
|
2624
|
-
return (_jsx(QueryBuilderRow, { rule: child, constraints: constraints, constraintMeta: constraintMap.get(child.constraint), onConstraintChange: (v) => {
|
|
2625
|
-
const c = constraintMap.get(v);
|
|
2626
|
-
if (!c)
|
|
2627
|
-
return;
|
|
2628
|
-
updateChildAt(i, {
|
|
2629
|
-
constraint: v,
|
|
2630
|
-
operator: c.defaultOperator ?? c.operators[0]?.name ?? 'equals',
|
|
2631
|
-
value: undefined,
|
|
2632
|
-
});
|
|
2633
|
-
}, onOperatorChange: (v) => {
|
|
2634
|
-
updateChildAt(i, {
|
|
2635
|
-
...child,
|
|
2636
|
-
operator: v,
|
|
2637
|
-
value: undefined,
|
|
2638
|
-
});
|
|
2639
|
-
}, onValueChange: (v) => updateChildAt(i, { ...child, value: v }), onRemove: () => removeChildAt(i) }, i));
|
|
2640
|
-
}), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [_jsx("button", { type: "button", onClick: addRule, className: "inline-flex h-8 items-center justify-center rounded-md border border-dashed border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "+ Add condition" }), _jsx("button", { type: "button", onClick: addGroup, className: "inline-flex h-8 items-center justify-center rounded-md border border-dashed border-input bg-background px-3 text-xs font-medium hover:bg-accent hover:text-accent-foreground", children: "+ Add group" })] })] }));
|
|
2641
|
-
}
|
|
2642
|
-
/**
|
|
2643
|
-
* Compact AND/OR segmented control used at the head of every group. Pure
|
|
2644
|
-
* presentation — the parent owns the value.
|
|
2645
|
-
*/
|
|
2646
|
-
function ConnectorToggle({ value, onChange, }) {
|
|
2647
|
-
const base = 'inline-flex h-7 items-center px-2 text-[11px] font-medium uppercase tracking-wide transition';
|
|
2648
|
-
const on = 'bg-primary text-primary-foreground';
|
|
2649
|
-
const off = 'bg-background text-muted-foreground hover:bg-accent hover:text-accent-foreground';
|
|
2650
|
-
return (_jsxs("div", { className: "inline-flex overflow-hidden rounded-md border border-input", children: [_jsx("button", { type: "button", onClick: () => onChange('and'), className: `${base} ${value === 'and' ? on : off}`, "aria-pressed": value === 'and', children: "AND" }), _jsx("button", { type: "button", onClick: () => onChange('or'), className: `${base} ${value === 'or' ? on : off}`, "aria-pressed": value === 'or', children: "OR" })] }));
|
|
2651
|
-
}
|
|
2652
|
-
/**
|
|
2653
|
-
* One condition row inside `FilterQueryBuilder`. Three controls
|
|
2654
|
-
* left-to-right: constraint picker, operator picker, value input. The
|
|
2655
|
-
* value input dispatches off the operator's `valueKind` — `none` hides
|
|
2656
|
-
* it entirely, `numberRange` / `dateRange` mount a pair, otherwise a
|
|
2657
|
-
* single typed input.
|
|
2658
|
-
*/
|
|
2659
|
-
function QueryBuilderRow({ rule, constraints, constraintMeta, onConstraintChange, onOperatorChange, onValueChange, onRemove, }) {
|
|
2660
|
-
const operators = constraintMeta?.operators ?? [];
|
|
2661
|
-
const activeOp = operators.find(o => o.name === rule.operator);
|
|
2662
|
-
const valueKind = activeOp?.valueKind ?? 'text';
|
|
2663
|
-
return (_jsxs("div", { className: "flex items-start gap-1.5 rounded-md border border-input bg-background p-2", children: [_jsxs("div", { className: "flex flex-1 flex-wrap items-center gap-1.5", children: [_jsxs(Select, { value: rule.constraint, onValueChange: (v) => onConstraintChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 w-36 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: constraints.map(c => (_jsx(SelectItem, { value: c.name, children: c.label }, c.name))) })] }), _jsxs(Select, { value: rule.operator, onValueChange: (v) => onOperatorChange(typeof v === 'string' ? v : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 w-32 text-xs", children: _jsx(SelectValue, {}) }), _jsx(SelectContent, { children: operators.map(o => (_jsx(SelectItem, { value: o.name, children: o.label }, o.name))) })] }), _jsx(QueryBuilderValueInput, { kind: valueKind, value: rule.value, options: constraintMeta?.options, onChange: onValueChange })] }), _jsx("button", { type: "button", onClick: onRemove, "aria-label": "Remove condition", className: "inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-accent-foreground", children: "\u00D7" })] }));
|
|
2664
|
-
}
|
|
2665
|
-
/**
|
|
2666
|
-
* Operator-aware value control. Switches over the constraint operator's
|
|
2667
|
-
* `valueKind` and mounts the matching input. Value shapes:
|
|
2668
|
-
* - `text / number / date / dateTime / select` → scalar
|
|
2669
|
-
* - `multiSelect` → string[]
|
|
2670
|
-
* - `numberRange / dateRange` → [string, string]
|
|
2671
|
-
* - `boolean / none` → null / undefined
|
|
2672
|
-
*/
|
|
2673
|
-
function QueryBuilderValueInput({ kind, value, options, onChange, }) {
|
|
2674
|
-
if (kind === 'none' || kind === 'boolean')
|
|
2675
|
-
return null;
|
|
2676
|
-
if (kind === 'select') {
|
|
2677
|
-
const opts = options ?? [];
|
|
2678
|
-
const v = value === undefined || value === null ? '' : String(value);
|
|
2679
|
-
return (_jsxs(Select, { value: v, onValueChange: (next) => onChange(typeof next === 'string' ? next : ''), children: [_jsx(SelectTrigger, { size: "sm", className: "h-8 min-w-32 text-xs", children: _jsx(SelectValue, { placeholder: "Pick\u2026" }) }), _jsx(SelectContent, { children: opts.map(o => (_jsx(SelectItem, { value: o.value, children: o.label }, o.value))) })] }));
|
|
2680
|
-
}
|
|
2681
|
-
if (kind === 'multiSelect') {
|
|
2682
|
-
const opts = options ?? [];
|
|
2683
|
-
const list = Array.isArray(value) ? value.map(v => String(v)) : [];
|
|
2684
|
-
const toggle = (val) => {
|
|
2685
|
-
if (list.includes(val))
|
|
2686
|
-
onChange(list.filter(v => v !== val));
|
|
2687
|
-
else
|
|
2688
|
-
onChange([...list, val]);
|
|
2689
|
-
};
|
|
2690
|
-
return (_jsx("div", { className: "flex flex-wrap items-center gap-1", children: opts.map(o => {
|
|
2691
|
-
const active = list.includes(o.value);
|
|
2692
|
-
return (_jsx("button", { type: "button", onClick: () => toggle(o.value), className: 'inline-flex h-7 items-center rounded-md border px-2 text-xs ' +
|
|
2693
|
-
(active
|
|
2694
|
-
? 'border-primary bg-primary text-primary-foreground'
|
|
2695
|
-
: 'border-input bg-background hover:bg-accent'), children: o.label }, o.value));
|
|
2696
|
-
}) }));
|
|
2697
|
-
}
|
|
2698
|
-
if (kind === 'numberRange') {
|
|
2699
|
-
const [min, max] = Array.isArray(value) ? [value[0], value[1]] : [undefined, undefined];
|
|
2700
|
-
return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: "number", className: "h-8 w-24 text-xs", value: min === undefined || min === null ? '' : String(min), onChange: (e) => onChange([e.target.value, max ?? '']), placeholder: "Min" }), _jsx("span", { className: "text-muted-foreground text-xs", children: "\u2013" }), _jsx(Input, { type: "number", className: "h-8 w-24 text-xs", value: max === undefined || max === null ? '' : String(max), onChange: (e) => onChange([min ?? '', e.target.value]), placeholder: "Max" })] }));
|
|
2701
|
-
}
|
|
2702
|
-
if (kind === 'dateRange') {
|
|
2703
|
-
const [from, to] = Array.isArray(value) ? [value[0], value[1]] : [undefined, undefined];
|
|
2704
|
-
return (_jsxs("div", { className: "flex items-center gap-1", children: [_jsx(Input, { type: "date", className: "h-8 w-36 text-xs", value: from === undefined || from === null ? '' : String(from), onChange: (e) => onChange([e.target.value, to ?? '']) }), _jsx("span", { className: "text-muted-foreground text-xs", children: "\u2192" }), _jsx(Input, { type: "date", className: "h-8 w-36 text-xs", value: to === undefined || to === null ? '' : String(to), onChange: (e) => onChange([from ?? '', e.target.value]) })] }));
|
|
2705
|
-
}
|
|
2706
|
-
if (kind === 'date' || kind === 'dateTime') {
|
|
2707
|
-
const v = value === undefined || value === null ? '' : String(value);
|
|
2708
|
-
return (_jsx(Input, { type: kind === 'dateTime' ? 'datetime-local' : 'date', className: "h-8 w-44 text-xs", value: v, onChange: (e) => onChange(e.target.value) }));
|
|
2709
|
-
}
|
|
2710
|
-
if (kind === 'number') {
|
|
2711
|
-
const v = value === undefined || value === null ? '' : String(value);
|
|
2712
|
-
return (_jsx(Input, { type: "number", className: "h-8 w-32 text-xs", value: v, onChange: (e) => onChange(e.target.value), placeholder: "Value" }));
|
|
2713
|
-
}
|
|
2714
|
-
// Default: text
|
|
2715
|
-
const v = value === undefined || value === null ? '' : String(value);
|
|
2716
|
-
return (_jsx(Input, { type: "text", className: "h-8 min-w-32 flex-1 text-xs", value: v, onChange: (e) => onChange(e.target.value), placeholder: "Value" }));
|
|
2717
|
-
}
|
|
2718
|
-
function renderFilterControl(el, index, prefix) {
|
|
2719
|
-
const name = String(el['name'] ?? '');
|
|
2720
|
-
const label = String(el['label'] ?? name);
|
|
2721
|
-
const kind = String(el['kind'] ?? 'select');
|
|
2722
|
-
const value = el['value'] ? String(el['value']) : '';
|
|
2723
|
-
const placeholder = el['placeholder'] ? String(el['placeholder']) : 'All';
|
|
2724
|
-
if (kind === 'queryBuilder') {
|
|
2725
|
-
const constraints = el['constraints'] ?? [];
|
|
2726
|
-
return (_jsx(FilterQueryBuilder, { name: name, label: label, defaultValue: value, constraints: constraints, prefix: prefix }, index));
|
|
2727
|
-
}
|
|
2728
|
-
if (kind === 'form') {
|
|
2729
|
-
const formSchema = el['formSchema'] ?? [];
|
|
2730
|
-
return (_jsx(FilterForm, { name: name, label: label, defaultValue: value, formSchema: formSchema, prefix: prefix }, index));
|
|
2731
|
-
}
|
|
2732
|
-
if (kind === 'boolean') {
|
|
2733
|
-
return (_jsx(FilterSelect, { name: name, label: label, defaultValue: value, placeholder: placeholder, options: [{ value: '1', label: 'Yes' }, { value: '0', label: 'No' }], prefix: prefix }, index));
|
|
2734
|
-
}
|
|
2735
|
-
if (kind === 'multiSelect') {
|
|
2736
|
-
const options = el['options'] ?? [];
|
|
2737
|
-
return (_jsx(FilterMultiSelect, { name: name, label: label, defaultValue: value, options: options, prefix: prefix }, index));
|
|
2738
|
-
}
|
|
2739
|
-
if (kind === 'dateRange') {
|
|
2740
|
-
const includesTime = Boolean(el['includesTime']);
|
|
2741
|
-
const minDate = el['minDate'] ? String(el['minDate']) : undefined;
|
|
2742
|
-
const maxDate = el['maxDate'] ? String(el['maxDate']) : undefined;
|
|
2743
|
-
return (_jsx(FilterDateRange, { name: name, label: label, defaultValue: value, placeholder: placeholder, includesTime: includesTime, prefix: prefix, ...(minDate !== undefined ? { minDate } : {}), ...(maxDate !== undefined ? { maxDate } : {}) }, index));
|
|
2744
|
-
}
|
|
2745
|
-
// 'ternary' and 'select' both render as a single-select dropdown,
|
|
2746
|
-
// differing only in their server-supplied option set.
|
|
2747
|
-
const options = el['options'] ?? [];
|
|
2748
|
-
return (_jsx(FilterSelect, { name: name, label: label, defaultValue: value, placeholder: placeholder, options: options, prefix: prefix }, index));
|
|
2749
|
-
}
|
|
2750
|
-
/**
|
|
2751
|
-
* Resolve the record URL for a single data cell. Column-level override
|
|
2752
|
-
* (`Column.recordUrl(fn)` → `_columnRecordUrls[name]`) wins over the
|
|
2753
|
-
* table-level `Table.recordUrl(fn)` (`_recordUrl`). Explicit per-column
|
|
2754
|
-
* opt-out (`Column.recordUrl(false)` → `meta.recordUrl === false`)
|
|
2755
|
-
* suppresses the link entirely. Returns `undefined` when the cell is
|
|
2756
|
-
* not linkable, in which case the renderer leaves it unwrapped.
|
|
2757
|
-
*/
|
|
2758
|
-
function resolveColumnUrl(col, tableUrl, colUrls) {
|
|
2759
|
-
if (col['recordUrl'] === false)
|
|
2760
|
-
return undefined;
|
|
2761
|
-
const own = colUrls[String(col['name'] ?? '')];
|
|
2762
|
-
if (own !== undefined)
|
|
2763
|
-
return own;
|
|
2764
|
-
return tableUrl;
|
|
2765
|
-
}
|
|
2766
|
-
/**
|
|
2767
|
-
* Cell-level link wrapper. Renders a real `<a href>` so right-click /
|
|
2768
|
-
* cmd-click / middle-click "open in new tab" works, but intercepts plain
|
|
2769
|
-
* left-clicks for SPA navigation via `useNavigate()`. Modified clicks
|
|
2770
|
-
* (cmd / ctrl / shift / alt / non-primary buttons) fall through to the
|
|
2771
|
-
* browser's default link behavior.
|
|
2772
|
-
*/
|
|
2773
|
-
function RecordCellLink({ href, navigate, children, }) {
|
|
2774
|
-
const onClick = (e) => {
|
|
2775
|
-
if (e.button !== 0)
|
|
2776
|
-
return;
|
|
2777
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
2778
|
-
return;
|
|
2779
|
-
e.preventDefault();
|
|
2780
|
-
void navigate(href);
|
|
2781
|
-
};
|
|
2782
|
-
return (_jsx("a", { href: href, onClick: onClick, className: "block px-2 py-2 text-inherit no-underline hover:text-inherit focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: children }));
|
|
2783
|
-
}
|
|
2784
|
-
/**
|
|
2785
|
-
* "Drilled into <Label>: <Value>" chip above the table when a group
|
|
2786
|
-
* heading has been clicked. The × clears `?<prefix>groupKey=`, returning
|
|
2787
|
-
* the table to its banded view. Real `<a href>` with `useNavigate()`
|
|
2788
|
-
* intercept on plain left-click so cmd-click / middle-click open a
|
|
2789
|
-
* fresh tab (rare but valid for sharing the banded view URL).
|
|
2790
|
-
*/
|
|
2791
|
-
function ActiveGroupKeyChip({ label, value, displayValue, clearHref, navigate, }) {
|
|
2792
|
-
const onClick = (e) => {
|
|
2793
|
-
if (e.button !== 0)
|
|
2794
|
-
return;
|
|
2795
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
2796
|
-
return;
|
|
2797
|
-
e.preventDefault();
|
|
2798
|
-
void navigate(clearHref);
|
|
2799
|
-
};
|
|
2800
|
-
return (_jsxs("div", { className: "flex items-center gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm", children: [_jsx("span", { className: "text-muted-foreground", children: "Drilled into" }), _jsxs("span", { className: "font-medium text-foreground", children: [label ? `${label}: ` : '', displayValue || value] }), _jsx("a", { href: clearHref, onClick: onClick, "aria-label": "Clear drill-in", className: "ms-auto text-muted-foreground hover:text-foreground", children: "\u00D7" })] }));
|
|
2801
|
-
}
|
|
2802
|
-
/**
|
|
2803
|
-
* Group-heading text wrapped in a real `<a href>` that SPA-navs into the
|
|
2804
|
-
* drilled-in URL. Plain left-click intercepts for `useNavigate()`;
|
|
2805
|
-
* cmd/ctrl/shift-click + middle-click fall through to the browser so
|
|
2806
|
-
* "open in new tab" semantics work. Visually inherits the heading
|
|
2807
|
-
* styling — the link adds underline-on-hover affordance without
|
|
2808
|
-
* disturbing the surrounding text-transform / size.
|
|
2809
|
-
*/
|
|
2810
|
-
function GroupHeadingLink({ href, navigate, children, }) {
|
|
2811
|
-
const onClick = (e) => {
|
|
2812
|
-
if (e.button !== 0)
|
|
2813
|
-
return;
|
|
2814
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
2815
|
-
return;
|
|
2816
|
-
e.preventDefault();
|
|
2817
|
-
void navigate(href);
|
|
2818
|
-
};
|
|
2819
|
-
return (_jsx("a", { href: href, onClick: onClick, className: "inline-flex items-center gap-1 text-inherit no-underline hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: children }));
|
|
2820
|
-
}
|
|
2821
177
|
/**
|
|
2822
178
|
* List-page tab strip — Filament-style query shortcuts above the table
|
|
2823
179
|
* ("All / Drafts / Published / Archived"). Each trigger is a real `<a>`
|
|
@@ -2919,939 +275,7 @@ function RelationTabIcon({ icon }) {
|
|
|
2919
275
|
const Icon = useIconFor(icon);
|
|
2920
276
|
if (!Icon)
|
|
2921
277
|
return null;
|
|
2922
|
-
return _jsx(Icon, { className: "size-4", "aria-hidden": "true" });
|
|
2923
|
-
}
|
|
2924
|
-
/**
|
|
2925
|
-
* Sort-by dropdown for `contentLayout: 'cards'`. Since the column-header
|
|
2926
|
-
* row (which usually doubles as the sort affordance) is hidden in cards
|
|
2927
|
-
* mode, this picker appears in the top bar instead. Each `Column` flagged
|
|
2928
|
-
* `.sortable()` contributes two options — ascending and descending —
|
|
2929
|
-
* yielding "Title (A→Z) / Title (Z→A) / Date (oldest first) / Date (newest
|
|
2930
|
-
* first)" style entries. Selecting an option resets `?page=1`.
|
|
2931
|
-
*/
|
|
2932
|
-
function SortByPicker({ columns, active, onChange, }) {
|
|
2933
|
-
const sortable = columns.filter(c => Boolean(c['sortable']));
|
|
2934
|
-
if (sortable.length === 0)
|
|
2935
|
-
return null;
|
|
2936
|
-
const value = active ? `${active.column}:${active.direction}` : '';
|
|
2937
|
-
return (_jsxs(Select, { value: value, onValueChange: (v) => {
|
|
2938
|
-
if (typeof v !== 'string' || v === '')
|
|
2939
|
-
return;
|
|
2940
|
-
const idx = v.indexOf(':');
|
|
2941
|
-
if (idx < 0)
|
|
2942
|
-
return;
|
|
2943
|
-
const col = v.slice(0, idx);
|
|
2944
|
-
const dir = v.slice(idx + 1) === 'desc' ? 'desc' : 'asc';
|
|
2945
|
-
onChange(col, dir);
|
|
2946
|
-
}, children: [_jsx(SelectTrigger, { size: "sm", className: "h-9 w-44", children: _jsx(SelectValue, { placeholder: "Sort by\u2026" }) }), _jsx(SelectContent, { children: sortable.map(col => {
|
|
2947
|
-
const name = String(col['name'] ?? '');
|
|
2948
|
-
const label = String(col['label'] ?? name);
|
|
2949
|
-
return (_jsxs(React.Fragment, { children: [_jsxs(SelectItem, { value: `${name}:asc`, children: [label, " (A\u2192Z)"] }), _jsxs(SelectItem, { value: `${name}:desc`, children: [label, " (Z\u2192A)"] })] }, name));
|
|
2950
|
-
}) })] }));
|
|
2951
|
-
}
|
|
2952
|
-
/**
|
|
2953
|
-
* Toolbar dropdown for `Column.toggleable()` columns. Lists every
|
|
2954
|
-
* toggleable column with a checkbox; toggling writes through to a
|
|
2955
|
-
* caller-supplied `onToggle` (the `TableRendererBody` owns the state
|
|
2956
|
-
* + the localStorage round-trip). Mounted only when at least one
|
|
2957
|
-
* column is toggleable.
|
|
2958
|
-
*/
|
|
2959
|
-
function ColumnsToggleDropdown({ columns, hidden, onToggle, }) {
|
|
2960
|
-
if (columns.length === 0)
|
|
2961
|
-
return null;
|
|
2962
|
-
return (_jsxs(DropdownMenu, { children: [_jsx(DropdownMenuTrigger, { render: (props) => (_jsxs("button", { ...props, type: "button", className: "inline-flex h-9 items-center gap-1.5 rounded-md border border-input bg-background px-3 text-sm font-medium text-foreground hover:bg-accent", "aria-label": "Show or hide columns", children: [_jsx(Columns3Icon, { className: "h-4 w-4", "aria-hidden": "true" }), _jsx("span", { children: "Columns" })] })) }), _jsx(DropdownMenuContent, { align: "end", className: "min-w-[12rem]", children: columns.map((col, i) => {
|
|
2963
|
-
const name = String(col['name'] ?? '');
|
|
2964
|
-
const label = String(col['label'] ?? name);
|
|
2965
|
-
const isHidden = hidden.has(name);
|
|
2966
|
-
return (_jsxs(DropdownMenuItem, {
|
|
2967
|
-
// Suppress menu-close so users can toggle multiple columns
|
|
2968
|
-
// without re-opening the dropdown.
|
|
2969
|
-
closeOnClick: false, onClick: () => onToggle(name, !isHidden), children: [_jsx("span", { className: "inline-flex w-4 items-center justify-center", children: !isHidden && _jsx(CheckIcon, { className: "h-4 w-4", "aria-hidden": "true" }) }), _jsx("span", { children: label })] }, i));
|
|
2970
|
-
}) })] }));
|
|
2971
|
-
}
|
|
2972
|
-
/**
|
|
2973
|
-
* Lookup tables for responsive grid column-counts in `contentLayout:
|
|
2974
|
-
* 'cards'`. Tailwind's JIT scanner needs **literal** class strings; we
|
|
2975
|
-
* can't construct them at runtime via template literals (`grid-cols-${n}`
|
|
2976
|
-
* would never be matched). Limit to 1–6 columns + 12 — covers every
|
|
2977
|
-
* reasonable card grid; bigger values are silently capped at 6 for
|
|
2978
|
-
* non-base breakpoints in `cardsPerRowClasses`.
|
|
2979
|
-
*/
|
|
2980
|
-
const CARDS_GRID_COLS_BASE = {
|
|
2981
|
-
1: 'grid-cols-1',
|
|
2982
|
-
2: 'grid-cols-2',
|
|
2983
|
-
3: 'grid-cols-3',
|
|
2984
|
-
4: 'grid-cols-4',
|
|
2985
|
-
5: 'grid-cols-5',
|
|
2986
|
-
6: 'grid-cols-6',
|
|
2987
|
-
12: 'grid-cols-12',
|
|
2988
|
-
};
|
|
2989
|
-
const CARDS_GRID_COLS_SM = {
|
|
2990
|
-
1: 'sm:grid-cols-1', 2: 'sm:grid-cols-2', 3: 'sm:grid-cols-3',
|
|
2991
|
-
4: 'sm:grid-cols-4', 5: 'sm:grid-cols-5', 6: 'sm:grid-cols-6',
|
|
2992
|
-
12: 'sm:grid-cols-12',
|
|
2993
|
-
};
|
|
2994
|
-
const CARDS_GRID_COLS_MD = {
|
|
2995
|
-
1: 'md:grid-cols-1', 2: 'md:grid-cols-2', 3: 'md:grid-cols-3',
|
|
2996
|
-
4: 'md:grid-cols-4', 5: 'md:grid-cols-5', 6: 'md:grid-cols-6',
|
|
2997
|
-
12: 'md:grid-cols-12',
|
|
2998
|
-
};
|
|
2999
|
-
const CARDS_GRID_COLS_LG = {
|
|
3000
|
-
1: 'lg:grid-cols-1', 2: 'lg:grid-cols-2', 3: 'lg:grid-cols-3',
|
|
3001
|
-
4: 'lg:grid-cols-4', 5: 'lg:grid-cols-5', 6: 'lg:grid-cols-6',
|
|
3002
|
-
12: 'lg:grid-cols-12',
|
|
3003
|
-
};
|
|
3004
|
-
const CARDS_GRID_COLS_XL = {
|
|
3005
|
-
1: 'xl:grid-cols-1', 2: 'xl:grid-cols-2', 3: 'xl:grid-cols-3',
|
|
3006
|
-
4: 'xl:grid-cols-4', 5: 'xl:grid-cols-5', 6: 'xl:grid-cols-6',
|
|
3007
|
-
12: 'xl:grid-cols-12',
|
|
3008
|
-
};
|
|
3009
|
-
const CARDS_GRID_COLS_2XL = {
|
|
3010
|
-
1: '2xl:grid-cols-1', 2: '2xl:grid-cols-2', 3: '2xl:grid-cols-3',
|
|
3011
|
-
4: '2xl:grid-cols-4', 5: '2xl:grid-cols-5', 6: '2xl:grid-cols-6',
|
|
3012
|
-
12: '2xl:grid-cols-12',
|
|
3013
|
-
};
|
|
3014
|
-
function pickCardCols(table, raw) {
|
|
3015
|
-
if (raw === undefined)
|
|
3016
|
-
return undefined;
|
|
3017
|
-
if (table[raw])
|
|
3018
|
-
return table[raw];
|
|
3019
|
-
// Snap unsupported values to nearest available — values outside [1,6]∪{12}
|
|
3020
|
-
// round down. Already-clamped to [1,12] server-side.
|
|
3021
|
-
if (raw >= 12)
|
|
3022
|
-
return table[12];
|
|
3023
|
-
if (raw >= 6)
|
|
3024
|
-
return table[6];
|
|
3025
|
-
if (raw >= 5)
|
|
3026
|
-
return table[5];
|
|
3027
|
-
if (raw >= 4)
|
|
3028
|
-
return table[4];
|
|
3029
|
-
if (raw >= 3)
|
|
3030
|
-
return table[3];
|
|
3031
|
-
if (raw >= 2)
|
|
3032
|
-
return table[2];
|
|
3033
|
-
return table[1];
|
|
3034
|
-
}
|
|
3035
|
-
/** Build a Tailwind grid-cols class string from a per-row config. Default
|
|
3036
|
-
* `{ default: 1, sm: 2, lg: 3 }` mirrors Filament's typical card grid. */
|
|
3037
|
-
function cardsPerRowClasses(opts) {
|
|
3038
|
-
const cfg = opts ?? {};
|
|
3039
|
-
const baseN = cfg['default'] ?? 1;
|
|
3040
|
-
const out = [pickCardCols(CARDS_GRID_COLS_BASE, baseN)];
|
|
3041
|
-
if (cfg['sm'] !== undefined) {
|
|
3042
|
-
const c = pickCardCols(CARDS_GRID_COLS_SM, cfg['sm']);
|
|
3043
|
-
if (c)
|
|
3044
|
-
out.push(c);
|
|
3045
|
-
}
|
|
3046
|
-
if (cfg['md'] !== undefined) {
|
|
3047
|
-
const c = pickCardCols(CARDS_GRID_COLS_MD, cfg['md']);
|
|
3048
|
-
if (c)
|
|
3049
|
-
out.push(c);
|
|
3050
|
-
}
|
|
3051
|
-
if (cfg['lg'] !== undefined) {
|
|
3052
|
-
const c = pickCardCols(CARDS_GRID_COLS_LG, cfg['lg']);
|
|
3053
|
-
if (c)
|
|
3054
|
-
out.push(c);
|
|
3055
|
-
}
|
|
3056
|
-
if (cfg['xl'] !== undefined) {
|
|
3057
|
-
const c = pickCardCols(CARDS_GRID_COLS_XL, cfg['xl']);
|
|
3058
|
-
if (c)
|
|
3059
|
-
out.push(c);
|
|
3060
|
-
}
|
|
3061
|
-
if (cfg['2xl'] !== undefined) {
|
|
3062
|
-
const c = pickCardCols(CARDS_GRID_COLS_2XL, cfg['2xl']);
|
|
3063
|
-
if (c)
|
|
3064
|
-
out.push(c);
|
|
3065
|
-
}
|
|
3066
|
-
// Unset fallback covers Filament's typical default — 1 column on mobile,
|
|
3067
|
-
// 2 on small screens, 3 on large.
|
|
3068
|
-
if (Object.keys(cfg).length === 0) {
|
|
3069
|
-
return 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3';
|
|
3070
|
-
}
|
|
3071
|
-
return out.join(' ');
|
|
3072
|
-
}
|
|
3073
|
-
/**
|
|
3074
|
-
* Tier-3 deferred-load shell. When `Resource.deferLoading = true`, the
|
|
3075
|
-
* SSR pass marks each Table on the page as `deferred` + stamps a
|
|
3076
|
-
* `tableUrl`. This wrapper paints a skeleton on first frame and fetches
|
|
3077
|
-
* the actual rows from the JSON endpoint after mount; the inner
|
|
3078
|
-
* `TableRendererBody` renders identically against either the SSR meta
|
|
3079
|
-
* (non-deferred case) or the fetched meta (deferred case).
|
|
3080
|
-
*
|
|
3081
|
-
* SPA nav with a query change re-runs SSR, which re-stamps `deferred`
|
|
3082
|
-
* — so the URL-change effect fires another fetch. The skeleton frame
|
|
3083
|
-
* still shows current sort / search / page / filter chrome because the
|
|
3084
|
-
* SSR pass mirrors URL state on the deferred Table.
|
|
3085
|
-
*/
|
|
3086
|
-
function TableRenderer({ el }) {
|
|
3087
|
-
const isDeferred = el['deferred'] === true && typeof el['tableUrl'] === 'string';
|
|
3088
|
-
const tableUrl = isDeferred ? el['tableUrl'] : '';
|
|
3089
|
-
// Track the URL search string so a navigation that changes filters /
|
|
3090
|
-
// sort / page re-fires the fetch. Initialized lazy on first client
|
|
3091
|
-
// render; on the SSR pass we just fall through to skeleton.
|
|
3092
|
-
const [search, setSearch] = useState(() => typeof window === 'undefined' ? '' : window.location.search);
|
|
3093
|
-
useEffect(() => {
|
|
3094
|
-
if (!isDeferred)
|
|
3095
|
-
return;
|
|
3096
|
-
if (typeof window === 'undefined')
|
|
3097
|
-
return;
|
|
3098
|
-
setSearch(window.location.search);
|
|
3099
|
-
}, [isDeferred, el]);
|
|
3100
|
-
const [deferredMeta, setDeferredMeta] = useState(null);
|
|
3101
|
-
const [deferredError, setDeferredError] = useState(null);
|
|
3102
|
-
useEffect(() => {
|
|
3103
|
-
if (!isDeferred || !tableUrl)
|
|
3104
|
-
return;
|
|
3105
|
-
if (typeof window === 'undefined')
|
|
3106
|
-
return;
|
|
3107
|
-
let cancelled = false;
|
|
3108
|
-
setDeferredMeta(null);
|
|
3109
|
-
setDeferredError(null);
|
|
3110
|
-
fetch(tableUrl + search, {
|
|
3111
|
-
headers: { 'Accept': 'application/json' },
|
|
3112
|
-
credentials: 'same-origin',
|
|
3113
|
-
})
|
|
3114
|
-
.then(async (r) => {
|
|
3115
|
-
const data = (await r.json());
|
|
3116
|
-
if (cancelled)
|
|
3117
|
-
return;
|
|
3118
|
-
if (data.ok && Array.isArray(data.tables) && data.tables.length > 0) {
|
|
3119
|
-
setDeferredMeta(data.tables[0]);
|
|
3120
|
-
}
|
|
3121
|
-
else {
|
|
3122
|
-
setDeferredError(data.error ?? 'Failed to load table');
|
|
3123
|
-
}
|
|
3124
|
-
})
|
|
3125
|
-
.catch(err => {
|
|
3126
|
-
if (cancelled)
|
|
3127
|
-
return;
|
|
3128
|
-
setDeferredError(err instanceof Error ? err.message : 'Failed to load table');
|
|
3129
|
-
});
|
|
3130
|
-
return () => { cancelled = true; };
|
|
3131
|
-
}, [isDeferred, tableUrl, search]);
|
|
3132
|
-
if (isDeferred && deferredError) {
|
|
3133
|
-
return (_jsxs("div", { className: "rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive", children: ["Failed to load table: ", deferredError] }));
|
|
3134
|
-
}
|
|
3135
|
-
if (isDeferred && !deferredMeta) {
|
|
3136
|
-
return _jsx(TableSkeleton, { el: el });
|
|
3137
|
-
}
|
|
3138
|
-
return _jsx(TableRendererBody, { el: isDeferred ? deferredMeta : el });
|
|
3139
|
-
}
|
|
3140
|
-
/**
|
|
3141
|
-
* Skeleton placeholder painted while a deferred-loaded table fetches
|
|
3142
|
-
* its rows. Mirrors the table's heading + description chrome (already
|
|
3143
|
-
* present on `el`) so the frame doesn't pop layout when the real rows
|
|
3144
|
-
* arrive. Renders a small column header strip + 5 placeholder rows.
|
|
3145
|
-
*/
|
|
3146
|
-
function TableSkeleton({ el }) {
|
|
3147
|
-
const heading = typeof el['heading'] === 'string' ? el['heading'] : undefined;
|
|
3148
|
-
const description = typeof el['description'] === 'string' ? el['description'] : undefined;
|
|
3149
|
-
const children = el.children ?? [];
|
|
3150
|
-
const colCount = Math.max(1, children.filter(c => c.type === 'column').length);
|
|
3151
|
-
return (_jsxs("div", { className: "space-y-3", children: [(heading || description) ? (_jsxs("div", { className: "space-y-1", children: [heading ? _jsx("div", { className: "text-lg font-semibold", children: heading }) : null, description ? _jsx("div", { className: "text-sm text-muted-foreground", children: description }) : null] })) : null, _jsxs("div", { className: "rounded-md border", children: [_jsx("div", { className: "grid border-b bg-muted/50 px-4 py-2", style: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }, children: Array.from({ length: colCount }).map((_, i) => (_jsx("div", { className: "h-4 w-20 rounded bg-muted-foreground/20" }, i))) }), _jsx("div", { className: "divide-y", children: Array.from({ length: 5 }).map((_, rowIdx) => (_jsx("div", { className: "grid items-center px-4 py-3", style: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }, children: Array.from({ length: colCount }).map((_, colIdx) => (_jsx("div", { className: "h-4 w-2/3 rounded bg-muted-foreground/10 animate-pulse" }, colIdx))) }, rowIdx))) })] })] }));
|
|
3152
|
-
}
|
|
3153
|
-
function TableRendererBody({ el }) {
|
|
3154
|
-
const navigate = useNavigate();
|
|
3155
|
-
const children = el.children ?? [];
|
|
3156
|
-
const columns = children.filter(c => c.type === 'column');
|
|
3157
|
-
// `Column.toggleable()` columns — sourced from the resolved meta. The
|
|
3158
|
-
// user's per-table visibility map is owned + persisted below; the full
|
|
3159
|
-
// `columns` list stays available for the toolbar dropdown so hidden
|
|
3160
|
-
// columns can be re-shown without a roundtrip.
|
|
3161
|
-
const toggleableColumns = columns.filter(c => c['toggleable'] !== undefined);
|
|
3162
|
-
// Actions and ActionGroups share placement — both show up in the
|
|
3163
|
-
// header/bulk/row toolbars depending on their `placement` field.
|
|
3164
|
-
const actionLike = children.filter(c => c.type === 'action' || c.type === 'actionGroup' || c.type === 'slotComponent');
|
|
3165
|
-
const filters = children.filter(c => c.type === 'filter');
|
|
3166
|
-
const hasRecordUrl = Boolean(el['recordUrl']);
|
|
3167
|
-
const hasRecordClasses = Boolean(el['recordClasses']);
|
|
3168
|
-
const pollInterval = typeof el['pollInterval'] === 'number' ? el['pollInterval'] : undefined;
|
|
3169
|
-
const defaultGroup = typeof el['defaultGroup'] === 'string' ? el['defaultGroup'] : undefined;
|
|
3170
|
-
const activeGroupKey = typeof el['activeGroupKey'] === 'string' ? el['activeGroupKey'] : undefined;
|
|
3171
|
-
const summaries = el['summaries'];
|
|
3172
|
-
const groupSummaries = el['groupSummaries'];
|
|
3173
|
-
const groupOptions = el['groups'] ?? [];
|
|
3174
|
-
// Active group's registered metadata (if any). Falls back to a synth
|
|
3175
|
-
// for the bare-column form so the heading row still has a label.
|
|
3176
|
-
const activeGroupMeta = defaultGroup
|
|
3177
|
-
? (groupOptions.find(g => g.column === defaultGroup) ?? {
|
|
3178
|
-
column: defaultGroup,
|
|
3179
|
-
label: (() => {
|
|
3180
|
-
const col = columns.find(c => c['name'] === defaultGroup);
|
|
3181
|
-
return col ? String(col['label'] ?? defaultGroup) : defaultGroup;
|
|
3182
|
-
})(),
|
|
3183
|
-
})
|
|
3184
|
-
: undefined;
|
|
3185
|
-
const groupColumnLabel = activeGroupMeta?.label;
|
|
3186
|
-
// Heading text becomes a real `<a href>` when the active group opts in
|
|
3187
|
-
// via `.scopable()`. Synthesized bare-column groups can't be scopable
|
|
3188
|
-
// (no builder call ran).
|
|
3189
|
-
const groupHeadingScopable = activeGroupMeta !== undefined
|
|
3190
|
-
&& activeGroupMeta.scopable === true;
|
|
3191
|
-
// Auto-refresh: re-visit current URL on a timer so sort/filter/pagination
|
|
3192
|
-
// state survives. Pause while the document is hidden — background tabs
|
|
3193
|
-
// shouldn't keep hammering the server.
|
|
3194
|
-
useEffect(() => {
|
|
3195
|
-
if (!pollInterval || pollInterval <= 0)
|
|
3196
|
-
return;
|
|
3197
|
-
if (typeof document === 'undefined')
|
|
3198
|
-
return;
|
|
3199
|
-
let timerId;
|
|
3200
|
-
const tick = () => navigate(window.location.pathname + window.location.search);
|
|
3201
|
-
const start = () => {
|
|
3202
|
-
if (timerId === undefined)
|
|
3203
|
-
timerId = setInterval(tick, pollInterval * 1000);
|
|
3204
|
-
};
|
|
3205
|
-
const stop = () => {
|
|
3206
|
-
if (timerId !== undefined) {
|
|
3207
|
-
clearInterval(timerId);
|
|
3208
|
-
timerId = undefined;
|
|
3209
|
-
}
|
|
3210
|
-
};
|
|
3211
|
-
if (document.visibilityState === 'visible')
|
|
3212
|
-
start();
|
|
3213
|
-
const onVis = () => {
|
|
3214
|
-
if (document.visibilityState === 'visible')
|
|
3215
|
-
start();
|
|
3216
|
-
else
|
|
3217
|
-
stop();
|
|
3218
|
-
};
|
|
3219
|
-
document.addEventListener('visibilitychange', onVis);
|
|
3220
|
-
return () => {
|
|
3221
|
-
document.removeEventListener('visibilitychange', onVis);
|
|
3222
|
-
stop();
|
|
3223
|
-
};
|
|
3224
|
-
}, [pollInterval, navigate]);
|
|
3225
|
-
// Group actions by placement. `inline` defaults to header so it shows up
|
|
3226
|
-
// somewhere visible — explicit placements always win.
|
|
3227
|
-
const placementOf = (a) => String(a['placement'] ?? 'inline');
|
|
3228
|
-
const headerActions = actionLike.filter(a => { const p = placementOf(a); return p === 'header' || p === 'inline'; });
|
|
3229
|
-
const bulkActions = actionLike.filter(a => placementOf(a) === 'bulk');
|
|
3230
|
-
const rowActions = actionLike.filter(a => placementOf(a) === 'row');
|
|
3231
|
-
const rawRows = el['rows'] ?? [];
|
|
3232
|
-
const total = el['total'] ?? rawRows.length;
|
|
3233
|
-
const search = el['search'];
|
|
3234
|
-
const currentSort = el['currentSort'];
|
|
3235
|
-
const currentPage = el['currentPage'] ?? 1;
|
|
3236
|
-
const perPage = el['perPage'];
|
|
3237
|
-
const searchable = Boolean(el['searchable']);
|
|
3238
|
-
const currentPath = el['currentPath'] ?? '';
|
|
3239
|
-
// `Column.toggleable()` user-visibility map. Persisted per-table at
|
|
3240
|
-
// `pilotiq.table.<currentPath>.columns.<name>` ('1' = hidden,
|
|
3241
|
-
// '0' = visible). On first paint, fall back to `meta.toggleable.initiallyHidden`.
|
|
3242
|
-
// SSR returns the meta default — the localStorage hydrate happens
|
|
3243
|
-
// inside the effect so server + first client render match.
|
|
3244
|
-
const columnsVisibilityKey = (name) => `pilotiq.table.${currentPath}.columns.${name}`;
|
|
3245
|
-
const initialHidden = () => {
|
|
3246
|
-
const out = new Set();
|
|
3247
|
-
for (const col of toggleableColumns) {
|
|
3248
|
-
const cfg = col['toggleable'];
|
|
3249
|
-
if (cfg?.initiallyHidden)
|
|
3250
|
-
out.add(String(col['name']));
|
|
3251
|
-
}
|
|
3252
|
-
return out;
|
|
3253
|
-
};
|
|
3254
|
-
const [hiddenColumns, setHiddenColumns] = useState(initialHidden);
|
|
3255
|
-
useEffect(() => {
|
|
3256
|
-
if (typeof window === 'undefined')
|
|
3257
|
-
return;
|
|
3258
|
-
if (toggleableColumns.length === 0)
|
|
3259
|
-
return;
|
|
3260
|
-
const next = new Set();
|
|
3261
|
-
for (const col of toggleableColumns) {
|
|
3262
|
-
const name = String(col['name']);
|
|
3263
|
-
const cfg = col['toggleable'];
|
|
3264
|
-
try {
|
|
3265
|
-
const stored = window.localStorage.getItem(columnsVisibilityKey(name));
|
|
3266
|
-
if (stored === '1')
|
|
3267
|
-
next.add(name);
|
|
3268
|
-
else if (stored === '0') { /* visible */ }
|
|
3269
|
-
else if (cfg?.initiallyHidden)
|
|
3270
|
-
next.add(name);
|
|
3271
|
-
}
|
|
3272
|
-
catch {
|
|
3273
|
-
if (cfg?.initiallyHidden)
|
|
3274
|
-
next.add(name);
|
|
3275
|
-
}
|
|
3276
|
-
}
|
|
3277
|
-
setHiddenColumns(next);
|
|
3278
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3279
|
-
}, [currentPath, toggleableColumns.length]);
|
|
3280
|
-
const toggleColumnHidden = (name, nextHidden) => {
|
|
3281
|
-
setHiddenColumns(prev => {
|
|
3282
|
-
const next = new Set(prev);
|
|
3283
|
-
if (nextHidden)
|
|
3284
|
-
next.add(name);
|
|
3285
|
-
else
|
|
3286
|
-
next.delete(name);
|
|
3287
|
-
if (typeof window !== 'undefined') {
|
|
3288
|
-
try {
|
|
3289
|
-
window.localStorage.setItem(columnsVisibilityKey(name), nextHidden ? '1' : '0');
|
|
3290
|
-
}
|
|
3291
|
-
catch { /* private mode / quota — silent */ }
|
|
3292
|
-
}
|
|
3293
|
-
return next;
|
|
3294
|
-
});
|
|
3295
|
-
};
|
|
3296
|
-
// Filtered column list used by every render path (header, body cells,
|
|
3297
|
-
// group + footer summaries, empty-state colSpan). Non-toggleable
|
|
3298
|
-
// columns always survive.
|
|
3299
|
-
const visibleColumns = columns.filter(c => !hiddenColumns.has(String(c['name'])));
|
|
3300
|
-
// Tier-3 — when the table opts into `Table.queryStringIdentifier(...)`,
|
|
3301
|
-
// every URL key (search / sort / page / perPage / group / filter names)
|
|
3302
|
-
// gets prefixed with `${id}_` so multiple tables on one page don't
|
|
3303
|
-
// collide on `?search=` etc. Bare keys still apply when unset.
|
|
3304
|
-
const queryPrefix = typeof el['queryStringIdentifier'] === 'string'
|
|
3305
|
-
? el['queryStringIdentifier']
|
|
3306
|
-
: undefined;
|
|
3307
|
-
// Reorderable rows — grip column + HTML5 DnD wiring. Rows live in
|
|
3308
|
-
// local state during a drag so the optimistic reorder happens
|
|
3309
|
-
// immediately; on POST failure we roll back to the server's order.
|
|
3310
|
-
const reorderableColumn = typeof el['reorderableColumn'] === 'string' ? el['reorderableColumn'] : undefined;
|
|
3311
|
-
const reorderUrl = typeof el['reorderUrl'] === 'string' ? el['reorderUrl'] : undefined;
|
|
3312
|
-
const [reorderRowsLocal, setReorderRowsLocal] = useState(null);
|
|
3313
|
-
const rows = reorderRowsLocal ?? rawRows;
|
|
3314
|
-
const { notify } = useToast();
|
|
3315
|
-
// Read the explicit `?group=` value out of the URL so sort/pagination
|
|
3316
|
-
// links preserve "None" overrides (`?group=`). Server render: no URL,
|
|
3317
|
-
// so we fall back to `defaultGroup` from the meta — which is already
|
|
3318
|
-
// the reconciled active column.
|
|
3319
|
-
const urlGroup = typeof window === 'undefined'
|
|
3320
|
-
? undefined
|
|
3321
|
-
: (() => {
|
|
3322
|
-
const sp = new URLSearchParams(window.location.search);
|
|
3323
|
-
const k = prefixK(queryPrefix, 'group');
|
|
3324
|
-
return sp.has(k) ? sp.get(k) : undefined;
|
|
3325
|
-
})();
|
|
3326
|
-
// Collapsible groups — per-group fold state. Keyed by `_groupValue`
|
|
3327
|
-
// (the raw column value, NOT the resolved title) so rows that share a
|
|
3328
|
-
// group key fold together. Persisted in localStorage at
|
|
3329
|
-
// `pilotiq.table.<currentPath>.groups.<column>.<value>`. Default-
|
|
3330
|
-
// collapsed groups derive their initial state from `meta.collapsed`.
|
|
3331
|
-
const groupCollapsible = activeGroupMeta?.collapsible === true;
|
|
3332
|
-
const groupDefaultCollapsed = activeGroupMeta?.collapsed === true;
|
|
3333
|
-
const groupStorageKey = (groupValue) => `pilotiq.table.${currentPath}.groups.${defaultGroup ?? ''}.${groupValue}`;
|
|
3334
|
-
// Lazy-init from localStorage on mount; SSR returns the meta default.
|
|
3335
|
-
const [collapsedGroups, setCollapsedGroups] = useState({});
|
|
3336
|
-
useEffect(() => {
|
|
3337
|
-
if (!groupCollapsible || !defaultGroup)
|
|
3338
|
-
return;
|
|
3339
|
-
if (typeof window === 'undefined')
|
|
3340
|
-
return;
|
|
3341
|
-
// Walk the rendered rows once on mount, picking up persisted state.
|
|
3342
|
-
const next = {};
|
|
3343
|
-
const seen = new Set();
|
|
3344
|
-
for (const row of rows) {
|
|
3345
|
-
const v = String(row['_groupValue'] ?? '');
|
|
3346
|
-
if (seen.has(v))
|
|
3347
|
-
continue;
|
|
3348
|
-
seen.add(v);
|
|
3349
|
-
try {
|
|
3350
|
-
const stored = window.localStorage.getItem(groupStorageKey(v));
|
|
3351
|
-
next[v] = stored === null ? groupDefaultCollapsed : stored === '1';
|
|
3352
|
-
}
|
|
3353
|
-
catch {
|
|
3354
|
-
next[v] = groupDefaultCollapsed;
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
|
-
setCollapsedGroups(next);
|
|
3358
|
-
// Re-run if the active group changes — different values, different
|
|
3359
|
-
// localStorage namespace.
|
|
3360
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3361
|
-
}, [defaultGroup, groupCollapsible, groupDefaultCollapsed, currentPath]);
|
|
3362
|
-
const toggleGroupCollapsed = (groupValue) => {
|
|
3363
|
-
setCollapsedGroups(prev => {
|
|
3364
|
-
const nextOpen = !prev[groupValue];
|
|
3365
|
-
const next = { ...prev, [groupValue]: nextOpen };
|
|
3366
|
-
if (typeof window !== 'undefined') {
|
|
3367
|
-
try {
|
|
3368
|
-
window.localStorage.setItem(groupStorageKey(groupValue), nextOpen ? '1' : '0');
|
|
3369
|
-
}
|
|
3370
|
-
catch { /* private mode / quota — silent */ }
|
|
3371
|
-
}
|
|
3372
|
-
return next;
|
|
3373
|
-
});
|
|
3374
|
-
};
|
|
3375
|
-
const state = {
|
|
3376
|
-
...(search !== undefined ? { search } : {}),
|
|
3377
|
-
...(currentSort !== undefined ? { sort: currentSort } : {}),
|
|
3378
|
-
page: currentPage,
|
|
3379
|
-
...(urlGroup !== undefined ? { group: urlGroup }
|
|
3380
|
-
: defaultGroup !== undefined ? { group: defaultGroup }
|
|
3381
|
-
: {}),
|
|
3382
|
-
...(activeGroupKey !== undefined ? { groupKey: activeGroupKey } : {}),
|
|
3383
|
-
};
|
|
3384
|
-
// Snapshot active filter values for sort/pagination href construction.
|
|
3385
|
-
// Filter form submits already carry these (selects are inside the
|
|
3386
|
-
// form); `<a href>` links don't, so we re-emit them here.
|
|
3387
|
-
const activeFilters = {};
|
|
3388
|
-
for (const f of filters) {
|
|
3389
|
-
const v = f['value'];
|
|
3390
|
-
if (typeof v === 'string' && v !== '')
|
|
3391
|
-
activeFilters[String(f['name'])] = v;
|
|
3392
|
-
}
|
|
3393
|
-
// Drill-in / drill-out URL builders for the group heading link and the
|
|
3394
|
-
// active-key chip's clear button. Drill-in sets `?<prefix>groupKey=v`
|
|
3395
|
-
// and resets `page`; drill-out clears it. Both round-trip foreign
|
|
3396
|
-
// params (other tables' state) through `buildTableQuery`.
|
|
3397
|
-
const buildGroupKeyHref = (value) => buildTableQuery(state, { groupKey: value, page: 1 }, currentPath, activeFilters, queryPrefix);
|
|
3398
|
-
const drillOutHref = () => buildTableQuery(state, { groupKey: '', page: 1 }, currentPath, activeFilters, queryPrefix);
|
|
3399
|
-
// Track which row ids are currently checked. Keyed by id (string), not
|
|
3400
|
-
// by index, so pagination and re-renders don't drop selection state.
|
|
3401
|
-
const [selected, setSelected] = useState(() => new Set());
|
|
3402
|
-
const visibleIds = rows.map((row, i) => rowId(row, i));
|
|
3403
|
-
const allChecked = visibleIds.length > 0 && visibleIds.every(id => selected.has(id));
|
|
3404
|
-
const someChecked = selected.size > 0;
|
|
3405
|
-
const toggleRow = (id) => {
|
|
3406
|
-
setSelected(prev => {
|
|
3407
|
-
const next = new Set(prev);
|
|
3408
|
-
if (next.has(id))
|
|
3409
|
-
next.delete(id);
|
|
3410
|
-
else
|
|
3411
|
-
next.add(id);
|
|
3412
|
-
return next;
|
|
3413
|
-
});
|
|
3414
|
-
};
|
|
3415
|
-
const toggleAll = () => {
|
|
3416
|
-
setSelected(prev => {
|
|
3417
|
-
if (visibleIds.every(id => prev.has(id))) {
|
|
3418
|
-
const next = new Set(prev);
|
|
3419
|
-
for (const id of visibleIds)
|
|
3420
|
-
next.delete(id);
|
|
3421
|
-
return next;
|
|
3422
|
-
}
|
|
3423
|
-
const next = new Set(prev);
|
|
3424
|
-
for (const id of visibleIds)
|
|
3425
|
-
next.add(id);
|
|
3426
|
-
return next;
|
|
3427
|
-
});
|
|
3428
|
-
};
|
|
3429
|
-
// ── Reorder DnD state + handlers ──────────────────────
|
|
3430
|
-
// dragId — the row currently being dragged (string id), or null.
|
|
3431
|
-
// dropAt — the boundary the cursor is hovering (0..rows.length), or null.
|
|
3432
|
-
const [dragId, setDragId] = useState(null);
|
|
3433
|
-
const [dropAt, setDropAt] = useState(null);
|
|
3434
|
-
const onRowDragStart = (id) => (e) => {
|
|
3435
|
-
if (!reorderEnabled)
|
|
3436
|
-
return;
|
|
3437
|
-
setDragId(id);
|
|
3438
|
-
e.dataTransfer.effectAllowed = 'move';
|
|
3439
|
-
try {
|
|
3440
|
-
e.dataTransfer.setData('text/plain', id);
|
|
3441
|
-
}
|
|
3442
|
-
catch { /* IE quirk */ }
|
|
3443
|
-
};
|
|
3444
|
-
const onRowDragOver = (idx) => (e) => {
|
|
3445
|
-
if (!reorderEnabled || dragId === null)
|
|
3446
|
-
return;
|
|
3447
|
-
e.preventDefault();
|
|
3448
|
-
e.dataTransfer.dropEffect = 'move';
|
|
3449
|
-
const rect = e.currentTarget.getBoundingClientRect();
|
|
3450
|
-
const aboveHalf = e.clientY < rect.top + rect.height / 2;
|
|
3451
|
-
setDropAt(aboveHalf ? idx : idx + 1);
|
|
3452
|
-
};
|
|
3453
|
-
const onRowDrop = async (e) => {
|
|
3454
|
-
if (!reorderEnabled || dragId === null || dropAt === null || !reorderUrl) {
|
|
3455
|
-
setDragId(null);
|
|
3456
|
-
setDropAt(null);
|
|
3457
|
-
return;
|
|
3458
|
-
}
|
|
3459
|
-
e.preventDefault();
|
|
3460
|
-
const fromIdx = visibleIds.findIndex(id => id === dragId);
|
|
3461
|
-
setDragId(null);
|
|
3462
|
-
setDropAt(null);
|
|
3463
|
-
if (fromIdx < 0)
|
|
3464
|
-
return;
|
|
3465
|
-
const target = dropAt > fromIdx ? dropAt - 1 : dropAt;
|
|
3466
|
-
if (target === fromIdx)
|
|
3467
|
-
return;
|
|
3468
|
-
const reordered = rows.slice();
|
|
3469
|
-
const moved = reordered.splice(fromIdx, 1)[0];
|
|
3470
|
-
reordered.splice(target, 0, moved);
|
|
3471
|
-
const newIds = reordered.map((row, i) => rowId(row, i));
|
|
3472
|
-
const previousLocal = reorderRowsLocal;
|
|
3473
|
-
setReorderRowsLocal(reordered);
|
|
3474
|
-
try {
|
|
3475
|
-
const res = await fetch(reorderUrl, {
|
|
3476
|
-
method: 'POST',
|
|
3477
|
-
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
3478
|
-
body: JSON.stringify({ ids: newIds }),
|
|
3479
|
-
});
|
|
3480
|
-
if (!res.ok)
|
|
3481
|
-
throw new Error(`Reorder failed (${res.status})`);
|
|
3482
|
-
}
|
|
3483
|
-
catch (err) {
|
|
3484
|
-
// Roll back to server order. The toast surfaces the failure;
|
|
3485
|
-
// next page render fetches the persisted column.
|
|
3486
|
-
setReorderRowsLocal(previousLocal);
|
|
3487
|
-
notify({
|
|
3488
|
-
type: 'error',
|
|
3489
|
-
title: 'Could not save new order',
|
|
3490
|
-
body: err instanceof Error ? err.message : 'Reorder failed',
|
|
3491
|
-
});
|
|
3492
|
-
}
|
|
3493
|
-
};
|
|
3494
|
-
const onRowDragEnd = () => {
|
|
3495
|
-
setDragId(null);
|
|
3496
|
-
setDropAt(null);
|
|
3497
|
-
};
|
|
3498
|
-
if (columns.length === 0) {
|
|
3499
|
-
return (_jsx("div", { className: "rounded-xl border bg-card p-6 text-sm text-muted-foreground", children: "No columns configured for this table." }));
|
|
3500
|
-
}
|
|
3501
|
-
const isCardsLayout = el['contentLayout'] === 'cards';
|
|
3502
|
-
const cardsPerRow = el['cardsPerRow'];
|
|
3503
|
-
const totalPages = perPage && perPage > 0 ? Math.max(1, Math.ceil(total / perPage)) : 1;
|
|
3504
|
-
const showPagination = totalPages > 1;
|
|
3505
|
-
const hasFilters = filters.length > 0;
|
|
3506
|
-
// Filter layout positions (Filament v5). `'modal'` (default) keeps the
|
|
3507
|
-
// toolbar Filters button + popover. The three inline modes lay every
|
|
3508
|
-
// filter widget out as a wrapping strip in the matching slot. The
|
|
3509
|
-
// collapsible variant adds a toolbar toggle + per-table-path persisted
|
|
3510
|
-
// open state.
|
|
3511
|
-
const filtersLayout = el['filtersLayout'] ?? 'modal';
|
|
3512
|
-
const filtersInModal = filtersLayout === 'modal';
|
|
3513
|
-
const filtersAbove = filtersLayout === 'above-content'
|
|
3514
|
-
|| filtersLayout === 'above-content-collapsible';
|
|
3515
|
-
const filtersBelow = filtersLayout === 'below-content';
|
|
3516
|
-
const filtersCollapsible = filtersLayout === 'above-content-collapsible';
|
|
3517
|
-
const filtersStripStorageKey = `pilotiq.table.${currentPath}.filters.open`;
|
|
3518
|
-
const [filtersOpen, setFiltersOpen] = useState(() => {
|
|
3519
|
-
if (!filtersCollapsible)
|
|
3520
|
-
return true;
|
|
3521
|
-
if (typeof window === 'undefined')
|
|
3522
|
-
return false;
|
|
3523
|
-
try {
|
|
3524
|
-
const stored = window.localStorage.getItem(filtersStripStorageKey);
|
|
3525
|
-
// Default to OPEN when filters are active (URL carried filter values
|
|
3526
|
-
// in) so the user can see what's filtering — same UX cue as the
|
|
3527
|
-
// active-filters pill row.
|
|
3528
|
-
if (stored === null)
|
|
3529
|
-
return Object.keys(activeFilters).length > 0;
|
|
3530
|
-
return stored === '1';
|
|
3531
|
-
}
|
|
3532
|
-
catch {
|
|
3533
|
-
return false;
|
|
3534
|
-
}
|
|
3535
|
-
});
|
|
3536
|
-
const toggleFiltersOpen = () => {
|
|
3537
|
-
setFiltersOpen(prev => {
|
|
3538
|
-
const next = !prev;
|
|
3539
|
-
if (typeof window !== 'undefined') {
|
|
3540
|
-
try {
|
|
3541
|
-
window.localStorage.setItem(filtersStripStorageKey, next ? '1' : '0');
|
|
3542
|
-
}
|
|
3543
|
-
catch { /* private mode / quota — silent */ }
|
|
3544
|
-
}
|
|
3545
|
-
return next;
|
|
3546
|
-
});
|
|
3547
|
-
};
|
|
3548
|
-
// Show the "Group by" dropdown when 2+ groups are registered, or 1
|
|
3549
|
-
// group with rich metadata (label/collapsible/etc.). A single bare
|
|
3550
|
-
// `defaultGroup('col')` with no `groups([...])` registration shouldn't
|
|
3551
|
-
// render the picker — there's nothing to pick.
|
|
3552
|
-
const hasGroupPicker = groupOptions.length >= 2
|
|
3553
|
-
|| (groupOptions.length === 1 && Boolean(groupOptions[0].collapsible
|
|
3554
|
-
|| groupOptions[0].collapsed
|
|
3555
|
-
|| groupOptions[0].date));
|
|
3556
|
-
const sortableColumns = isCardsLayout ? columns.filter(c => Boolean(c['sortable'])) : [];
|
|
3557
|
-
const hasSortPicker = isCardsLayout && sortableColumns.length > 0;
|
|
3558
|
-
// Only modal + collapsible mount a toolbar widget; the always-visible
|
|
3559
|
-
// strip modes don't add anything to the header bar.
|
|
3560
|
-
const showFiltersInToolbar = hasFilters && (filtersInModal || filtersCollapsible);
|
|
3561
|
-
const hasColumnsToggle = toggleableColumns.length > 0;
|
|
3562
|
-
const showHeaderBar = searchable || headerActions.length > 0 || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle;
|
|
3563
|
-
const hasBulkActions = bulkActions.length > 0;
|
|
3564
|
-
const hasRowActions = rowActions.length > 0;
|
|
3565
|
-
// Drag-to-reorder is enabled only when the visible rows ARE the
|
|
3566
|
-
// canonical sort. Filters / search / non-default sort / pagination
|
|
3567
|
-
// beyond page 1 all break that invariant; we render the grip column
|
|
3568
|
-
// greyed-out instead of letting the user reorder a slice that won't
|
|
3569
|
-
// round-trip cleanly. `reorderableColumn` is set server-side when
|
|
3570
|
-
// `Table.reorderable()` opts in.
|
|
3571
|
-
const sortMatchesReorder = currentSort?.column === reorderableColumn &&
|
|
3572
|
-
currentSort?.direction === 'asc';
|
|
3573
|
-
const filtersActive = Object.keys(activeFilters).length > 0;
|
|
3574
|
-
const searchActive = typeof search === 'string' && search !== '';
|
|
3575
|
-
const reorderEnabled = reorderableColumn !== undefined &&
|
|
3576
|
-
reorderUrl !== undefined &&
|
|
3577
|
-
sortMatchesReorder &&
|
|
3578
|
-
!filtersActive &&
|
|
3579
|
-
!searchActive &&
|
|
3580
|
-
currentPage === 1;
|
|
3581
|
-
const reorderColumnVisible = reorderableColumn !== undefined;
|
|
3582
|
-
const totalCols = visibleColumns.length
|
|
3583
|
-
+ (hasBulkActions ? 1 : 0)
|
|
3584
|
-
+ (hasRowActions ? 1 : 0)
|
|
3585
|
-
+ (reorderColumnVisible ? 1 : 0);
|
|
3586
|
-
// Top-bar chrome (heading / description / striped / emptyState).
|
|
3587
|
-
const tableHeading = el['heading'];
|
|
3588
|
-
const tableDescription = el['description'];
|
|
3589
|
-
const striped = Boolean(el['striped']);
|
|
3590
|
-
const emptyState = el['emptyState'];
|
|
3591
|
-
const filteredEmptyState = el['filteredEmptyState'];
|
|
3592
|
-
const hasFilterOrSearch = (search !== undefined && search !== '') ||
|
|
3593
|
-
Object.keys(activeFilters).length > 0;
|
|
3594
|
-
// Distinct copy when a query / filter is active. Falls back to
|
|
3595
|
-
// `emptyState` when `filteredEmptyState` is not set, preserving the
|
|
3596
|
-
// pre-2026-05-04 behavior for tables that haven't opted in.
|
|
3597
|
-
const activeEmpty = (hasFilterOrSearch && filteredEmptyState) ? filteredEmptyState : emptyState;
|
|
3598
|
-
const EmptyIcon = activeEmpty?.icon ? (resolveIcon(activeEmpty.icon) ?? InboxIcon) : InboxIcon;
|
|
3599
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [(tableHeading || tableDescription) && (_jsxs("div", { className: "flex flex-col gap-1", children: [tableHeading && _jsx("h2", { className: "text-lg font-semibold", children: tableHeading }), tableDescription && _jsx("p", { className: "text-sm text-muted-foreground", children: tableDescription })] })), showHeaderBar && (_jsxs("div", { className: "flex flex-col-reverse gap-2 sm:flex-row sm:items-center sm:justify-between", children: [(searchable || showFiltersInToolbar || hasGroupPicker || hasSortPicker || hasColumnsToggle) ? (_jsxs("div", { className: "flex items-center gap-2", children: [searchable && (_jsxs("form", { method: "get", action: currentPath || undefined, className: "flex items-end gap-2", children: [_jsx(SearchFormHiddenInputs, { prefix: queryPrefix }), _jsx(Input, { type: "search", name: prefixK(queryPrefix, 'search'), defaultValue: search ?? '', placeholder: "Search\u2026", className: "h-9 w-64" }), _jsx("button", { type: "submit", className: "sr-only", tabIndex: -1, "aria-hidden": "true", children: "Apply" })] })), hasFilters && filtersInModal && (_jsx(FilterPopover, { filters: filters, prefix: queryPrefix })), hasFilters && filtersCollapsible && (_jsx(FilterStripToggle, { filters: filters, open: filtersOpen, onToggle: toggleFiltersOpen })), hasGroupPicker && (_jsx(TableGroupPicker, { options: groupOptions, active: defaultGroup, onChange: (value) => {
|
|
3600
|
-
// value === '' → explicit "None" (clears defaultGroup);
|
|
3601
|
-
// value !== '' → switch to that column.
|
|
3602
|
-
const href = buildTableQuery(state, { page: 1, group: value }, currentPath, activeFilters, queryPrefix);
|
|
3603
|
-
navigate(href);
|
|
3604
|
-
} })), hasSortPicker && (_jsx(SortByPicker, { columns: sortableColumns, active: currentSort, onChange: (column, direction) => {
|
|
3605
|
-
const href = buildTableQuery(state, { sort: { column, direction }, page: 1 }, currentPath, activeFilters, queryPrefix);
|
|
3606
|
-
navigate(href);
|
|
3607
|
-
} })), toggleableColumns.length > 0 && (_jsx(ColumnsToggleDropdown, { columns: toggleableColumns, hidden: hiddenColumns, onToggle: toggleColumnHidden }))] })) : _jsx("span", {}), headerActions.length > 0 && (_jsx("div", { className: "flex items-center gap-2", children: headerActions.map((a, i) => renderActionLike(a, i)) }))] })), hasFilters && filtersInModal && _jsx(ActiveFiltersBar, { filters: filters, prefix: queryPrefix }), hasFilters && filtersAbove && filtersOpen && (_jsx(FilterStrip, { filters: filters, prefix: queryPrefix })), activeGroupKey !== undefined && (_jsx(ActiveGroupKeyChip, { label: groupColumnLabel ?? defaultGroup ?? '', value: activeGroupKey, displayValue: (() => {
|
|
3608
|
-
// Prefer a row-resolved `_groupTitle` (server stamped via
|
|
3609
|
-
// `getTitleFromRecordUsing`) so the chip reads the same as
|
|
3610
|
-
// a banded heading. Falls back to the raw bucket key when
|
|
3611
|
-
// no row matched — empty drilled-in pages still show what
|
|
3612
|
-
// they're drilled into.
|
|
3613
|
-
for (const r of rows) {
|
|
3614
|
-
const obj = r;
|
|
3615
|
-
if (String(obj['_groupValue'] ?? '') !== activeGroupKey)
|
|
3616
|
-
continue;
|
|
3617
|
-
const t = obj['_groupTitle'];
|
|
3618
|
-
if (typeof t === 'string' && t !== '')
|
|
3619
|
-
return t;
|
|
3620
|
-
break;
|
|
3621
|
-
}
|
|
3622
|
-
return activeGroupKey;
|
|
3623
|
-
})(), clearHref: drillOutHref(), navigate: navigate })), hasBulkActions && someChecked && (_jsxs("div", { className: "flex items-center justify-between gap-2 rounded-md border bg-muted/40 px-3 py-2 text-sm", children: [_jsxs("span", { className: "text-muted-foreground", children: [selected.size, " selected"] }), _jsxs("div", { className: "flex items-center gap-2", children: [bulkActions.map((a, i) => renderActionLike(a, i, { ids: Array.from(selected) })), _jsx("button", { type: "button", onClick: () => setSelected(new Set()), className: "text-xs text-muted-foreground hover:text-foreground", children: "Clear" })] })] })), isCardsLayout ? (_jsx(CardsLayoutBody, { el: el, columns: columns, rows: rows, visibleIds: visibleIds, selected: selected, toggleRow: toggleRow, hasBulkActions: hasBulkActions, hasRowActions: hasRowActions, rowActions: rowActions, hasRecordUrl: hasRecordUrl, hasRecordClasses: hasRecordClasses, striped: striped, activeEmpty: activeEmpty, EmptyIcon: EmptyIcon, hasFilterOrSearch: hasFilterOrSearch, defaultGroup: defaultGroup, groupColumnLabel: groupColumnLabel, groupCollapsible: groupCollapsible, collapsedGroups: collapsedGroups, toggleGroupCollapsed: toggleGroupCollapsed, cardsPerRow: cardsPerRow, navigate: navigate, groupHeadingScopable: groupHeadingScopable, buildGroupKeyHref: buildGroupKeyHref })) : (_jsx("div", { className: "rounded-xl border bg-card overflow-hidden", children: _jsxs(DataTable, { children: [_jsx(TableHeader, { className: "bg-muted", children: _jsxs(TableRow, { children: [reorderColumnVisible && (_jsx(TableHead, { className: "w-9 px-2", "aria-label": "Reorder" })), hasBulkActions && (_jsx(TableHead, { className: "w-9 px-3", children: _jsx(Checkbox, { "aria-label": "Select all rows", checked: allChecked, onCheckedChange: () => toggleAll() }) })), visibleColumns.map((col, i) => {
|
|
3624
|
-
const name = String(col['name'] ?? '');
|
|
3625
|
-
const label = String(col['label'] ?? name);
|
|
3626
|
-
const sortable = Boolean(col['sortable']);
|
|
3627
|
-
const isActive = currentSort?.column === name;
|
|
3628
|
-
if (!sortable) {
|
|
3629
|
-
return (_jsx(TableHead, { className: "text-xs uppercase tracking-wider", children: label }, i));
|
|
3630
|
-
}
|
|
3631
|
-
const next = nextSortDir(currentSort, name);
|
|
3632
|
-
const href = buildTableQuery(state, { sort: next, page: 1 }, currentPath, activeFilters, queryPrefix);
|
|
3633
|
-
return (_jsx(TableHead, { className: "text-xs uppercase tracking-wider", children: _jsxs("a", { href: href, className: "inline-flex items-center gap-1 hover:text-foreground", children: [label, _jsx("span", { className: "text-muted-foreground/70", children: isActive ? (currentSort.direction === 'asc' ? '↑' : '↓') : '↕' })] }) }, i));
|
|
3634
|
-
}), hasRowActions && (_jsx(TableHead, { className: "w-px text-right text-xs uppercase tracking-wider", children: _jsx("span", { className: "sr-only", children: "Actions" }) }))] }) }), _jsx(TableBody, { children: rows.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: totalCols, className: "py-12 text-center", children: _jsxs("div", { className: "flex flex-col items-center gap-2 text-muted-foreground", children: [_jsx(EmptyIcon, { className: "size-8 opacity-60" }), _jsx("p", { className: "text-base font-medium text-foreground", children: activeEmpty?.heading
|
|
3635
|
-
?? (hasFilterOrSearch ? 'No matching records' : 'No records yet') }), (activeEmpty?.description ||
|
|
3636
|
-
(hasFilterOrSearch && !activeEmpty?.description)) && (_jsx("p", { className: "text-sm", children: activeEmpty?.description
|
|
3637
|
-
?? 'Try clearing filters or adjusting your search.' }))] }) }) })) : rows.map((row, ri) => {
|
|
3638
|
-
const id = visibleIds[ri];
|
|
3639
|
-
const recordObj = row;
|
|
3640
|
-
const isSelected = selected.has(id);
|
|
3641
|
-
const stripedClass = striped && ri % 2 === 1 ? 'bg-muted/30' : '';
|
|
3642
|
-
// Group banding — emit a heading row whenever `_groupValue`
|
|
3643
|
-
// differs from the previous row. The first row in any group
|
|
3644
|
-
// gets the heading; rows within keep their normal chrome.
|
|
3645
|
-
const groupValue = defaultGroup
|
|
3646
|
-
? String(recordObj['_groupValue'] ?? '')
|
|
3647
|
-
: undefined;
|
|
3648
|
-
const groupTitle = defaultGroup
|
|
3649
|
-
? recordObj['_groupTitle']
|
|
3650
|
-
: undefined;
|
|
3651
|
-
const groupDescription = defaultGroup
|
|
3652
|
-
? recordObj['_groupDescription']
|
|
3653
|
-
: undefined;
|
|
3654
|
-
const prevGroupValue = defaultGroup && ri > 0
|
|
3655
|
-
? String((rows[ri - 1]['_groupValue'] ?? ''))
|
|
3656
|
-
: undefined;
|
|
3657
|
-
const showGroupHeader = defaultGroup !== undefined && groupValue !== prevGroupValue;
|
|
3658
|
-
// Hide data rows whose group is collapsed. The heading row
|
|
3659
|
-
// for that group still renders (so the user can re-expand).
|
|
3660
|
-
const isInCollapsedGroup = groupCollapsible && groupValue !== undefined && collapsedGroups[groupValue] === true;
|
|
3661
|
-
// Filament-style per-cell linking. Each data cell wraps
|
|
3662
|
-
// its content in a real `<a href>` when the column resolves
|
|
3663
|
-
// to a record URL — column override (`Column.recordUrl(fn)`)
|
|
3664
|
-
// beats inheritance from the table (`Table.recordUrl(fn)`),
|
|
3665
|
-
// and `Column.recordUrl(false)` opts out. Action and bulk
|
|
3666
|
-
// cells are never wrapped, so clicks there fire only their
|
|
3667
|
-
// own handlers — no event-bubbling gymnastics.
|
|
3668
|
-
const tableUrl = hasRecordUrl ? recordObj['_recordUrl'] : undefined;
|
|
3669
|
-
const colUrls = recordObj['_columnRecordUrls'] ?? {};
|
|
3670
|
-
const rowHasAnyLink = tableUrl !== undefined || Object.keys(colUrls).length > 0;
|
|
3671
|
-
const customRowClasses = hasRecordClasses
|
|
3672
|
-
? recordObj['_recordClasses'] ?? ''
|
|
3673
|
-
: '';
|
|
3674
|
-
const rowClassName = [stripedClass, rowHasAnyLink ? 'cursor-pointer' : '', customRowClasses]
|
|
3675
|
-
.filter(Boolean)
|
|
3676
|
-
.join(' ')
|
|
3677
|
-
.trim();
|
|
3678
|
-
return (_jsxs(React.Fragment, { children: [showGroupHeader && (_jsx(TableRow, { className: "bg-muted/40 hover:bg-muted/40", children: _jsx(TableCell, { colSpan: totalCols, className: "px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: (() => {
|
|
3679
|
-
const drillable = groupHeadingScopable
|
|
3680
|
-
&& groupValue !== undefined
|
|
3681
|
-
&& groupValue !== '';
|
|
3682
|
-
const headingText = (_jsx(GroupHeaderText, { label: groupColumnLabel, value: groupValue, title: groupTitle, description: groupDescription }));
|
|
3683
|
-
const headingNode = drillable
|
|
3684
|
-
? _jsx(GroupHeadingLink, { href: buildGroupKeyHref(groupValue), navigate: navigate, children: headingText })
|
|
3685
|
-
: headingText;
|
|
3686
|
-
if (groupCollapsible) {
|
|
3687
|
-
return (_jsxs("div", { className: "flex w-full items-center gap-2", children: [_jsx("button", { type: "button", className: "inline-flex items-center", onClick: () => toggleGroupCollapsed(groupValue), "aria-expanded": !isInCollapsedGroup, "aria-label": isInCollapsedGroup ? 'Expand group' : 'Collapse group', children: _jsx(ChevronDownIcon, { className: [
|
|
3688
|
-
'size-4 transition-transform',
|
|
3689
|
-
isInCollapsedGroup ? '-rotate-90' : '',
|
|
3690
|
-
].filter(Boolean).join(' ') }) }), headingNode] }));
|
|
3691
|
-
}
|
|
3692
|
-
return headingNode;
|
|
3693
|
-
})() }) }, `group-${id}`)), isInCollapsedGroup ? null : (_jsxs(TableRow, { "data-state": isSelected ? 'selected' : undefined, className: [
|
|
3694
|
-
rowClassName,
|
|
3695
|
-
dragId === id ? 'opacity-50' : '',
|
|
3696
|
-
dropAt === ri && dragId !== null ? 'border-t-2 border-t-primary' : '',
|
|
3697
|
-
].filter(Boolean).join(' ') || undefined, draggable: reorderEnabled || undefined, onDragStart: reorderEnabled ? onRowDragStart(id) : undefined, onDragOver: reorderEnabled ? onRowDragOver(ri) : undefined, onDrop: reorderEnabled ? onRowDrop : undefined, onDragEnd: reorderEnabled ? onRowDragEnd : undefined, children: [reorderColumnVisible && (_jsx(TableCell, { className: "w-9 px-2", children: _jsx("span", { "aria-label": reorderEnabled ? 'Drag to reorder' : 'Reorder paused — clear filters and sort to enable', className: reorderEnabled
|
|
3698
|
-
? 'inline-flex cursor-grab text-muted-foreground hover:text-foreground active:cursor-grabbing'
|
|
3699
|
-
: 'inline-flex cursor-not-allowed text-muted-foreground/40', children: _jsx(GripVerticalIcon, { className: "size-4" }) }) })), hasBulkActions && (_jsx(TableCell, { className: "w-9 px-3", children: _jsx(Checkbox, { "aria-label": `Select row ${id}`, checked: isSelected, onCheckedChange: () => toggleRow(id) }) })), visibleColumns.map((col, ci) => {
|
|
3700
|
-
const name = String(col['name'] ?? '');
|
|
3701
|
-
const value = recordObj[name];
|
|
3702
|
-
const align = col['alignment'] === 'center' ? 'text-center'
|
|
3703
|
-
: col['alignment'] === 'end' ? 'text-right'
|
|
3704
|
-
: 'text-left';
|
|
3705
|
-
const widthStyle = col['width']
|
|
3706
|
-
? { width: String(col['width']) }
|
|
3707
|
-
: undefined;
|
|
3708
|
-
// Inline-edit cells take priority over read-only chrome.
|
|
3709
|
-
// `_cellEditable[name]` is set per row by `loadTableRecords`
|
|
3710
|
-
// only when `R.canEdit(user, row)` passed; the URL was
|
|
3711
|
-
// stamped by `tagCellEditUrls` immediately after.
|
|
3712
|
-
const editableMap = recordObj['_cellEditable'];
|
|
3713
|
-
const editUrlMap = recordObj['_cellEditUrls'];
|
|
3714
|
-
const cellDisabledMap = recordObj['_cellDisabled'];
|
|
3715
|
-
const editUrl = editableMap?.[name] ? editUrlMap?.[name] : undefined;
|
|
3716
|
-
const EditableComp = editUrl !== undefined
|
|
3717
|
-
? pickEditableCell(String(col['columnType'] ?? 'text'))
|
|
3718
|
-
: null;
|
|
3719
|
-
if (EditableComp && editUrl !== undefined) {
|
|
3720
|
-
const cellDisabled = col['disabled'] === true || cellDisabledMap?.[name] === true;
|
|
3721
|
-
const cellSelectOptionsMap = recordObj['_cellSelectOptions'];
|
|
3722
|
-
const rowOptions = cellSelectOptionsMap?.[name];
|
|
3723
|
-
return (_jsx(TableCell, { className: `text-sm text-foreground ${align} p-0`, style: widthStyle, children: _jsx(EditableComp, { url: editUrl, col: col, value: value, disabled: cellDisabled, ...(rowOptions ? { rowOptions } : {}) }) }, ci));
|
|
3724
|
-
}
|
|
3725
|
-
const cellContent = formatCell(value, col, recordObj);
|
|
3726
|
-
const colUrl = resolveColumnUrl(col, tableUrl, colUrls);
|
|
3727
|
-
return (_jsx(TableCell, { className: `text-sm text-foreground ${align} p-0`, style: widthStyle, children: colUrl !== undefined
|
|
3728
|
-
? _jsx(RecordCellLink, { href: colUrl, navigate: navigate, children: cellContent })
|
|
3729
|
-
: _jsx("div", { className: "px-2 py-2", children: cellContent }) }, ci));
|
|
3730
|
-
}), hasRowActions && (_jsx(TableCell, { className: "w-px text-right", children: renderRowActions(id, recordObj, rowActions) }))] })), (() => {
|
|
3731
|
-
if (!groupSummaries)
|
|
3732
|
-
return null;
|
|
3733
|
-
if (groupValue === undefined)
|
|
3734
|
-
return null;
|
|
3735
|
-
if (isInCollapsedGroup)
|
|
3736
|
-
return null;
|
|
3737
|
-
const isLastInGroup = ri === rows.length - 1
|
|
3738
|
-
|| String((rows[ri + 1]['_groupValue'] ?? '')) !== groupValue;
|
|
3739
|
-
if (!isLastInGroup)
|
|
3740
|
-
return null;
|
|
3741
|
-
const perCol = groupSummaries[groupValue];
|
|
3742
|
-
if (!perCol || Object.keys(perCol).length === 0)
|
|
3743
|
-
return null;
|
|
3744
|
-
return (_jsxs(TableRow, { className: "bg-muted/20 hover:bg-muted/20", children: [reorderColumnVisible && _jsx(TableCell, {}), hasBulkActions && _jsx(TableCell, {}), visibleColumns.map((col, ci) => {
|
|
3745
|
-
const name = String(col['name'] ?? '');
|
|
3746
|
-
const align = col['alignment'] === 'center' ? 'text-center'
|
|
3747
|
-
: col['alignment'] === 'end' ? 'text-right'
|
|
3748
|
-
: 'text-left';
|
|
3749
|
-
const items = perCol[name];
|
|
3750
|
-
return (_jsx(TableCell, { className: `text-xs font-medium ${align} px-2 py-1.5`, children: items?.map((s, i) => (_jsxs("div", { className: "leading-tight", children: [s.label && _jsxs("span", { className: "text-muted-foreground", children: [s.label, ": "] }), _jsx("span", { children: s.value })] }, i))) }, ci));
|
|
3751
|
-
}), hasRowActions && _jsx(TableCell, {})] }, `group-summary-${id}`));
|
|
3752
|
-
})()] }, id));
|
|
3753
|
-
}) }), summaries && Object.keys(summaries).length > 0 && (_jsx(TableFooter, { children: _jsxs(TableRow, { children: [reorderColumnVisible && _jsx(TableCell, {}), hasBulkActions && _jsx(TableCell, {}), visibleColumns.map((col, ci) => {
|
|
3754
|
-
const name = String(col['name'] ?? '');
|
|
3755
|
-
const align = col['alignment'] === 'center' ? 'text-center'
|
|
3756
|
-
: col['alignment'] === 'end' ? 'text-right'
|
|
3757
|
-
: 'text-left';
|
|
3758
|
-
const items = summaries[name];
|
|
3759
|
-
return (_jsx(TableCell, { className: `text-sm font-medium ${align}`, children: items?.map((s, i) => (_jsxs("div", { className: "leading-tight", children: [s.label && _jsxs("span", { className: "text-muted-foreground", children: [s.label, ": "] }), _jsx("span", { children: s.value })] }, i))) }, ci));
|
|
3760
|
-
}), hasRowActions && _jsx(TableCell, {})] }) }))] }) })), showPagination && (_jsxs("div", { className: "flex items-center justify-between text-sm text-muted-foreground", children: [_jsxs("span", { children: ["Page ", currentPage, " of ", totalPages, total > 0 ? ` · ${total} record${total === 1 ? '' : 's'}` : ''] }), _jsxs("div", { className: "flex items-center gap-2", children: [currentPage > 1 && (_jsx("a", { href: buildTableQuery(state, { page: currentPage - 1 }, currentPath, activeFilters, queryPrefix), className: "rounded-md border px-3 py-1 text-xs hover:bg-muted", children: "\u2190 Previous" })), currentPage < totalPages && (_jsx("a", { href: buildTableQuery(state, { page: currentPage + 1 }, currentPath, activeFilters, queryPrefix), className: "rounded-md border px-3 py-1 text-xs hover:bg-muted", children: "Next \u2192" }))] })] })), hasFilters && filtersBelow && (_jsx(FilterStrip, { filters: filters, prefix: queryPrefix }))] }));
|
|
3761
|
-
}
|
|
3762
|
-
/**
|
|
3763
|
-
* Card-grid body for `Table.contentLayout('cards')`. Renders the rows
|
|
3764
|
-
* area only — the surrounding chrome (heading / search / filters /
|
|
3765
|
-
* pagination / bulk-action toolbar / "Sort by" picker) lives in the
|
|
3766
|
-
* parent `TableRendererBody` so both layouts share it.
|
|
3767
|
-
*
|
|
3768
|
-
* Each card renders its `_cardChildren` schema via the standard
|
|
3769
|
-
* `renderElement` walker, so any display-Element (Heading, Text, Image,
|
|
3770
|
-
* Icon, Badge entries, layout primitives, etc.) drops in without a new
|
|
3771
|
-
* renderer. Per-row chrome attaches via the same `_recordUrl` /
|
|
3772
|
-
* `_recordClasses` / `_visibleActions` / `_disabledActions` keys the
|
|
3773
|
-
* table-mode renderer reads from — `loadTableRecords` is unchanged.
|
|
3774
|
-
*
|
|
3775
|
-
* Group banding splits the rows into contiguous sections by
|
|
3776
|
-
* `_groupValue`, emitting a heading row above each section. The user's
|
|
3777
|
-
* configured per-card grid (`cardsPerRow`) re-applies inside every
|
|
3778
|
-
* section so the column count stays consistent.
|
|
3779
|
-
*/
|
|
3780
|
-
function CardsLayoutBody({ el, columns, rows, visibleIds, selected, toggleRow, hasBulkActions, hasRowActions, rowActions, hasRecordUrl, hasRecordClasses, striped, activeEmpty, EmptyIcon, hasFilterOrSearch, defaultGroup, groupColumnLabel, groupCollapsible, collapsedGroups, toggleGroupCollapsed, cardsPerRow, navigate, groupHeadingScopable, buildGroupKeyHref, }) {
|
|
3781
|
-
void el; // keep prop for future telemetry; silences unused-prop lint
|
|
3782
|
-
void columns;
|
|
3783
|
-
void striped; // visual stripes don't apply to cards (each card has its own surface)
|
|
3784
|
-
const gridClass = `grid gap-4 ${cardsPerRowClasses(cardsPerRow)}`;
|
|
3785
|
-
if (rows.length === 0) {
|
|
3786
|
-
return (_jsx("div", { className: "rounded-xl border bg-card py-12 text-center", children: _jsxs("div", { className: "flex flex-col items-center gap-2 text-muted-foreground", children: [_jsx(EmptyIcon, { className: "size-8 opacity-60" }), _jsx("p", { className: "text-base font-medium text-foreground", children: activeEmpty?.heading
|
|
3787
|
-
?? (hasFilterOrSearch ? 'No matching records' : 'No records yet') }), (activeEmpty?.description ||
|
|
3788
|
-
(hasFilterOrSearch && !activeEmpty?.description)) && (_jsx("p", { className: "text-sm", children: activeEmpty?.description
|
|
3789
|
-
?? 'Try clearing filters or adjusting your search.' }))] }) }));
|
|
3790
|
-
}
|
|
3791
|
-
const sections = [];
|
|
3792
|
-
if (defaultGroup === undefined) {
|
|
3793
|
-
sections.push({ indices: rows.map((_, i) => i) });
|
|
3794
|
-
}
|
|
3795
|
-
else {
|
|
3796
|
-
let current;
|
|
3797
|
-
for (let i = 0; i < rows.length; i++) {
|
|
3798
|
-
const r = rows[i];
|
|
3799
|
-
const v = String(r['_groupValue'] ?? '');
|
|
3800
|
-
if (current === undefined || current.groupValue !== v) {
|
|
3801
|
-
const title = r['_groupTitle'];
|
|
3802
|
-
const description = r['_groupDescription'];
|
|
3803
|
-
current = { groupValue: v, indices: [], ...(title ? { title } : {}), ...(description ? { description } : {}) };
|
|
3804
|
-
sections.push(current);
|
|
3805
|
-
}
|
|
3806
|
-
current.indices.push(i);
|
|
3807
|
-
}
|
|
3808
|
-
}
|
|
3809
|
-
return (_jsx("div", { className: "flex flex-col gap-4", children: sections.map((section, si) => {
|
|
3810
|
-
const collapsed = groupCollapsible
|
|
3811
|
-
&& section.groupValue !== undefined
|
|
3812
|
-
&& collapsedGroups[section.groupValue] === true;
|
|
3813
|
-
return (_jsxs("div", { className: "flex flex-col gap-3", children: [section.groupValue !== undefined && (() => {
|
|
3814
|
-
const drillable = groupHeadingScopable === true
|
|
3815
|
-
&& buildGroupKeyHref !== undefined
|
|
3816
|
-
&& section.groupValue !== '';
|
|
3817
|
-
const headingText = (_jsx(GroupHeaderText, { label: groupColumnLabel, value: section.groupValue, title: section.title, description: section.description }));
|
|
3818
|
-
const headingNode = drillable
|
|
3819
|
-
? _jsx(GroupHeadingLink, { href: buildGroupKeyHref(section.groupValue), navigate: navigate, children: headingText })
|
|
3820
|
-
: headingText;
|
|
3821
|
-
if (groupCollapsible) {
|
|
3822
|
-
return (_jsxs("div", { className: "flex w-full items-center gap-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: [_jsx("button", { type: "button", className: "inline-flex items-center", onClick: () => toggleGroupCollapsed(section.groupValue), "aria-expanded": !collapsed, "aria-label": collapsed ? 'Expand group' : 'Collapse group', children: _jsx(ChevronDownIcon, { className: [
|
|
3823
|
-
'size-4 transition-transform',
|
|
3824
|
-
collapsed ? '-rotate-90' : '',
|
|
3825
|
-
].filter(Boolean).join(' ') }) }), headingNode] }));
|
|
3826
|
-
}
|
|
3827
|
-
return (_jsx("div", { className: "text-xs font-semibold uppercase tracking-wider text-muted-foreground", children: headingNode }));
|
|
3828
|
-
})(), !collapsed && (_jsx("div", { className: gridClass, children: section.indices.map((ri) => {
|
|
3829
|
-
const id = visibleIds[ri];
|
|
3830
|
-
const recordObj = rows[ri];
|
|
3831
|
-
const isSelected = selected.has(id);
|
|
3832
|
-
const recordUrl = hasRecordUrl ? recordObj['_recordUrl'] : undefined;
|
|
3833
|
-
const customRowClasses = hasRecordClasses
|
|
3834
|
-
? recordObj['_recordClasses'] ?? ''
|
|
3835
|
-
: '';
|
|
3836
|
-
const cardChildren = recordObj['_cardChildren'] ?? [];
|
|
3837
|
-
const cardClassName = [
|
|
3838
|
-
'group relative flex flex-col gap-3 rounded-xl border bg-card p-4 transition-colors',
|
|
3839
|
-
recordUrl ? 'hover:border-primary/40 hover:bg-accent/30' : '',
|
|
3840
|
-
isSelected ? 'border-primary ring-2 ring-primary/20' : '',
|
|
3841
|
-
customRowClasses,
|
|
3842
|
-
].filter(Boolean).join(' ');
|
|
3843
|
-
const onLinkClick = (e) => {
|
|
3844
|
-
if (e.button !== 0)
|
|
3845
|
-
return;
|
|
3846
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)
|
|
3847
|
-
return;
|
|
3848
|
-
e.preventDefault();
|
|
3849
|
-
if (recordUrl)
|
|
3850
|
-
void navigate(recordUrl);
|
|
3851
|
-
};
|
|
3852
|
-
return (_jsxs("div", { className: cardClassName, children: [recordUrl !== undefined && (_jsx("a", { href: recordUrl, onClick: onLinkClick, "aria-label": "Open record", className: "absolute inset-0 z-0 rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: _jsx("span", { className: "sr-only", children: "Open record" }) })), hasBulkActions && (_jsx("div", { className: "absolute top-3 right-3 z-10", children: _jsx(Checkbox, { "aria-label": `Select row ${id}`, checked: isSelected, onCheckedChange: () => toggleRow(id), "data-no-row-nav": true }) })), _jsx("div", { className: "relative z-[1] flex flex-col gap-3", children: cardChildren.length === 0 ? (_jsx("div", { className: "text-xs italic text-muted-foreground", children: "No card content configured." })) : cardChildren.map((c, i) => renderElement(c, i)) }), hasRowActions && (_jsx("div", { className: "relative z-10 mt-auto flex items-center justify-end pt-2 border-t border-border/60", children: renderRowActions(id, recordObj, rowActions) }))] }, id));
|
|
3853
|
-
}) }))] }, si));
|
|
3854
|
-
}) }));
|
|
278
|
+
return _jsx(Icon, { className: "size-4 inline", "aria-hidden": "true" });
|
|
3855
279
|
}
|
|
3856
280
|
export function SchemaRenderer({ elements, widgetData }) {
|
|
3857
281
|
if (!elements || elements.length === 0)
|