Migrating to Workspace v2
Workspace v2 is a redesigned workspace system that introduces a hierarchical model of groups, windows, and workspaces. It replaces the flat workspace model from v1 with a structured approach that provides better support for child workspaces, proper unsaved changes tracking, and scoped workspace lifecycle management.
This guide covers how to migrate your frontend module’s workspaces from the v1 API to v2.
Background
The v1 workspace system had several limitations:
- Flat structure — all workspaces shared a single container, making it difficult to manage complex multi-step flows.
- Manual unsaved changes tracking — workspaces had to call
promptBeforeClosing()imperatively. - No child workspace support — multi-step flows (e.g. order basket → drug search) required ad-hoc workarounds.
- Title management — workspaces had to call
setTitle()to update their title.
Workspace v2 addresses these with a three-level hierarchy:
- Workspace Group — a top-level container that owns the action menu (siderail). Groups define scope, overlay behavior, and persistence.
- Workspace Window — a panel within a group that can be maximized, minimized, or hidden. Each window belongs to a group.
- Workspace — the actual content rendered inside a window. Workspaces can launch child workspaces within the same window.
Key concepts
Before migrating, it helps to understand how the three levels relate:
- A workspace group is declared in
workspaceGroups2. It can be opened explicitly withlaunchWorkspaceGroup2, or implicitly whenlaunchWorkspace2opens a workspace that belongs to the group. If the group has windows with icons, it renders the action menu (siderail) while the group is open. Only one group can be open at a time. - A workspace window is declared in
workspaceWindows2and belongs to a group. A window with aniconcorresponds to an action menu button. The icon is registered as an extension whose extension ID must match the window name. This is howActionMenuButton2knows which window it controls. - A workspace is declared in
workspaces2and belongs to a window. Workspaces are the actual content panels. A window can contain a stack of workspaces (parent + children).
Group props provide shared context (e.g. current patient, visit) to all workspaces in the group. Window props and workspace props are scoped to their respective levels. When any of these props change in an incompatible way, the system prompts the user to close workspaces with unsaved changes.
Migration steps
Update routes.json
Replace the workspaces section in your routes.json with the new workspaces2, workspaceWindows2, and workspaceGroups2 sections.
Before (v1):
{
"workspaces": [
{
"name": "my-form-workspace",
"component": "myFormWorkspace",
"title": "myFormTitle",
"type": "form"
}
]
}After (v2):
{
"workspaces2": [
{
"name": "my-form-workspace",
"component": "myFormWorkspace",
"window": "my-form-window"
}
],
"workspaceWindows2": [
{
"name": "my-form-window",
"group": "my-workspace-group"
}
],
"workspaceGroups2": [
{
"name": "my-workspace-group"
}
]
}Key differences:
- The
titleandtypeproperties are removed from workspace definitions. Titles are now set by the<Workspace2>wrapper component. - Each workspace must specify a
windowit belongs to. - Each window must specify a
groupit belongs to. - Groups can optionally configure
overlay,persistence, andscopePatternproperties.
If your app’s workspaces belong to a group owned by another app (e.g. the patient-chart group), you only need to declare workspaces2 and workspaceWindows2. You do not need to declare workspaceGroups2 — that’s the responsibility of the app that owns the group.
Workspace window options
Windows support the following optional properties:
| Property | Type | Description |
|---|---|---|
canMaximize | boolean | Whether the window can be maximized |
group | string | Required. The group this window belongs to |
icon | string | Exported action-menu icon component for this window |
order | number | Display order in the action menu |
width | "narrow" | "wider" | "extra-wide" | Width of the workspace panel |
Workspace group options
Groups support the following optional properties:
| Property | Type | Description |
|---|---|---|
overlay | boolean | Whether workspaces render as overlays |
persistence | "app-wide" | "closable" | Lifecycle persistence mode. Use "closable" to render the group action menu with a close button. |
scopePattern | string | URL pattern defining where workspaces persist |
If persistence is omitted, the group behaves like an app-wide group: it can keep multiple windows open and closes only on app changes or scopePattern changes. Use "closable" for focused workflows where the action menu should include a close button. In a closable group, launching a different window checks affected workspaces for unsaved changes and then closes the other windows in that group.
Initialize the workspace group only when needed
Declaring a group in workspaceGroups2 does not automatically mean you must call launchWorkspaceGroup2. A normal launchWorkspace2 call opens the workspace’s group, target window, and workspace if they are not already open.
Call launchWorkspaceGroup2 from the owning app when the group needs to be active before the first workspace launch, or when the group needs shared context props before action-menu buttons and workspaces render. Patient-chart does this so patient and visit context are available to all workspaces in the patient-chart group.
import { launchWorkspaceGroup2 } from "@openmrs/esm-framework";
// In your app's root component:
useEffect(() => {
launchWorkspaceGroup2("patient-chart", {
patient,
patientUuid,
visitContext,
mutateVisitContext,
});
}, [patient, patientUuid, visitContext, mutateVisitContext]);Only one workspace group can be open at a time. If launchWorkspaceGroup2 is called with a different group name (or incompatible props), O3 checks the open workspaces for unsaved changes and prompts when needed before the new group is opened.
Most modules do not need this step. Modules that add workspaces to an existing group, such as patient-chart, should skip group initialization entirely. Modules that own a simple group can also rely on launchWorkspace2 unless the group needs app-wide context or an action menu before a workspace is opened.
Update workspace component signatures
Workspace components receive different props in v2. The main change is that custom props are now nested under workspaceProps instead of being spread as top-level props, and shared context is available via groupProps.
Before (v1):
import { type DefaultWorkspaceProps } from "@openmrs/esm-framework";
interface MyFormProps extends DefaultWorkspaceProps {
patientUuid: string;
appointment?: Appointment;
}
const MyForm: React.FC<MyFormProps> = ({
patientUuid,
appointment,
closeWorkspace,
closeWorkspaceWithSavedChanges,
promptBeforeClosing,
setTitle,
}) => {
// ...
};After (v2):
import {
Workspace2,
type Workspace2DefinitionProps,
} from "@openmrs/esm-framework";
interface MyFormProps {
patientUuid: string;
appointment?: Appointment;
}
export default function MyForm({
workspaceProps,
groupProps,
closeWorkspace,
}: Workspace2DefinitionProps<MyFormProps>) {
const { patientUuid, appointment } = workspaceProps ?? {};
return (
<Workspace2 title={t("myForm", "My form")} hasUnsavedChanges={isDirty}>
{/* workspace content */}
</Workspace2>
);
}Key changes:
- Replace
DefaultWorkspacePropswithWorkspace2DefinitionProps<YourProps>. The generic takes up to three type params:<WorkspaceProps, WindowProps, GroupProps>. - Custom props are accessed via
workspacePropsinstead of being spread at the top level. Note thatworkspaceProps,windowProps, andgroupPropscan benull. - Shared context (e.g. current patient) is available via
groupPropsinstead of being passed to each workspace individually. promptBeforeClosingandsetTitleare removed. Instead, passhasUnsavedChangesandtitleas props to the<Workspace2>wrapper component.closeWorkspaceWithSavedChangesis replaced bycloseWorkspace({ discardUnsavedChanges: true }).closeWorkspacealso accepts{ closeWindow: true }to close the entire window (all workspaces in it), not just the current workspace.- Wrap your workspace content in
<Workspace2>, which renders the workspace header bar with the title, maximize/minimize, and close buttons.
Update workspace launch calls
Replace launchWorkspace with launchWorkspace2. The full signature is:
launchWorkspace2<WorkspaceProps, WindowProps, GroupProps>(
workspaceName: string,
workspaceProps?: WorkspaceProps | null,
windowProps?: WindowProps | null,
groupProps?: GroupProps | null,
): Promise<boolean>The workspaceTitle option is no longer accepted — titles are set by the workspace component itself.
Before (v1):
launchWorkspace("my-form-workspace", {
patientUuid,
appointment,
workspaceTitle: t("editAppointment", "Edit appointment"),
});After (v2):
launchWorkspace2("my-form-workspace", {
patientUuid,
appointment,
});When launching a workspace that needs window or group props, pass them as additional arguments:
launchWorkspace2(
"start-visit-workspace-form",
{ openedFrom: "patient-chart-start-visit" }, // workspace props
{}, // window props
{ patient, patientUuid: patient.id, visitContext: null, mutateVisitContext }, // group props
);Update action menu buttons
Replace ActionMenuButton with ActionMenuButton2. The new component replaces handler and type with a workspaceToLaunch object. It also handles toggling window visibility automatically — clicking the button will hide, restore, or launch the workspace depending on the current state.
Reference the button component from the owning window’s icon property in workspaceWindows2. The framework registers that icon as an extension whose ID is the window name.
Before (v1):
<ActionMenuButton
getIcon={(props) => <EditIcon {...props} />}
label={t("edit", "Edit")}
iconDescription={t("edit", "Edit")}
handler={() => launchWorkspace("my-form-workspace", { patientUuid })}
type="form"
/>After (v2):
<ActionMenuButton2
icon={(props) => <EditIcon {...props} />}
label={t("edit", "Edit")}
workspaceToLaunch={{
workspaceName: "my-form-workspace",
workspaceProps: { patientUuid },
}}
/>ActionMenuButton2 also supports an optional onBeforeWorkspaceLaunch callback that can prevent the workspace from opening (e.g. to prompt the user to start a visit first):
<ActionMenuButton2
icon={(props) => <DocumentIcon {...props} />}
label={t("clinicalForms", "Clinical forms")}
workspaceToLaunch={{
workspaceName: "clinical-forms-workspace",
workspaceProps: {},
}}
onBeforeWorkspaceLaunch={startVisitIfNeeded}
/>Update child workspace patterns
If your workspace launches sub-workspaces (e.g. an order basket that opens a drug search), use the launchChildWorkspace prop instead of calling launchWorkspace directly.
Before (v1):
const OrderBasket: React.FC<DefaultWorkspaceProps> = ({
closeWorkspace,
}) => {
const handleAddDrug = () => {
launchWorkspace("drug-search-workspace", { onSelect: handleDrugSelect });
};
// ...
};After (v2):
function OrderBasket({
closeWorkspace,
launchChildWorkspace,
}: Workspace2DefinitionProps<OrderBasketProps>) {
const handleAddDrug = () => {
launchChildWorkspace("drug-search-workspace", {
onSelect: handleDrugSelect,
});
};
// ...
}Child workspaces are rendered within the same window and are automatically closed when their parent workspace is closed.
Update tests
Tests need to provide the new prop structure when rendering workspace components.
Before (v1):
const defaultProps = {
patientUuid: mockPatient.id,
closeWorkspace: vi.fn(),
closeWorkspaceWithSavedChanges: vi.fn(),
promptBeforeClosing: vi.fn(),
setTitle: vi.fn(),
};
render(<MyForm {...defaultProps} />);After (v2):
const defaultProps: Workspace2DefinitionProps<MyFormProps> = {
workspaceProps: {
patientUuid: mockPatient.id,
},
closeWorkspace: vi.fn(),
launchChildWorkspace: vi.fn(),
groupProps: null,
windowProps: null,
workspaceName: "my-form-workspace",
windowName: "my-form-window",
isRootWorkspace: true,
showActionMenu: false,
};
render(<MyForm {...defaultProps} />);Migration examples
- Patient chart — the most comprehensive example, including child workspace patterns for clinical forms and order basket
- Appointments app
- Service queues app
- Ward app
- Bed management app
- Patient list management app
- Laboratory app