UNPKG

@openmrs/esm-styleguide

Version:
616 lines (576 loc) • 24.6 kB
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(), }; }