@openmrs/esm-styleguide
Version:
The styleguide for OpenMRS SPA
616 lines (576 loc) • 24.6 kB
text/typescript
import { type Context, useContext } from 'react';
import { SingleSpaContext } from 'single-spa-react';
import { v4 as uuidV4 } from 'uuid';
import {
getGroupByWindowName,
getOpenedWindowIndexByWorkspace,
getWindowByWorkspaceName,
type OpenedWindow,
type OpenedWorkspace,
workspace2Store,
type WorkspaceStoreState2,
} from '@openmrs/esm-extensions';
import { useStoreWithActions, type Actions } from '@openmrs/esm-react-utils';
import { shallowEqual } from '@openmrs/esm-utils';
import { showModal } from '../modals';
import { type Workspace2DefinitionProps } from './workspace2.component';
/**
* Attempts to launch the specified workspace group with the given group props. Note that only one workspace group
* may be opened at any given time. If a workspace group is already opened, calling `launchWorkspaceGroup2` with
* either a different group name, or same group name but different incompatible props**, will result in prompting to
* confirm closing workspaces. If the user confirms, the opened group, along with its windows (and their workspaces), is closed, and
* the requested group is immediately opened.
*
* ** 2 sets of props are compatible if either one is nullish, or if they are shallow equal.
* @experimental
* @param groupName
* @param groupProps
* @returns a Promise that resolves to true if the specified workspace group with the specified group props
* is successfully opened, or that it already is opened.
*/
export async function launchWorkspaceGroup2<GroupProps extends object>(
groupName: string,
groupProps: GroupProps | null,
): Promise<boolean> {
const { openedGroup } = workspace2Store.getState();
if (openedGroup) {
if (openedGroup.groupName !== groupName || !arePropsCompatible(openedGroup.props, groupProps)) {
const okToCloseWorkspaces = await promptForClosingWorkspaces({
reason: 'CLOSE_WORKSPACE_GROUP',
explicit: false,
});
if (!okToCloseWorkspaces) {
return false;
}
// else, proceed to open the new group with no openedWindows
} else {
// no-op, group with group props is already opened
return true;
}
}
workspace2Store.setState((state) => ({
...state,
openedGroup: {
groupName,
props: groupProps,
},
openedWindows: [],
}));
return true;
}
/**
* Closes the workspace group that is currently opened. Note that only one workspace group
* may be opened at any given time
* @experimental
* @param discardUnsavedChanges If true, then the workspace group is forced closed, with no prompt
* for confirmation for unsaved changes in any opened workspace. This should be used sparingly
* for clean-up purpose, ex: when exiting an app.
* @returns a Promise that resolves to true if there is no opened group to begin with or we successfully closed
* the opened group; false otherwise.
*/
export async function closeWorkspaceGroup2(discardUnsavedChanges?: boolean) {
const state = workspace2Store.getState();
const { openedGroup, openedWindows } = state;
if (openedGroup) {
if (openedWindows.length > 0) {
const okToCloseWorkspaces =
discardUnsavedChanges ||
(await promptForClosingWorkspaces({ reason: 'CLOSE_WORKSPACE_GROUP', explicit: true }));
if (!okToCloseWorkspaces) {
return false;
}
}
workspace2Store.setState((state) => ({
...state,
openedGroup: null,
openedWindows: [],
}));
return true;
}
// no openedGroup with begin with, return true
return true;
}
/**
* Attempts to launch the specified workspace with the given workspace props. This also implicitly opens
* the workspace window to which the workspace belongs (if it's not opened already),
* and the workspace group to which the window belongs (if it's not opened already).
*
* When calling `launchWorkspace2`, we need to also pass in the workspace props. While not required,
* we can also pass in the window props (shared by other workspaces in the window) and the group props
* (shared by all windows and their workspaces). Omitting the window props or the group props[^1] means the caller
* explicitly does not care what the current window props and group props are, and that they may be set
* by other actions (like calling `launchWorkspace2` on a different workspace with those props passed in)
* at a later time.
*
* If there is already an opened workspace group, and it's not the group the workspace belongs to
* or has incompatible[^2] group props, then we prompt the user to close the group (and its windows and their workspaces).
* On user confirm, the existing opened group is closed and the new workspace, along with its window and its group,
* is opened.
*
* If the window is already opened, but with incompatible window props, we prompt the user to close
* the window (and all its opened workspaces), and reopen the window with (only) the newly requested workspace.
*
* If the workspace is already opened, but with incompatible workspace props, we also prompt the user to close
* the **window** (and all its opened workspaces), and reopen the window with (only) the newly requested workspace.
* This is true regardless of whether the already opened workspace has any child workspaces.
*
* Note that calling this function *never* results in creating a child workspace in the affected window.
* To do so, we need to call `launchChildWorkspace` instead.
*
* [^1] Omitting window or group props is useful for workspaces that don't have ties to the window or group "context" (props).
* For example, in the patient chart, the visit notes / clinical forms / order basket action menu button all share
* a "group context" of the current visit. However, the "patient list" action menu button does not need to share that group
* context, so opening that workspace should not need to cause other workspaces / windows / groups to potentially close.
* The "patient search" workspace in the queues and ward apps is another example.
*
* [^2] 2 sets of props are compatible if either one is nullish, or if they are shallow equal.
* @experimental
*/
export async function launchWorkspace2<
WorkspaceProps extends object,
WindowProps extends object,
GroupProp extends object,
>(
workspaceName: string,
workspaceProps: WorkspaceProps | null = null,
windowProps: WindowProps | null = null,
groupProps: GroupProp | null = null,
): Promise<boolean> {
const storeState = workspace2Store.getState();
if (!storeState.registeredWorkspacesByName[workspaceName]) {
throw new Error(`Unable to launch workspace ${workspaceName}. Workspace is not registered`);
}
const windowDef = getWindowByWorkspaceName(workspaceName);
if (!windowDef) {
throw new Error(`Unable to launch workspace ${workspaceName}. Workspace is not registered to a workspace window`);
}
const { openedGroup } = storeState;
const { name: windowName } = windowDef;
const groupDef = getGroupByWindowName(windowName);
if (!groupDef) {
throw new Error(
`Unable to launch workspace ${workspaceName}. Workspace window ${windowDef.name} is not registered to a workspace group`,
);
}
const openedWindowIndex = getOpenedWindowIndexByWorkspace(workspaceName);
const isWindowAlreadyOpened = openedWindowIndex >= 0;
// if current opened group is not the same as the requested group, or if the group props are different, then prompt for unsaved changes
if (openedGroup && (openedGroup.groupName !== groupDef.name || !arePropsCompatible(openedGroup.props, groupProps))) {
const okToCloseWorkspaces = await promptForClosingWorkspaces({ reason: 'CLOSE_WORKSPACE_GROUP', explicit: true });
if (okToCloseWorkspaces) {
workspace2Store.setState({
...storeState,
openedGroup: {
groupName: groupDef.name,
props: groupProps,
},
openedWindows: [
// discard all opened windows, open a new one with the requested workspace
// most recently opened action appended to the end
{
windowName: windowName,
openedWorkspaces: [newOpenedWorkspace(workspaceName, workspaceProps)], // root workspace at index 0
props: windowProps,
maximized: false,
},
],
isMostRecentlyOpenedWindowHidden: false,
});
return true;
} else {
return false;
}
} else if (isWindowAlreadyOpened) {
const openedWindow = storeState.openedWindows[openedWindowIndex];
const groupProps = storeState.openedGroup?.props ?? {};
const isMostRecentlyOpenedWindowHidden = storeState.isMostRecentlyOpenedWindowHidden;
const { openedWorkspaces } = openedWindow;
if (arePropsCompatible(openedWindow.props, windowProps)) {
// this case is tricky, this results in restoring the window if:
// 1. the workspace is opened (but not necessarily as a leaf workspace)
// 2. the props of the opened workspace is same as workspace props (from the function input)
//
// Otherwise, we close all workspaces in this window, and open this newly requested one
const openedWorkspace = openedWorkspaces.find((w) => w.workspaceName === workspaceName);
if (openedWorkspace && arePropsCompatible(openedWorkspace.props, workspaceProps)) {
// restore the window if it is hidden or not the most recently opened one
if (isMostRecentlyOpenedWindowHidden || openedWindowIndex !== storeState.openedWindows.length - 1) {
workspace2Store.setState(workspace2StoreActions.restoreWindow(storeState, windowName));
}
return true;
} else {
const okToCloseWorkspaces = await promptForClosingWorkspaces({
reason: 'CLOSE_WORKSPACE',
explicit: false,
windowName,
workspaceName,
});
if (okToCloseWorkspaces) {
workspace2Store.setState({
...storeState,
openedGroup: {
groupName: groupDef.name,
props: storeState?.openedGroup?.props ?? groupProps,
},
openedWindows: [
...storeState.openedWindows.filter((_, i) => i !== openedWindowIndex),
// most recently opened workspace at the end of the array
{
windowName: windowName,
openedWorkspaces: [newOpenedWorkspace(workspaceName, workspaceProps)],
props: openedWindow?.props ?? windowProps,
maximized: false,
},
],
isMostRecentlyOpenedWindowHidden: false,
});
return true;
} else {
return false;
}
}
} else {
const okToCloseWorkspaces = await promptForClosingWorkspaces({
reason: 'CLOSE_WINDOW',
explicit: false,
windowName,
});
if (okToCloseWorkspaces) {
// discard the openedWindows element at openedWindowIndex
// and create a new one with the requested workspace opened
workspace2Store.setState({
...storeState,
openedGroup: {
groupName: groupDef.name,
props: groupProps ?? storeState?.openedGroup?.props,
},
openedWindows: [
...storeState.openedWindows.filter((_, i) => i !== openedWindowIndex),
// most recently opened workspace at the end of the array
{
windowName: windowName,
openedWorkspaces: [newOpenedWorkspace(workspaceName, workspaceProps)],
props: windowProps,
maximized: false,
},
],
isMostRecentlyOpenedWindowHidden: false,
});
return true;
} else {
return false;
}
}
} else if (groupDef.persistence == 'closable') {
const okToCloseWorkspaces = await promptForClosingWorkspaces({
reason: 'CLOSE_OTHER_WINDOWS',
explicit: false,
windowNameToSpare: windowDef.name,
});
if (okToCloseWorkspaces) {
workspace2Store.setState({
...storeState,
openedGroup: {
groupName: groupDef.name,
props: groupProps ?? storeState?.openedGroup?.props ?? null,
},
openedWindows: [
{
windowName: windowName,
openedWorkspaces: [newOpenedWorkspace(workspaceName, workspaceProps)], // root workspace at index 0
props: windowProps,
maximized: false,
},
],
isMostRecentlyOpenedWindowHidden: false,
});
return true;
} else {
return false;
}
} else {
workspace2Store.setState({
...storeState,
openedGroup: {
groupName: groupDef.name,
props: groupProps ?? storeState?.openedGroup?.props ?? null,
},
openedWindows: [
...storeState.openedWindows,
// most recently opened workspace at the end of the array
{
windowName: windowName,
openedWorkspaces: [newOpenedWorkspace(workspaceName, workspaceProps)], // root workspace at index 0
props: windowProps,
maximized: false,
},
],
isMostRecentlyOpenedWindowHidden: false,
});
return true;
}
}
/**
* When we launch a workspace, we may pass in workspace / windows / group props. If the workspace or
* window or group is currently opened, we have to check whether the current props are compatible with
* the passed in props. 2 props A and B are compatible if:
* either one is nullish (because this indicates that the caller does not care about prop incompatibility)
* neither is nullish, and A and B are shallow equal.
* @param a props
* @param b props
* @returns whether props a and b are compatible
*/
function arePropsCompatible(a: Record<string, any> | null, b: Record<string, any> | null) {
if (a == null || b == null) {
return true;
}
return shallowEqual(a, b);
}
type PromptReason =
| { reason: 'CLOSE_WORKSPACE_GROUP'; explicit: boolean }
| { reason: 'CLOSE_WINDOW'; explicit: boolean; windowName: string }
| { reason: 'CLOSE_WORKSPACE'; explicit: boolean; windowName: string; workspaceName: string }
| { reason: 'CLOSE_OTHER_WINDOWS'; explicit: false; windowNameToSpare: string };
/**
* A user can perform actions that explicitly result in closing workspaces
* (such that clicking the 'X' button for the workspace or workspace group), or
* implicitly (by opening a workspace with different props than the one that is already opened).
* Calls to closeWorkspace2() or closeWorkspaceGroup2() are considered explicit, while calls
* to launchWorkspace2() or launchWorkspaceGroup2() are considered implicit.
*
* This function prompts the user for confirmation to close workspaces with a modal dialog.
* When the closing is explicit, it prompts for confirmation for affected workspaces with unsaved changes.
* When the closing is implicit, it prompts for confirmation for all affected workspaces, regardless of
* whether they have unsaved changes.
* @experimental
* @param promptReason
* @returns a Promise that resolves to true if the user confirmed closing the workspaces; false otherwise.
*/
export function promptForClosingWorkspaces(promptReason: PromptReason): Promise<boolean> {
// if onlyUpToThisWorkspace is provided, we will only loop till we hit that workspace
function getAffectedWorkspacesInWindow(openedWindow: OpenedWindow, onlyUpToThisWorkspace?: string) {
const ret: Array<OpenedWorkspace> = [];
for (let i = openedWindow.openedWorkspaces.length - 1; i >= 0; i--) {
const openedWorkspace = openedWindow.openedWorkspaces[i];
if (openedWorkspace.hasUnsavedChanges) {
ret.push(openedWorkspace);
}
if (onlyUpToThisWorkspace && openedWorkspace.workspaceName === onlyUpToThisWorkspace) {
break;
}
}
return ret;
}
const { openedWindows, workspaceTitleByWorkspaceName } = workspace2Store.getState();
let affectedWorkspaces: OpenedWorkspace[] = [];
switch (promptReason.reason) {
case 'CLOSE_WORKSPACE_GROUP': {
affectedWorkspaces = openedWindows.flatMap((window) => getAffectedWorkspacesInWindow(window));
break;
}
case 'CLOSE_WINDOW': {
const openedWindow = openedWindows.find((window) => window.windowName === promptReason.windowName);
if (!openedWindow) {
throw new Error(`Window ${promptReason.windowName} not found in opened windows.`);
}
affectedWorkspaces = getAffectedWorkspacesInWindow(openedWindow);
break;
}
case 'CLOSE_WORKSPACE': {
const openedWindow = openedWindows.find((window) => window.windowName === promptReason.windowName);
if (!openedWindow) {
throw new Error(`Window ${promptReason.windowName} not found in opened windows.`);
}
affectedWorkspaces = getAffectedWorkspacesInWindow(openedWindow, promptReason.workspaceName);
break;
}
case 'CLOSE_OTHER_WINDOWS': {
const windowsToClose = openedWindows.filter((window) => window.windowName !== promptReason.windowNameToSpare);
affectedWorkspaces = windowsToClose.flatMap((w) => getAffectedWorkspacesInWindow(w));
break;
}
}
if (affectedWorkspaces.length === 0) {
return Promise.resolve(true); // no unsaved changes, no need to prompt
}
return new Promise((resolve) => {
const dispose = showModal('workspace2-close-prompt', {
onConfirm: () => {
dispose();
resolve(true);
},
onCancel: () => {
dispose();
resolve(false);
},
affectedWorkspaceTitles: affectedWorkspaces.map(
(workspace) => workspaceTitleByWorkspaceName[workspace.workspaceName],
),
});
});
}
export const workspace2StoreActions = {
setWindowMaximized(state: WorkspaceStoreState2, windowName: string, maximized: boolean) {
const openedWindowIndex = state.openedWindows.findIndex((a) => a.windowName === windowName);
const openedWindows = [...state.openedWindows];
const currentWindow = { ...openedWindows[openedWindowIndex], maximized };
openedWindows[openedWindowIndex] = currentWindow;
return {
...state,
openedWindows,
};
},
// hides the most recently opened window (all other opened windows are implicitly hidden)
hideWindow(state: WorkspaceStoreState2) {
return {
...state,
isMostRecentlyOpenedWindowHidden: true,
};
},
restoreWindow(state: WorkspaceStoreState2, windowName: string) {
const openedWindowIndex = state.openedWindows.findIndex((a) => a.windowName === windowName);
const currentWindow = state.openedWindows[openedWindowIndex];
const openedWindows = [...state.openedWindows.filter((_, i) => i !== openedWindowIndex), currentWindow];
return {
...state,
openedWindows,
isMostRecentlyOpenedWindowHidden: false,
};
},
closeWorkspace(state, workspaceName: string) {
const openedWindowIndex = getOpenedWindowIndexByWorkspace(workspaceName);
if (openedWindowIndex < 0) {
return state; // no-op if the window does not exist
}
const window = { ...state.openedWindows[openedWindowIndex] };
const workspaceIndex = window.openedWorkspaces.findIndex((w) => w.workspaceName === workspaceName);
const openedWindows = [...state.openedWindows];
// close all children of the input workspace as well
window.openedWorkspaces = window.openedWorkspaces.slice(0, workspaceIndex);
let hidden = state.isMostRecentlyOpenedWindowHidden;
if (window.openedWorkspaces.length === 0) {
const wasMostRecentWindow = openedWindowIndex === state.openedWindows.length - 1;
// if no workspaces left, remove the window
openedWindows.splice(openedWindowIndex, 1);
// If we removed the most recent window and there are still windows left,
// the new most recent window should be shown
if (wasMostRecentWindow && openedWindows.length > 0) {
hidden = false;
}
} else {
// if there are still workspaces left, just update the window
openedWindows[openedWindowIndex] = window;
}
return {
...state,
openedWindows,
isMostRecentlyOpenedWindowHidden: hidden,
};
},
openChildWorkspace(
state,
parentWorkspaceName: string,
childWorkspaceName: string,
childWorkspaceProps: Record<string, any>,
) {
const childWorkspaceDef = state.registeredWorkspacesByName[childWorkspaceName];
if (!childWorkspaceDef) {
throw new Error(`No workspace named "${childWorkspaceName}" registered`);
}
const parentWorkspaceDef = state.registeredWorkspacesByName[parentWorkspaceName];
if (!parentWorkspaceDef) {
throw new Error(`No workspace named "${parentWorkspaceName}" registered`);
}
if (parentWorkspaceDef.window !== childWorkspaceDef.window) {
throw new Error(
`Child workspace ${childWorkspaceName} does not belong to the same workspace window as parent workspace ${parentWorkspaceName}`,
);
}
// as the request workspace should be a child workspace, the corresponding window
// to contain the workspace should already be opened
const openedWindowIndex = state.openedWindows.findIndex((window) => window.windowName === childWorkspaceDef.window);
if (openedWindowIndex == -1) {
throw new Error(
`Cannot open child workspace ${childWorkspaceName} as window ${childWorkspaceDef.window} is not opened`,
);
}
const openedWindow = state.openedWindows[openedWindowIndex];
const { openedWorkspaces } = openedWindow;
const parentIndex = openedWorkspaces.findIndex((w) => w.workspaceName === parentWorkspaceName);
if (parentIndex === -1) {
throw new Error(
`Cannot open child workspace ${childWorkspaceName} from parent workspace ${parentWorkspaceName} as the parent is not opened within the workspace window`,
);
}
// Close any workspaces above the parent (analogous to closeWorkspace's slice behavior)
const trimmedWorkspaces = openedWorkspaces.slice(0, parentIndex + 1);
return {
openedWindows: state.openedWindows.map((w, i) => {
if (i == openedWindowIndex) {
return {
...w,
openedWorkspaces: [...trimmedWorkspaces, newOpenedWorkspace(childWorkspaceName, childWorkspaceProps)],
};
} else {
return w;
}
}),
};
},
setHasUnsavedChanges(state: WorkspaceStoreState2, workspaceName: string, hasUnsavedChanges: boolean) {
const openedWindowIndex = getOpenedWindowIndexByWorkspace(workspaceName);
if (openedWindowIndex < 0) {
return state; // no-op if the window does not exist
}
const openedWindow = { ...state.openedWindows[openedWindowIndex] };
const workspaceIndex = openedWindow.openedWorkspaces.findIndex((w) => w.workspaceName === workspaceName);
if (workspaceIndex < 0) {
return state; // no-op if the workspace is not found
}
openedWindow.openedWorkspaces[workspaceIndex] = {
...openedWindow.openedWorkspaces[workspaceIndex],
hasUnsavedChanges,
};
const openedWindows = [...state.openedWindows];
openedWindows[openedWindowIndex] = openedWindow;
return {
...state,
openedWindows,
};
},
setWorkspaceTitle(state: WorkspaceStoreState2, workspaceName: string, title: string | null) {
const newWorkspaceTitleByWorkspaceName = { ...state.workspaceTitleByWorkspaceName };
if (title === null) {
delete newWorkspaceTitleByWorkspaceName[workspaceName];
} else {
newWorkspaceTitleByWorkspaceName[workspaceName] = title;
}
return {
...state,
workspaceTitleByWorkspaceName: newWorkspaceTitleByWorkspaceName,
};
},
} satisfies Actions<WorkspaceStoreState2>;
export function useWorkspace2Store() {
return useStoreWithActions(workspace2Store, workspace2StoreActions);
}
/**
* Returns the react Context containing props passed into a workspace.
* This hook MUST be called inside a child of <Workspace2>
*/
export const useWorkspace2Context = () =>
useContext<Workspace2DefinitionProps>(SingleSpaContext as unknown as Context<Workspace2DefinitionProps>);
/**
* @returns a list of registered workspaces.
*/
export const getRegisteredWorkspace2Names = () => {
return Object.keys(workspace2Store.getState().registeredWorkspacesByName);
};
function newOpenedWorkspace(workspaceName: string, workspaceProps: Record<string, any> | null): OpenedWorkspace {
return {
workspaceName,
props: workspaceProps ?? {},
hasUnsavedChanges: false,
uuid: uuidV4(),
};
}