medOS ultra

Miniapp Modal Override

Three-layer modal override: hardcoded workflow default to admin config to runtime resolution in the action handler.

4 min read diagramsUpdated 2026-05-29docs/architecture/miniapp-modal-override.md

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:

  • miniappSelectionConfig on the node’s queueConfig — 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.miniappId on 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

  1. Ensure your miniapp is registered in DynamicCoreApp enum (web/src/setup/dynamic/DynamicCoreApp.ts)
  2. Go to /admin/worklist-config → select department → select workflow node
  3. Either:
    • Node-level: Enable miniapp selection config, add your miniapp to availableMiniapps, set as defaultMiniapp
    • Action-level: Edit the action button, set footerConfig.miniappId to your DynamicCoreApp key
  4. Save — config persists to workflow_action_configs table
  5. 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)

  1. Register it — add one line to CORE_MODAL_COMPONENTS: 'YourKey': YourComponent, (+ the import). That’s the whole “wiring”.
  2. (If it should open from any tab/action) add a wildcard flowConfig in configureModalFlows(): { requiresPatient: false, supportedActions: ['*'], supportedTabs: ['*'] }. Otherwise shouldShowModal may block it. (Modals in the bypass list skip this check entirely.)
  3. Props your component receives from GlobalModalRenderer: onClose, isOpen, patient/patientData/encounter, modalId, onSetFooterActions. Accept those (don’t assume handleClose/handleSubmit).

To trigger it (open it for a user)

Any of:

  • Workflow JSON / admin — a row action { actionType: 'modal', modalId: 'YourKey' } (hardcoded in a defaults/*.json, or set at /admin/worklist-config). workflowActionHandler.handleModalAction dispatches openModal({ modalId, data }).
  • Programmaticallydispatch(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).

Ask Anything