Miniapp Modal Override
Three-layer modal override: hardcoded workflow default to admin config to runtime resolution in the action handler.
How the medical-worklist resolves which miniapp renders inside the popup modal when a row action fires, and how admins override the default.
Three-Layer Resolution
Layer 1: Hardcoded default (workflow JSON)
↓ overridden by
Layer 2: Admin config (Supabase workflow_action_configs)
↓ overridden by
Layer 3: Action-level miniappId (footerConfig.miniappId)
Layer 1 — Default workflow JSON
Each workflow JSON in packages/medical-kit/src/medical-worklist/defaults/ defines rowActions per node with a modalId:
{
"nodes": [{
"id": "doctor-in-process",
"data": {
"queueConfig": {
"rowActions": [{
"id": "discharge",
"actionType": "modal",
"modalId": "DIALOG_DISCHARGE_PATIENT"
}]
}
}
}]
}
The modalId maps to a component in CORE_MODAL_COMPONENTS (see modalRegistry.ts). No miniapp override at this layer.
Layer 2 — Admin worklist config (database)
At /admin/worklist-config, admins configure per-node:
miniappSelectionConfigon the node’squeueConfig— a pool of miniapps with a default:
queueConfig: {
miniappSelectionConfig: {
enabled: boolean;
availableMiniapps: MiniappConfig[];
defaultMiniapp: string; // DynamicCoreApp enum key
selectionRequired: boolean;
heightAllocation: { top: number; bottom: number };
}
}
footerConfig.miniappIdon individual actions — overrides the modal body with a specific miniapp.
Saved via workflowActionConfigService.saveNodeConfiguration() → Supabase RPC save_workflow_config.
Layer 3 — Runtime resolution
workflow-config-merger.ts merges the three config levels at load time:
Base node config (JSON)
→ Department config (DB) — overwrites
→ Role config (Admin Panel) — overwrites
The merged miniappSelectionConfig lands on node.data.queueConfig.miniappSelectionConfig.
Data Flow at Runtime
WorkflowBasedTabs
reads node.data.queueConfig.miniappSelectionConfig
passes as commonProps.miniappSelectionConfig
↓
OrderRequestTable / WorklistTabTable
forwards to <WorkflowActionButtons miniappSelectionConfig={...} />
↓
WorkflowActionButtons
builds WorkflowActionContext { miniappSelectionConfig }
calls handleWorkflowAction(context, dispatch)
↓
workflowActionHandler.ts :: handleModalAction()
resolves miniapp override:
1. action.footerConfig.miniappId (action-level, highest priority)
2. context.miniappSelectionConfig (node-level, from admin config)
if either is set → dispatches with useSplitLayout: true + miniappSelectionConfig
↓
UniversalTransitionModal
detects useSplitLayout && miniappSelectionConfig.enabled
delegates to <SplitLayoutModal>
↓
SplitLayoutModal
renders <MiniappSelector> (dropdown of available miniapps)
renders <DynamicContentRenderer tabName={selectedMiniapp}> (the miniapp body)
renders transition footer (confirm/cancel buttons)
Key Files
| File | Role |
|---|---|
web/src/setup/dynamic/central-table/handlers/workflowActionHandler.ts |
Resolves miniapp override, dispatches modal with useSplitLayout |
web/src/setup/dynamic/central-table/components/WorkflowActionButtons.tsx |
Passes miniappSelectionConfig into WorkflowActionContext |
web/src/common/components/medical/worklist/components/WorkflowBasedTabs.tsx |
Reads node config, forwards miniappSelectionConfig as prop |
web/packages/medical-kit/src/central-worklist/components/OrderRequestTable.tsx |
Forwards miniappSelectionConfig to WorkflowActionButtons |
web/packages/medical-kit/src/central-worklist/components/WorklistTabTable.tsx |
Forwards miniappSelectionConfig to WorkflowActionButtons |
web/src/common/components/medical/builder/modal/workflow-modals/UniversalTransitionModal.tsx |
Split layout branch → SplitLayoutModal |
web/src/common/components/medical/builder/worklist/SplitLayoutModal.tsx |
Renders miniapp selector + DynamicContentRenderer + footer |
web/src/containers/admin/workflow-action-config/WorkflowActionConfigManager.tsx |
Admin UI for configuring miniapps per node |
web/src/utils/workflow-config-merger.ts |
Merges base + DB + role configs at load time |
web/src/services/workflow-action-config.service.ts |
CRUD for saved configs (Supabase) |
web/src/common/components/medical/builder/modal/registry/modalRegistry.ts |
Core + dynamic modal registry |
Adding a New Miniapp Override
- Ensure your miniapp is registered in
DynamicCoreAppenum (web/src/setup/dynamic/DynamicCoreApp.ts) - Go to
/admin/worklist-config→ select department → select workflow node - Either:
- Node-level: Enable miniapp selection config, add your miniapp to
availableMiniapps, set asdefaultMiniapp - Action-level: Edit the action button, set
footerConfig.miniappIdto yourDynamicCoreAppkey
- Node-level: Enable miniapp selection config, add your miniapp to
- Save — config persists to
workflow_action_configstable - Next time a user clicks that row action, the modal opens in split layout with your miniapp rendered via
DynamicContentRenderer
Priority Rules
| Priority | Source | Field | Effect |
|---|---|---|---|
| Highest | Action config | footerConfig.miniappId |
Single miniapp, no selector dropdown |
| Medium | Node config | queueConfig.miniappSelectionConfig |
Pool of miniapps with selector dropdown |
| Lowest | Workflow JSON | modalId in CORE_MODAL_COMPONENTS |
Standard registered modal, no split layout |
When miniappId is set on the action, it creates a synthetic miniappSelectionConfig with just that one miniapp and skips the selector. When the node-level config is used, the full MiniappSelector dropdown renders so the user can switch between available miniapps.
ui.componentKey (flow editor) vs modalId (runtime) — and the one place you manage modals
A main-flow-editor node carries ui.componentKey (e.g. 'DialogVisitRequest'). This string is descriptive metadata shown in the node panel — it is NOT what mounts a dialog at runtime. Nothing reads ui.componentKey to render a component.
The runtime modal resolver is a single map: CORE_MODAL_COMPONENTS in modalRegistry.ts. GlobalModalRenderer calls getModalComponent(modalId) against it. modalId is the key that matters. (Convention is DIALOG_*, but camelCase keys like DialogCreateVisit / IpdNursingAssessment / DialogVisitRequest are equally valid.)
To make a dialog mountable (the manage point)
- Register it — add one line to
CORE_MODAL_COMPONENTS:'YourKey': YourComponent,(+ the import). That’s the whole “wiring”. - (If it should open from any tab/action) add a wildcard
flowConfiginconfigureModalFlows():{ requiresPatient: false, supportedActions: ['*'], supportedTabs: ['*'] }. OtherwiseshouldShowModalmay block it. (Modals in the bypass list skip this check entirely.) - Props your component receives from
GlobalModalRenderer:onClose,isOpen,patient/patientData/encounter,modalId,onSetFooterActions. Accept those (don’t assumehandleClose/handleSubmit).
To trigger it (open it for a user)
Any of:
- Workflow JSON / admin — a row action
{ actionType: 'modal', modalId: 'YourKey' }(hardcoded in adefaults/*.json, or set at/admin/worklist-config).workflowActionHandler.handleModalActiondispatchesopenModal({ modalId, data }). - Programmatically —
dispatch(openModal({ modalId: 'YourKey', data: { patient, encounter } }))from anywhere.
Tip: name the registry key the same string as the node’s ui.componentKey so they line up (we register 'DialogVisitRequest' for exactly this reason). DialogVisitRequest also persists to POST /v2/administration/visitRequests itself when no handleSubmit override is passed (i.e. when mounted via the resolver).