diff --git a/packages/framework/esm-extensions/src/index.ts b/packages/framework/esm-extensions/src/index.ts index 09ed05cee..d450a3519 100644 --- a/packages/framework/esm-extensions/src/index.ts +++ b/packages/framework/esm-extensions/src/index.ts @@ -3,6 +3,7 @@ export * from './helpers'; export * from './left-nav'; export * from './modals'; export * from './workspaces'; +export * from './workspaces2'; export * from './render'; export * from './store'; export * from './types'; diff --git a/packages/framework/esm-extensions/src/workspaces.ts b/packages/framework/esm-extensions/src/workspaces.ts index 68c947737..c198d00a9 100644 --- a/packages/framework/esm-extensions/src/workspaces.ts +++ b/packages/framework/esm-extensions/src/workspaces.ts @@ -152,7 +152,7 @@ export function getWorkspaceRegistration(name: string): WorkspaceRegistration { } /** - * This provides the workspace group registration and is also compatibile with the + * This provides the workspace group registration and is also compatible with the * old way of registering workspace groups (as extensions), but isn't recommended. * * @param name of the workspace diff --git a/packages/framework/esm-extensions/src/workspaces2.ts b/packages/framework/esm-extensions/src/workspaces2.ts new file mode 100644 index 000000000..85933dd1e --- /dev/null +++ b/packages/framework/esm-extensions/src/workspaces2.ts @@ -0,0 +1,149 @@ +import { + type WorkspaceDefinition2, + type WorkspaceGroupDefinition2, + type WorkspaceWindowDefinition2, +} from '@openmrs/esm-globals'; +import { createGlobalStore } from '@openmrs/esm-state'; + +export interface OpenedWorkspace { + workspaceName: string; + props: Record | null; + hasUnsavedChanges: boolean; + /** Unique identifier for the workspace instance, used to track unique instances of the same workspace */ + uuid: string; +} +export interface OpenedWindow { + windowName: string; + /** Root workspace at index 0, child workspaces follow */ + openedWorkspaces: Array; + props: Record | null; + maximized: boolean; + hidden: boolean; +} +export interface WorkspaceStoreState2 { + registeredGroupsByName: Record; + registeredWindowsByName: Record; + registeredWorkspacesByName: Record; + openedGroup: { + groupName: string; + props: Record | null; + } | null; + /** Most recently opened window at the end of array. Each element has a unique windowName */ + openedWindows: Array; + + workspaceTitleByWorkspaceName: Record; +} + +const initialState: WorkspaceStoreState2 = { + registeredGroupsByName: {}, + registeredWindowsByName: {}, + registeredWorkspacesByName: {}, + openedGroup: null, + openedWindows: [], + workspaceTitleByWorkspaceName: {}, +}; + +export const workspace2Store = createGlobalStore('workspace2', initialState); + +/** + * Given a workspace name, return the window that the workspace belongs to + * @param workspaceName + * @returns + */ +export function getWindowByWorkspaceName(workspaceName: string) { + const { registeredWorkspacesByName, registeredWindowsByName } = workspace2Store.getState(); + const workspace = registeredWorkspacesByName[workspaceName]; + if (!workspace) { + throw new Error(`No workspace found with name: ${workspaceName}`); + } else { + const workspaceWindow = registeredWindowsByName[workspace.window]; + if (!workspaceWindow) { + throw new Error(`No workspace window found with name: ${workspace.window} for workspace: ${workspaceName}`); + } else { + return workspaceWindow; + } + } +} + +/** + * Given a window name, return the group that the window belongs to + * @param windowName + * @returns + */ +export function getGroupByWindowName(windowName: string) { + const { registeredGroupsByName, registeredWindowsByName } = workspace2Store.getState(); + const workspaceWindow = registeredWindowsByName[windowName]; + if (!workspaceWindow) { + throw new Error(`No workspace window found with name: ${windowName}`); + } else { + const group = registeredGroupsByName[workspaceWindow.group]; + if (!group) { + throw new Error(`No workspace group found with name: ${workspaceWindow.group} for window: ${windowName}`); + } else { + return group; + } + } +} + +export function getOpenedWindowIndexByWorkspace(workspaceName: string) { + const { openedWindows } = workspace2Store.getState(); + return openedWindows.findIndex((a) => + a.openedWorkspaces.find((openedWorkspace) => openedWorkspace.workspaceName === workspaceName), + ); +} + +export function registerWorkspaceGroups2(workspaceGroupDefs: Array) { + if (workspaceGroupDefs.length == 0) { + return; + } + const { registeredGroupsByName } = workspace2Store.getState(); + const newRegisteredGroupsByName = { ...registeredGroupsByName }; + for (const workspaceGroupDef of workspaceGroupDefs) { + if (newRegisteredGroupsByName[workspaceGroupDef.name]) { + throw new Error(`Cannot register workspace group ${workspaceGroupDef.name} more than once`); + } + newRegisteredGroupsByName[workspaceGroupDef.name] = workspaceGroupDef; + } + + workspace2Store.setState({ + registeredGroupsByName: newRegisteredGroupsByName, + }); +} + +export function registerWorkspaceWindows2(appName: string, workspaceWindowDefs: Array) { + if (workspaceWindowDefs.length == 0) { + return; + } + const { registeredWindowsByName } = workspace2Store.getState(); + const newRegisteredWindowsByName = { ...registeredWindowsByName }; + for (const windowDef of workspaceWindowDefs) { + if (newRegisteredWindowsByName[windowDef.name]) { + throw new Error(`Cannot register workspace window ${windowDef.name} more than once`); + } + const registeredWindowDef = { ...windowDef, moduleName: appName }; + newRegisteredWindowsByName[windowDef.name] = registeredWindowDef; + } + + workspace2Store.setState({ + registeredWindowsByName: newRegisteredWindowsByName, + }); +} + +export function registerWorkspaces2(moduleName: string, workspaceDefs: Array) { + if (workspaceDefs.length == 0) { + return; + } + const { registeredWorkspacesByName } = workspace2Store.getState(); + const newRegisteredWorkspacesByName = { ...registeredWorkspacesByName }; + for (const workspaceDef of workspaceDefs) { + if (newRegisteredWorkspacesByName[workspaceDef.name]) { + throw new Error(`Cannot register workspace ${workspaceDef.name} more than once`); + } + const workspaceDefWithLoader = { ...workspaceDef, moduleName }; + newRegisteredWorkspacesByName[workspaceDef.name] = workspaceDefWithLoader; + } + + workspace2Store.setState({ + registeredWorkspacesByName: newRegisteredWorkspacesByName, + }); +} diff --git a/packages/framework/esm-framework/docs/functions/closeWorkspaceGroup2.md b/packages/framework/esm-framework/docs/functions/closeWorkspaceGroup2.md new file mode 100644 index 000000000..4b5839f4e --- /dev/null +++ b/packages/framework/esm-framework/docs/functions/closeWorkspaceGroup2.md @@ -0,0 +1,19 @@ +[O3 Framework](../API.md) / closeWorkspaceGroup2 + +# Function: closeWorkspaceGroup2() + +> **closeWorkspaceGroup2**(): `Promise`\<`boolean`\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/workspace2.ts:69](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts#L69) + +**`Experimental`** + +Closes the workspace group that is currently opened. Note that only one workspace group +may be opened at any given time + +## Returns + +`Promise`\<`boolean`\> + +a Promise that resolves to true if there is no opened group to begin with or we successfully closed +the opened group; false otherwise. diff --git a/packages/framework/esm-framework/docs/functions/getCoreTranslation.md b/packages/framework/esm-framework/docs/functions/getCoreTranslation.md index 442b802ae..62999ec09 100644 --- a/packages/framework/esm-framework/docs/functions/getCoreTranslation.md +++ b/packages/framework/esm-framework/docs/functions/getCoreTranslation.md @@ -17,7 +17,7 @@ invalid key to this function will result in a type error. ### key -`"error"` | `"delete"` | `"actions"` | `"address"` | `"age"` | `"cancel"` | `"change"` | `"Clinic"` | `"close"` | `"confirm"` | `"contactAdministratorIfIssuePersists"` | `"contactDetails"` | `"edit"` | `"errorCopy"` | `"female"` | `"loading"` | `"male"` | `"other"` | `"patientIdentifierSticker"` | `"patientLists"` | `"print"` | `"printError"` | `"printErrorExplainer"` | `"printIdentifierSticker"` | `"printing"` | `"relationships"` | `"resetOverrides"` | `"save"` | `"scriptLoadingFailed"` | `"scriptLoadingError"` | `"seeMoreLists"` | `"sex"` | `"showLess"` | `"showMore"` | `"toggleDevTools"` | `"unknown"` | `"closeAllOpenedWorkspaces"` | `"closingAllWorkspacesPromptBody"` | `"closingAllWorkspacesPromptTitle"` | `"discard"` | `"hide"` | `"maximize"` | `"minimize"` | `"openAnyway"` | `"unsavedChangesInOpenedWorkspace"` | `"unsavedChangesInWorkspace"` | `"unsavedChangesTitleText"` | `"workspaceHeader"` | `"address1"` | `"address2"` | `"address3"` | `"address4"` | `"address5"` | `"address6"` | `"city"` | `"cityVillage"` | `"country"` | `"countyDistrict"` | `"district"` | `"postalCode"` | `"state"` | `"stateProvince"` +`"error"` | `"delete"` | `"actions"` | `"address"` | `"age"` | `"cancel"` | `"change"` | `"Clinic"` | `"close"` | `"confirm"` | `"contactAdministratorIfIssuePersists"` | `"contactDetails"` | `"edit"` | `"errorCopy"` | `"female"` | `"loading"` | `"male"` | `"other"` | `"patientIdentifierSticker"` | `"patientLists"` | `"print"` | `"printError"` | `"printErrorExplainer"` | `"printIdentifierSticker"` | `"printing"` | `"relationships"` | `"resetOverrides"` | `"save"` | `"scriptLoadingFailed"` | `"scriptLoadingError"` | `"seeMoreLists"` | `"sex"` | `"showLess"` | `"showMore"` | `"toggleDevTools"` | `"unknown"` | `"closeWorkspaces2PromptTitle"` | `"closeWorkspaces2PromptBody"` | `"closeAllOpenedWorkspaces"` | `"closingAllWorkspacesPromptBody"` | `"closingAllWorkspacesPromptTitle"` | `"discard"` | `"hide"` | `"maximize"` | `"minimize"` | `"openAnyway"` | `"unsavedChangesInOpenedWorkspace"` | `"unsavedChangesInWorkspace"` | `"unsavedChangesTitleText"` | `"workspaceHeader"` | `"address1"` | `"address2"` | `"address3"` | `"address4"` | `"address5"` | `"address6"` | `"city"` | `"cityVillage"` | `"country"` | `"countyDistrict"` | `"district"` | `"postalCode"` | `"state"` | `"stateProvince"` ### defaultText? diff --git a/packages/framework/esm-framework/docs/functions/launchWorkspace2.md b/packages/framework/esm-framework/docs/functions/launchWorkspace2.md new file mode 100644 index 000000000..a5cca2a6d --- /dev/null +++ b/packages/framework/esm-framework/docs/functions/launchWorkspace2.md @@ -0,0 +1,79 @@ +[O3 Framework](../API.md) / launchWorkspace2 + +# Function: launchWorkspace2() + +> **launchWorkspace2**\<`WorkspaceProps`, `WindowProps`, `GroupProp`\>(`workspaceName`, `workspaceProps`, `windowProps`, `groupProps`): `Promise`\<`boolean`\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/workspace2.ts:127](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts#L127) + +**`Experimental`** + +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. + +## Type Parameters + +### WorkspaceProps + +`WorkspaceProps` *extends* `object` + +### WindowProps + +`WindowProps` *extends* `object` + +### GroupProp + +`GroupProp` *extends* `object` + +## Parameters + +### workspaceName + +`string` + +### workspaceProps + +`null` | `WorkspaceProps` + +### windowProps + +`null` | `WindowProps` + +### groupProps + +`null` | `GroupProp` + +## Returns + +`Promise`\<`boolean`\> diff --git a/packages/framework/esm-framework/docs/functions/launchWorkspaceGroup2.md b/packages/framework/esm-framework/docs/functions/launchWorkspaceGroup2.md new file mode 100644 index 000000000..4b6937ac0 --- /dev/null +++ b/packages/framework/esm-framework/docs/functions/launchWorkspaceGroup2.md @@ -0,0 +1,40 @@ +[O3 Framework](../API.md) / launchWorkspaceGroup2 + +# Function: launchWorkspaceGroup2() + +> **launchWorkspaceGroup2**\<`GroupProps`\>(`groupName`, `groupProps`): `Promise`\<`boolean`\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/workspace2.ts:29](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts#L29) + +**`Experimental`** + +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. + +## Type Parameters + +### GroupProps + +`GroupProps` *extends* `object` + +## Parameters + +### groupName + +`string` + +### groupProps + +`null` | `GroupProps` + +## Returns + +`Promise`\<`boolean`\> + +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. diff --git a/packages/framework/esm-framework/docs/interfaces/FeatureFlagDefinition.md b/packages/framework/esm-framework/docs/interfaces/FeatureFlagDefinition.md index 65cd123ca..c1e9e85ae 100644 --- a/packages/framework/esm-framework/docs/interfaces/FeatureFlagDefinition.md +++ b/packages/framework/esm-framework/docs/interfaces/FeatureFlagDefinition.md @@ -2,7 +2,7 @@ # Interface: FeatureFlagDefinition -Defined in: packages/framework/esm-globals/dist/types.d.ts:296 +Defined in: packages/framework/esm-globals/dist/types.d.ts:316 A definition of a feature flag extracted from the routes.json @@ -12,7 +12,7 @@ A definition of a feature flag extracted from the routes.json > **description**: `string` -Defined in: packages/framework/esm-globals/dist/types.d.ts:302 +Defined in: packages/framework/esm-globals/dist/types.d.ts:322 An explanation of what the flag does, which will be displayed in the Implementer Tools @@ -22,7 +22,7 @@ An explanation of what the flag does, which will be displayed in the Implementer > **flagName**: `string` -Defined in: packages/framework/esm-globals/dist/types.d.ts:298 +Defined in: packages/framework/esm-globals/dist/types.d.ts:318 A code-friendly name for the flag, which will be used to reference it in code @@ -32,6 +32,6 @@ A code-friendly name for the flag, which will be used to reference it in code > **label**: `string` -Defined in: packages/framework/esm-globals/dist/types.d.ts:300 +Defined in: packages/framework/esm-globals/dist/types.d.ts:320 A human-friendly name which will be displayed in the Implementer Tools diff --git a/packages/framework/esm-framework/docs/interfaces/OpenmrsAppRoutes.md b/packages/framework/esm-framework/docs/interfaces/OpenmrsAppRoutes.md index 4fb4382f7..012d697ea 100644 --- a/packages/framework/esm-framework/docs/interfaces/OpenmrsAppRoutes.md +++ b/packages/framework/esm-framework/docs/interfaces/OpenmrsAppRoutes.md @@ -2,7 +2,7 @@ # Interface: OpenmrsAppRoutes -Defined in: packages/framework/esm-globals/dist/types.d.ts:305 +Defined in: packages/framework/esm-globals/dist/types.d.ts:325 This interface describes the format of the routes provided by an app @@ -12,7 +12,7 @@ This interface describes the format of the routes provided by an app > `optional` **backendDependencies**: `Record`\<`string`, `string`\> -Defined in: packages/framework/esm-globals/dist/types.d.ts:309 +Defined in: packages/framework/esm-globals/dist/types.d.ts:329 A list of backend modules necessary for this frontend module and the corresponding required versions. @@ -22,7 +22,7 @@ A list of backend modules necessary for this frontend module and the correspondi > `optional` **extensions**: [`ExtensionDefinition`](../type-aliases/ExtensionDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:323 +Defined in: packages/framework/esm-globals/dist/types.d.ts:343 An array of all extensions supported by this frontend module. Extensions can be mounted in extension slots, either via declarations in this file or configuration. @@ -32,7 +32,7 @@ An array of all extensions supported by this frontend module. Extensions can be > `optional` **featureFlags**: [`FeatureFlagDefinition`](FeatureFlagDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:325 +Defined in: packages/framework/esm-globals/dist/types.d.ts:345 An array of all feature flags for any beta-stage features this module provides. @@ -42,7 +42,7 @@ An array of all feature flags for any beta-stage features this module provides. > `optional` **modals**: [`ModalDefinition`](../type-aliases/ModalDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:327 +Defined in: packages/framework/esm-globals/dist/types.d.ts:347 An array of all modals supported by this frontend module. Modals can be launched by name. @@ -52,7 +52,7 @@ An array of all modals supported by this frontend module. Modals can be launched > `optional` **optionalBackendDependencies**: `object` -Defined in: packages/framework/esm-globals/dist/types.d.ts:311 +Defined in: packages/framework/esm-globals/dist/types.d.ts:331 A list of backend modules that may enable optional functionality in this frontend module if available and the corresponding required versions. @@ -68,7 +68,7 @@ The name of the backend dependency and either the required version or an object > `optional` **pages**: [`PageDefinition`](../type-aliases/PageDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:321 +Defined in: packages/framework/esm-globals/dist/types.d.ts:341 An array of all pages supported by this frontend module. Pages are automatically mounted based on a route. @@ -78,7 +78,7 @@ An array of all pages supported by this frontend module. Pages are automatically > `optional` **version**: `string` -Defined in: packages/framework/esm-globals/dist/types.d.ts:307 +Defined in: packages/framework/esm-globals/dist/types.d.ts:327 The version of this frontend module. @@ -88,16 +88,46 @@ The version of this frontend module. > `optional` **workspaceGroups**: [`WorkspaceGroupDefinition`](WorkspaceGroupDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:331 +Defined in: packages/framework/esm-globals/dist/types.d.ts:351 An array of all workspace groups supported by this frontend module. *** +### workspaceGroups2? + +> `optional` **workspaceGroups2**: [`WorkspaceGroupDefinition2`](WorkspaceGroupDefinition2.md)[] + +Defined in: packages/framework/esm-globals/dist/types.d.ts:353 + +An array of all workspace groups (v2) supported by this frontend module. + +*** + ### workspaces? > `optional` **workspaces**: [`WorkspaceDefinition`](../type-aliases/WorkspaceDefinition.md)[] -Defined in: packages/framework/esm-globals/dist/types.d.ts:329 +Defined in: packages/framework/esm-globals/dist/types.d.ts:349 An array of all workspaces supported by this frontend module. Workspaces can be launched by name. + +*** + +### workspaces2? + +> `optional` **workspaces2**: [`WorkspaceDefinition2`](WorkspaceDefinition2.md)[] + +Defined in: packages/framework/esm-globals/dist/types.d.ts:357 + +An array of all workspaces (v2) supported by this frontend module. + +*** + +### workspaceWindows2? + +> `optional` **workspaceWindows2**: [`WorkspaceWindowDefinition2`](WorkspaceWindowDefinition2.md)[] + +Defined in: packages/framework/esm-globals/dist/types.d.ts:355 + +An array of all workspace windows (v2) supported by this frontend module. diff --git a/packages/framework/esm-framework/docs/interfaces/ResourceLoader.md b/packages/framework/esm-framework/docs/interfaces/ResourceLoader.md index 303ed8642..a3e50cc92 100644 --- a/packages/framework/esm-framework/docs/interfaces/ResourceLoader.md +++ b/packages/framework/esm-framework/docs/interfaces/ResourceLoader.md @@ -2,7 +2,7 @@ # Interface: ResourceLoader()\ -Defined in: packages/framework/esm-globals/dist/types.d.ts:338 +Defined in: packages/framework/esm-globals/dist/types.d.ts:364 ## Type Parameters @@ -12,7 +12,7 @@ Defined in: packages/framework/esm-globals/dist/types.d.ts:338 > **ResourceLoader**(): `Promise`\<`T`\> -Defined in: packages/framework/esm-globals/dist/types.d.ts:339 +Defined in: packages/framework/esm-globals/dist/types.d.ts:365 ## Returns diff --git a/packages/framework/esm-framework/docs/interfaces/WorkspaceDefinition2.md b/packages/framework/esm-framework/docs/interfaces/WorkspaceDefinition2.md new file mode 100644 index 000000000..b8ed51e8e --- /dev/null +++ b/packages/framework/esm-framework/docs/interfaces/WorkspaceDefinition2.md @@ -0,0 +1,29 @@ +[O3 Framework](../API.md) / WorkspaceDefinition2 + +# Interface: WorkspaceDefinition2 + +Defined in: packages/framework/esm-globals/dist/types.d.ts:308 + +## Properties + +### component + +> **component**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:310 + +*** + +### name + +> **name**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:309 + +*** + +### window + +> **window**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:311 diff --git a/packages/framework/esm-framework/docs/interfaces/WorkspaceGroupDefinition2.md b/packages/framework/esm-framework/docs/interfaces/WorkspaceGroupDefinition2.md new file mode 100644 index 000000000..8bb2230c8 --- /dev/null +++ b/packages/framework/esm-framework/docs/interfaces/WorkspaceGroupDefinition2.md @@ -0,0 +1,29 @@ +[O3 Framework](../API.md) / WorkspaceGroupDefinition2 + +# Interface: WorkspaceGroupDefinition2 + +Defined in: packages/framework/esm-globals/dist/types.d.ts:293 + +## Properties + +### closeable? + +> `optional` **closeable**: `boolean` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:295 + +*** + +### name + +> **name**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:294 + +*** + +### overlay? + +> `optional` **overlay**: `boolean` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:296 diff --git a/packages/framework/esm-framework/docs/interfaces/WorkspaceWindowDefinition2.md b/packages/framework/esm-framework/docs/interfaces/WorkspaceWindowDefinition2.md new file mode 100644 index 000000000..09f5f1103 --- /dev/null +++ b/packages/framework/esm-framework/docs/interfaces/WorkspaceWindowDefinition2.md @@ -0,0 +1,69 @@ +[O3 Framework](../API.md) / WorkspaceWindowDefinition2 + +# Interface: WorkspaceWindowDefinition2 + +Defined in: packages/framework/esm-globals/dist/types.d.ts:298 + +## Properties + +### canHide + +> **canHide**: `boolean` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:301 + +*** + +### canMaximize + +> **canMaximize**: `boolean` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:302 + +*** + +### group + +> **group**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:304 + +*** + +### icon? + +> `optional` **icon**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:300 + +*** + +### name + +> **name**: `string` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:299 + +*** + +### order? + +> `optional` **order**: `number` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:305 + +*** + +### overlay + +> **overlay**: `boolean` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:303 + +*** + +### width? + +> `optional` **width**: `"narrow"` \| `"wider"` \| `"extra-wide"` + +Defined in: packages/framework/esm-globals/dist/types.d.ts:306 diff --git a/packages/framework/esm-framework/docs/type-aliases/NameUse.md b/packages/framework/esm-framework/docs/type-aliases/NameUse.md index eee11aa09..0dad0d1c8 100644 --- a/packages/framework/esm-framework/docs/type-aliases/NameUse.md +++ b/packages/framework/esm-framework/docs/type-aliases/NameUse.md @@ -4,4 +4,4 @@ > **NameUse** = `"usual"` \| `"official"` \| `"temp"` \| `"nickname"` \| `"anonymous"` \| `"old"` \| `"maiden"` -Defined in: packages/framework/esm-globals/dist/types.d.ts:341 +Defined in: packages/framework/esm-globals/dist/types.d.ts:367 diff --git a/packages/framework/esm-framework/docs/type-aliases/OpenmrsRoutes.md b/packages/framework/esm-framework/docs/type-aliases/OpenmrsRoutes.md index 40aa9047f..c09c29213 100644 --- a/packages/framework/esm-framework/docs/type-aliases/OpenmrsRoutes.md +++ b/packages/framework/esm-framework/docs/type-aliases/OpenmrsRoutes.md @@ -4,7 +4,7 @@ > **OpenmrsRoutes** = `Record`\<`string`, [`OpenmrsAppRoutes`](../interfaces/OpenmrsAppRoutes.md)\> -Defined in: packages/framework/esm-globals/dist/types.d.ts:337 +Defined in: packages/framework/esm-globals/dist/types.d.ts:363 This interfaces describes the format of the overall routes.json loaded by the app shell. Basically, this is the same as the app routes, with each routes definition keyed by the app's name diff --git a/packages/framework/esm-framework/docs/type-aliases/Workspace2Definition.md b/packages/framework/esm-framework/docs/type-aliases/Workspace2Definition.md new file mode 100644 index 000000000..464c3ace1 --- /dev/null +++ b/packages/framework/esm-framework/docs/type-aliases/Workspace2Definition.md @@ -0,0 +1,21 @@ +[O3 Framework](../API.md) / Workspace2Definition + +# Type Alias: Workspace2Definition\ + +> **Workspace2Definition**\<`WorkspaceProps`, `WindowProps`, `GroupProps`\> = `React.FC`\<`Workspace2DefinitionProps`\<`WorkspaceProps`, `WindowProps`, `GroupProps`\>\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx:47](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx#L47) + +## Type Parameters + +### WorkspaceProps + +`WorkspaceProps` *extends* `object` + +### WindowProps + +`WindowProps` *extends* `object` + +### GroupProps + +`GroupProps` *extends* `object` diff --git a/packages/framework/esm-framework/docs/variables/ActionMenuButton2.md b/packages/framework/esm-framework/docs/variables/ActionMenuButton2.md new file mode 100644 index 000000000..bf134b846 --- /dev/null +++ b/packages/framework/esm-framework/docs/variables/ActionMenuButton2.md @@ -0,0 +1,19 @@ +[O3 Framework](../API.md) / ActionMenuButton2 + +# Variable: ActionMenuButton2 + +> `const` **ActionMenuButton2**: `React.FC`\<`ActionMenuButtonProps2`\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.component.tsx:65](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.component.tsx#L65) + +**`Experimental`** + +The ActionMenuButton2 component is used to render a button in the action menu of a workspace group. +The button is associated with a specific workspace window, defined in routes.json of the app with the button. +When one or more workspaces within the window are opened, the button will be highlighted. +If the window is hidden, either `tagContent` (if defined) or an exclamation mark will be displayed +on top of the icon. + +On clicked, The button either: +1. restores the workspace window if it is opened and hidden; or +2. launch a workspace from within that window, if the window is not opened. diff --git a/packages/framework/esm-framework/docs/variables/Workspace2.md b/packages/framework/esm-framework/docs/variables/Workspace2.md new file mode 100644 index 000000000..470a0c06c --- /dev/null +++ b/packages/framework/esm-framework/docs/variables/Workspace2.md @@ -0,0 +1,14 @@ +[O3 Framework](../API.md) / Workspace2 + +# Variable: Workspace2 + +> `const` **Workspace2**: `React.FC`\<`Workspace2Props`\> + +Defined in: [packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx:60](https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx#L60) + +**`Experimental`** + +The Workspace2 component is used as a top-level container to render +its children as content within a workspace. When creating a workspace +component, `` should be the top-level component returned, +wrapping all of the workspace content. diff --git a/packages/framework/esm-framework/mock-jest.tsx b/packages/framework/esm-framework/mock-jest.tsx index 027f81672..74d550066 100644 --- a/packages/framework/esm-framework/mock-jest.tsx +++ b/packages/framework/esm-framework/mock-jest.tsx @@ -93,6 +93,7 @@ export const ActionMenu = jest.fn(() =>
Action Menu
); export const WorkspaceContainer = jest.fn(() =>
Workspace Container
); export const closeWorkspace = jest.fn(); export const launchWorkspace = jest.fn(); +export const launchWorkspace2 = jest.fn(); export const launchWorkspaceGroup = jest.fn(); export const navigateAndLaunchWorkspace = jest.fn(); export const useWorkspaces = jest.fn(); diff --git a/packages/framework/esm-framework/mock.tsx b/packages/framework/esm-framework/mock.tsx index 5b06c9963..6037a237f 100644 --- a/packages/framework/esm-framework/mock.tsx +++ b/packages/framework/esm-framework/mock.tsx @@ -94,6 +94,7 @@ export const ActionMenu = vi.fn(() =>
Action Menu
); export const WorkspaceContainer = vi.fn(() =>
Workspace Container
); export const closeWorkspace = vi.fn(); export const launchWorkspace = vi.fn(); +export const launchWorkspace2 = vi.fn(); export const launchWorkspaceGroup = vi.fn(); export const navigateAndLaunchWorkspace = vi.fn(); export const useWorkspaces = vi.fn(); diff --git a/packages/framework/esm-globals/src/types.ts b/packages/framework/esm-globals/src/types.ts index 36cdf81fe..33fc169be 100644 --- a/packages/framework/esm-globals/src/types.ts +++ b/packages/framework/esm-globals/src/types.ts @@ -1,4 +1,3 @@ -import type { LifeCycles } from 'single-spa'; import type { i18n } from 'i18next'; declare global { @@ -311,6 +310,29 @@ export interface WorkspaceGroupDefinition { members?: Array; } +export interface WorkspaceGroupDefinition2 { + name: string; + closeable?: boolean; + overlay?: boolean; +} + +export interface WorkspaceWindowDefinition2 { + name: string; + icon?: string; + canHide: boolean; + canMaximize: boolean; + overlay: boolean; + group: string; + order?: number; + width?: 'narrow' | 'wider' | 'extra-wide'; +} + +export interface WorkspaceDefinition2 { + name: string; + component: string; + window: string; +} + /** * A definition of a feature flag extracted from the routes.json */ @@ -353,6 +375,15 @@ export interface OpenmrsAppRoutes { workspaces?: Array; /** An array of all workspace groups supported by this frontend module. */ workspaceGroups?: Array; + + /** An array of all workspace groups (v2) supported by this frontend module. */ + workspaceGroups2?: Array; + + /** An array of all workspace windows (v2) supported by this frontend module. */ + workspaceWindows2?: Array; + + /** An array of all workspaces (v2) supported by this frontend module. */ + workspaces2?: Array; } /** diff --git a/packages/framework/esm-routes/src/loaders/components.ts b/packages/framework/esm-routes/src/loaders/components.ts index 1bbd9acfb..c6713af82 100644 --- a/packages/framework/esm-routes/src/loaders/components.ts +++ b/packages/framework/esm-routes/src/loaders/components.ts @@ -4,6 +4,9 @@ import { registerModal, registerWorkspace, registerWorkspaceGroup, + registerWorkspaceGroups2, + registerWorkspaces2, + registerWorkspaceWindows2, } from '@openmrs/esm-extensions'; import { type FeatureFlagDefinition, @@ -11,6 +14,9 @@ import { type ModalDefinition, type WorkspaceDefinition, type WorkspaceGroupDefinition, + type WorkspaceGroupDefinition2, + type WorkspaceDefinition2, + type WorkspaceWindowDefinition2, } from '@openmrs/esm-globals'; import { registerFeatureFlag } from '@openmrs/esm-feature-flags'; import { loadLifeCycles } from './load-lifecycles'; @@ -183,6 +189,18 @@ To fix this, ensure that you define the "name" field inside the workspace defini }); } +export function tryRegisterWorkspaceGroups2(appName: string, workspaceGroupDefs: Array) { + registerWorkspaceGroups2(workspaceGroupDefs); +} + +export function tryRegisterWorkspace2(appName: string, workspaceDefs: Array) { + registerWorkspaces2(appName, workspaceDefs); +} + +export function tryRegisterWorkspaceWindows2(appName: string, workspaceWindowDefs: Array) { + registerWorkspaceWindows2(appName, workspaceWindowDefs); +} + /** * This function registers a feature flag definition with the framework. * diff --git a/packages/framework/esm-routes/src/loaders/pages.ts b/packages/framework/esm-routes/src/loaders/pages.ts index 568be6988..2a3e6c09e 100644 --- a/packages/framework/esm-routes/src/loaders/pages.ts +++ b/packages/framework/esm-routes/src/loaders/pages.ts @@ -17,7 +17,10 @@ import { tryRegisterFeatureFlag, tryRegisterModal, tryRegisterWorkspace, + tryRegisterWorkspace2, tryRegisterWorkspaceGroup, + tryRegisterWorkspaceGroups2, + tryRegisterWorkspaceWindows2, } from './components'; import { loadLifeCycles } from './load-lifecycles'; @@ -102,6 +105,9 @@ export function registerApp(appName: string, routes: OpenmrsAppRoutes) { const availableWorkspaces: Array = routes.workspaces ?? []; const availableWorkspaceGroups: Array = routes.workspaceGroups ?? []; const availableFeatureFlags: Array = routes.featureFlags ?? []; + const availableWorkspaceGroups2 = routes.workspaceGroups2 ?? []; + const availableWorkspaceWindows2 = routes.workspaceWindows2 ?? []; + const availableWorkspaces2 = routes.workspaces2 ?? []; routes.pages?.forEach((p) => { if ( @@ -170,6 +176,9 @@ export function registerApp(appName: string, routes: OpenmrsAppRoutes) { ); } }); + tryRegisterWorkspaceGroups2(appName, availableWorkspaceGroups2); + tryRegisterWorkspaceWindows2(appName, availableWorkspaceWindows2); + tryRegisterWorkspace2(appName, availableWorkspaces2); availableFeatureFlags.forEach((featureFlag) => { if (featureFlag && typeof featureFlag === 'object' && Object.hasOwn(featureFlag, 'flagName')) { diff --git a/packages/framework/esm-styleguide/mock-jest.tsx b/packages/framework/esm-styleguide/mock-jest.tsx index c5a53d7a4..22e8659aa 100644 --- a/packages/framework/esm-styleguide/mock-jest.tsx +++ b/packages/framework/esm-styleguide/mock-jest.tsx @@ -174,3 +174,10 @@ export const DiagnosisTags = jest.fn(({ diagnoses }: { diagnoses: Array )); + +export const Workspace2 = jest.fn(({ title, children }) => ( +
+

{title}

+ {children} +
+)); diff --git a/packages/framework/esm-styleguide/mock.tsx b/packages/framework/esm-styleguide/mock.tsx index 9409dbc1a..da3da68d7 100644 --- a/packages/framework/esm-styleguide/mock.tsx +++ b/packages/framework/esm-styleguide/mock.tsx @@ -175,3 +175,10 @@ export const DiagnosisTags = vi.fn(({ diagnoses }: { diagnoses: Array ))} )); + +export const Workspace2 = vi.fn(({ title, children }) => ( +
+

{title}

+ {children} +
+)); diff --git a/packages/framework/esm-styleguide/src/components/_general.scss b/packages/framework/esm-styleguide/src/components/_general.scss index 2daf9e8f4..da8e26144 100644 --- a/packages/framework/esm-styleguide/src/components/_general.scss +++ b/packages/framework/esm-styleguide/src/components/_general.scss @@ -30,5 +30,30 @@ body { display: flex; flex-direction: column; width: 100%; - height: 100%; + min-height: calc(100vh - var(--omrs-navbar-height)); + height: fit-content; + grid-area: appRoots; +} + +#omrs-left-nav-container { + grid-area: leftNav; +} + +#omrs-workspaces-container { + grid-area: workspace; + position: relative; + height: calc(100vh - var(--omrs-navbar-height)); +} + +#omrs-top-nav-app-container { + grid-area: topNav; + height: var(--omrs-navbar-height); +} + +body { + display: grid; + grid-template-areas: + 'topNav topNav topNav' + 'leftNav appRoots workspace'; + grid-template-columns: min-content 1fr min-content; } diff --git a/packages/framework/esm-styleguide/src/index.ts b/packages/framework/esm-styleguide/src/index.ts index 5c3e07096..d590762db 100644 --- a/packages/framework/esm-styleguide/src/index.ts +++ b/packages/framework/esm-styleguide/src/index.ts @@ -4,9 +4,21 @@ import { setupIcons } from './icons/icon-registration'; import { setupBranding } from './brand'; import { esmStyleGuideSchema } from './config-schema'; import { setupPictograms } from './pictograms/pictogram-registration'; +import { registerModal } from '@openmrs/esm-extensions'; +import { getSyncLifecycle } from '@openmrs/esm-react-utils'; +import Workspace2ClosePromptModal from './workspaces2/workspace2-close-prompt.modal'; defineConfigSchema('@openmrs/esm-styleguide', esmStyleGuideSchema); setupBranding(); setupLogo(); setupIcons(); setupPictograms(); + +registerModal({ + name: 'workspace2-close-prompt', + moduleName: '@openmrs/esm-styleguide', + load: getSyncLifecycle(Workspace2ClosePromptModal, { + featureName: 'workspace2-close-prompt', + moduleName: '@openmrs/esm-styleguide', + }), +}); diff --git a/packages/framework/esm-styleguide/src/internal.ts b/packages/framework/esm-styleguide/src/internal.ts index 5620bb735..e832d5451 100644 --- a/packages/framework/esm-styleguide/src/internal.ts +++ b/packages/framework/esm-styleguide/src/internal.ts @@ -23,3 +23,5 @@ export * from './spinner'; export * from './toasts'; export * from './toasts/toast.component'; export * from './workspaces'; +export * from './workspaces2'; +export * from './workspaces2/workspace-windows-and-menu.component'; diff --git a/packages/framework/esm-styleguide/src/public.ts b/packages/framework/esm-styleguide/src/public.ts index 5ebf79016..473499948 100644 --- a/packages/framework/esm-styleguide/src/public.ts +++ b/packages/framework/esm-styleguide/src/public.ts @@ -24,3 +24,11 @@ export * from './pictograms/pictograms'; export { type StyleguideConfigObject } from './config-schema'; export * from './location-picker'; export * from './diagnosis-tags'; +export { + launchWorkspace2, + launchWorkspaceGroup2, + closeWorkspaceGroup2, + ActionMenuButton2, + Workspace2, + type Workspace2Definition, +} from './workspaces2'; diff --git a/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.component.tsx b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.component.tsx new file mode 100644 index 000000000..3d636252e --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.component.tsx @@ -0,0 +1,149 @@ +/** @module @category Workspace */ +import React, { useContext } from 'react'; +import classNames from 'classnames'; +import { Button, IconButton } from '@carbon/react'; +import { useLayoutType } from '@openmrs/esm-react-utils'; +import { SingleSpaContext } from 'single-spa-react'; +import { type OpenedWindow } from '@openmrs/esm-extensions'; +import styles from './action-menu-button2.module.scss'; +import { launchWorkspace2, useWorkspace2Store } from '../workspace2'; + +interface TagsProps { + getIcon: (props: object) => JSX.Element; + isWindowHidden: boolean; + tagContent?: React.ReactNode; +} + +function Tags({ getIcon, isWindowHidden, tagContent }: TagsProps) { + return ( + <> + {getIcon({ size: 16 })} + + {isWindowHidden ? ( + ! + ) : ( + {tagContent} + )} + + ); +} + +export interface ActionMenuButtonProps2 { + icon: (props: object) => JSX.Element; + label: string; + tagContent?: string | React.ReactNode; + workspaceToLaunch: { + workspaceName: string; + workspaceProps?: Record; + windowProps?: Record; + }; + + /** + * An optional callback function to run before launching the workspace. + * If provided, the workspace will only be launched if this function returns true. + * This can be used to perform checks or prompt the user before launching the workspace. + * Note that this function does not run if the action button's window is already opened; + * it will just restore (unhide) the window. + * + */ + onBeforeWorkspaceLaunch?: () => Promise; +} + +/** + * The ActionMenuButton2 component is used to render a button in the action menu of a workspace group. + * The button is associated with a specific workspace window, defined in routes.json of the app with the button. + * When one or more workspaces within the window are opened, the button will be highlighted. + * If the window is hidden, either `tagContent` (if defined) or an exclamation mark will be displayed + * on top of the icon. + * + * On clicked, The button either: + * 1. restores the workspace window if it is opened and hidden; or + * 2. launch a workspace from within that window, if the window is not opened. + * + * @experimental + */ +export const ActionMenuButton2: React.FC = ({ + icon: getIcon, + label, + tagContent, + workspaceToLaunch, + onBeforeWorkspaceLaunch, +}) => { + const layout = useLayoutType(); + const { openedWindows, restoreWindow } = useWorkspace2Store(); + + // name of the window that the button is associated with + const { windowName } = useContext(SingleSpaContext); + + // can be undefined if the window is not opened + const window = openedWindows.find((w) => w.windowName === windowName); + + const isWindowOpened = window != null; + const isWindowHidden = window?.hidden ?? false; + const isFocused = isCurrentWindowFocused(openedWindows, window); + + const onClick = async () => { + if (isWindowOpened) { + if (!isFocused) { + restoreWindow(window.windowName); + } + } else { + const shouldLaunch = await (onBeforeWorkspaceLaunch?.() ?? true); + if (shouldLaunch) { + const { workspaceName, workspaceProps, windowProps } = workspaceToLaunch; + launchWorkspace2(workspaceName, workspaceProps, windowProps); + } + } + }; + + if (layout === 'tablet' || layout === 'phone') { + return ( + + ); + } + + return ( + +
+ +
+
+ ); +}; + +function isCurrentWindowFocused(openedWindows: Array, currentWindow: OpenedWindow | undefined): boolean { + // openedWindows array is sorted with most recently opened window appearing last. + // We check if the current window is the last one in the array that is not hidden. + for (let i = openedWindows.length - 1; i >= 0; i--) { + const win = openedWindows[i]; + if (!win.hidden) { + return win.windowName === currentWindow?.windowName; + } + } + return false; +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.module.scss b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.module.scss new file mode 100644 index 000000000..ab89b3ac8 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu-button2.module.scss @@ -0,0 +1,112 @@ +@use '@carbon/layout'; +@use '@carbon/type'; +@use '../../vars' as *; + +.container { + margin-top: layout.$spacing-02; + margin-bottom: layout.$spacing-02; + border-radius: 50%; + color: $ui-05; + max-width: none; + + &:hover { + background-color: $color-gray-30; + color: $text-02; + } + + &:focus { + border-color: $interactive-01; + } + + & > span { + margin-top: layout.$spacing-02; + @include type.type-style('body-compact-01'); + color: $text-02; + } + + & svg { + transform: scale(1.25); + } +} + +.elementContainer { + display: flex; + align-items: center; + position: relative; + flex-direction: column; + + .countTag { + position: absolute; + font-size: 10px; + text-align: center; + color: $ui-01; + background-color: $danger; + padding: 0 4px; + border-radius: 50%; + top: -14px; + right: -12px; + } + + .interruptedTag { + @extend .countTag; + font-size: 10px; + padding: 0 5px; + width: 14px; + height: 14px; + } +} + +/* Desktop */ +:global(.omrs-breakpoint-gt-tablet) { + .active { + border: layout.$spacing-01 solid; + @include brand-03(border-color); + border-radius: 50%; + background-color: $ui-02; + + &.focused { + border-color: var(--brand-03) !important; + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--brand-03) 80%, black 20%) !important; + } + } + + .container { + & svg { + transform: scale(1.25); + } + } +} + +/* Tablet */ +:global(.omrs-breakpoint-lt-desktop) { + .container { + margin-bottom: 0; + margin-top: 0; + border-radius: unset; + color: $ui-05; + max-width: none; + + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + } + + .active { + background-color: $color-blue-10; + color: $interactive-01; + + &:hover { + color: $interactive-01; + } + + & > span { + color: $interactive-01; + @include type.type-style('heading-compact-01'); + } + + svg { + fill: $interactive-01; + } + } +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.component.tsx b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.component.tsx new file mode 100644 index 000000000..ebc04a326 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.component.tsx @@ -0,0 +1,45 @@ +/** @module @category Workspace */ +import React from 'react'; +import { mountRootParcel } from 'single-spa'; +import { loadLifeCycles } from '@openmrs/esm-routes'; +import Parcel from 'single-spa-react/parcel'; +import { useWorkspace2Store } from '../workspace2'; +import styles from './action-menu2.module.scss'; + +export interface ActionMenuProps { + workspaceGroup: string; +} + +/** + * This component renders the action menu (right nav on desktop, bottom on mobile) + * for a workspace group. The action menu is only rendered when at least one + * window in the workspace group has an icon defined. + */ +export function ActionMenu({ workspaceGroup }: ActionMenuProps) { + const { registeredWindowsByName } = useWorkspace2Store(); + + const windowsWithIcons = Object.values(registeredWindowsByName) + .filter((window): window is Required => window.group == workspaceGroup && window.icon !== undefined) + .sort((a, b) => (a.order ?? Number.MAX_VALUE) - (b.order ?? Number.MAX_VALUE)); + + if (windowsWithIcons.length === 0) { + return null; // No icons to display + } + + return ( + + ); +} + +export default ActionMenu; diff --git a/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.module.scss b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.module.scss new file mode 100644 index 000000000..bb7e4d1a2 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/action-menu2/action-menu2.module.scss @@ -0,0 +1,94 @@ +@use '@carbon/layout'; +@use '../../vars' as *; + +$icon-button-size: layout.$spacing-08; +$actionPanelOffset: layout.$spacing-09; + +.sideRailHidden { + display: none; +} + +.sideRailVisible { + display: flex; +} + +/* Desktop */ +:global(.omrs-breakpoint-gt-tablet) { + .sideRail { + width: $actionPanelOffset; + z-index: 8003; + height: 100%; + + &.overlay { + position: absolute; + top: 0; + right: 0; + height: 100%; + } + + .container { + background: $ui-01; + width: $actionPanelOffset; + height: 100%; + border-left: 1px solid $text-03; + display: flex; + align-items: center; + flex-direction: column; + } + + &.withinWorkspace .container { + top: calc(var(--omrs-navbar-height) + var(--workspace-header-height)); + } + } +} + +/* Tablet */ +:global(.omrs-breakpoint-lt-desktop) { + .sideRail { + border-top: 1px solid $color-gray-30; + background-color: $ui-02; + position: fixed; + left: 0; + bottom: 0; + z-index: 8003; + width: 100%; + display: flex; + justify-content: stretch; + } + + .chartExtensions { + background-color: $ui-02; + display: flex; + width: 100%; + + > div { + flex: 1; + cursor: pointer; + } + } + + .container { + display: flex; + align-items: center; + width: 100%; + } +} + +.divider { + background-color: $text-03; + margin: layout.$spacing-04 0; + height: 1px; + width: layout.$spacing-08; +} + +// Overriding styles for RTL support +html[dir='rtl'] { + :global(.omrs-breakpoint-gt-tablet) { + .sideRail { + .container { + right: unset; + left: 0; + } + } + } +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/active-workspace-window.component.tsx b/packages/framework/esm-styleguide/src/workspaces2/active-workspace-window.component.tsx new file mode 100644 index 000000000..a4645ac02 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/active-workspace-window.component.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { InlineLoading } from '@carbon/react'; +import { type OpenedWindow, type OpenedWorkspace, workspace2Store } from '@openmrs/esm-extensions'; +import { loadLifeCycles } from '@openmrs/esm-routes'; +import { getCoreTranslation } from '@openmrs/esm-translations'; +import { mountRootParcel, type ParcelConfig } from 'single-spa'; +import Parcel from 'single-spa-react/parcel'; +import { promptForClosingWorkspaces, useWorkspace2Store } from './workspace2'; +import { type Workspace2DefinitionProps } from './workspace2.component'; + +interface WorkspaceWindowProps { + openedWindow: OpenedWindow; +} +/** + * Renders an opened workspace window. + */ +const ActiveWorkspaceWindow: React.FC = ({ openedWindow }) => { + const { openedWorkspaces } = openedWindow; + const [lifeCycles, setLifeCycles] = useState(); + const { registeredWorkspacesByName } = workspace2Store.getState(); + + useEffect(() => { + Promise.all( + openedWorkspaces.map((openedWorkspace) => { + const { moduleName, component } = registeredWorkspacesByName[openedWorkspace.workspaceName]; + return loadLifeCycles(moduleName, component); + }), + ).then(setLifeCycles); + }, [openedWorkspaces]); + + return openedWorkspaces.map((openedWorkspace, i) => ( + + )); +}; + +interface ActiveWorkspaceProps { + lifeCycle: ParcelConfig | undefined; + openedWorkspace: OpenedWorkspace; + openedWindow: OpenedWindow; +} + +const ActiveWorkspace: React.FC = ({ lifeCycle, openedWorkspace, openedWindow }) => { + const { openedGroup, closeWorkspace, openChildWorkspace } = useWorkspace2Store(); + + const props: Workspace2DefinitionProps = useMemo( + () => + openedWorkspace && { + closeWorkspace: async (options = {}) => { + const { closeWindow = false, discardUnsavedChanges = false } = options; + if (closeWindow) { + const okToCloseWorkspaces = + discardUnsavedChanges || + (await promptForClosingWorkspaces({ + reason: 'CLOSE_WINDOW', + explicit: true, + windowName: openedWindow.windowName, + })); + if (okToCloseWorkspaces) { + closeWorkspace(openedWindow.openedWorkspaces[0].workspaceName); + return true; + } + return false; + } else { + const okToCloseWorkspaces = + discardUnsavedChanges || + (await promptForClosingWorkspaces({ + reason: 'CLOSE_WORKSPACE', + explicit: true, + windowName: openedWindow.windowName, + workspaceName: openedWorkspace.workspaceName, + })); + if (okToCloseWorkspaces) { + closeWorkspace(openedWorkspace.workspaceName); + return true; + } + return false; + } + }, + launchChildWorkspace: (childWorkspaceName, childWorkspaceProps) => { + const parentWorkspaceName = openedWorkspace.workspaceName; + openChildWorkspace(parentWorkspaceName, childWorkspaceName, childWorkspaceProps ?? {}); + }, + workspaceName: openedWorkspace.workspaceName, + workspaceProps: openedWorkspace.props, + windowProps: openedWindow.props, + groupProps: openedGroup?.props ?? null, + }, + [openedWorkspace, closeWorkspace, openedGroup, openedWindow], + ); + + return lifeCycle ? ( + + ) : ( + + ); +}; + +export default ActiveWorkspaceWindow; diff --git a/packages/framework/esm-styleguide/src/workspaces2/index.tsx b/packages/framework/esm-styleguide/src/workspaces2/index.tsx new file mode 100644 index 000000000..7de81a618 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/index.tsx @@ -0,0 +1,3 @@ +export * from './workspace2.component'; +export * from './workspace2'; +export * from './action-menu2/action-menu-button2.component'; diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.component.tsx b/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.component.tsx new file mode 100644 index 000000000..da5d15f77 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.component.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { ActionMenu } from './action-menu2/action-menu2.component'; +import ActiveWorkspaceWindow from './active-workspace-window.component'; +import { useWorkspace2Store } from './workspace2'; +import styles from './workspace-windows-and-menu.module.scss'; +import classNames from 'classnames'; + +export function renderWorkspaceWindowsAndMenu(target: HTMLElement | null) { + if (target) { + const root = createRoot(target); + root.render(); + } +} + +/** + * This component renders the workspace action menu of a workspace group + * and all the active workspace windows within that group. + */ +function WorkspaceWindowsAndMenu() { + const { openedGroup, openedWindows, registeredGroupsByName } = useWorkspace2Store(); + + if (!openedGroup) { + return null; + } + + const group = registeredGroupsByName[openedGroup.groupName]; + + return ( +
+
+ {openedWindows.map((openedWindow) => { + return ; + })} +
+ +
+ ); +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.module.scss b/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.module.scss new file mode 100644 index 000000000..bc0658897 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace-windows-and-menu.module.scss @@ -0,0 +1,22 @@ +.workspaceWindowsAndMenuContainer { + position: relative; + display: flex; + height: 100%; + + &.overlay { + position: absolute; + right: 0; + top: 0; + bottom: 0; + } +} + +.workspaceWindowsContainer { + display: grid; + width: fit-content; + + // make its children stack on top of each other + > * { + grid-area: 1 / 1; + } +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.modal.tsx b/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.modal.tsx new file mode 100644 index 000000000..aa99e0d76 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.modal.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { ModalHeader, ModalBody, ModalFooter, Button } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { getCoreTranslation } from '@openmrs/esm-translations'; +import styles from './workspace2-close-prompt.module.scss'; + +interface WorkspaceUnsavedChangesModal { + onConfirm: () => void; + onCancel: () => void; + affectedWorkspaceTitles: string[]; +} + +/** + * This modal is used for prompting user to confirm closing currently opened workspace. + */ +const Workspace2ClosePromptModal: React.FC = ({ + onConfirm, + onCancel, + affectedWorkspaceTitles, +}) => { + const { t } = useTranslation(); + + return ( + <> + + +

{getCoreTranslation('closeWorkspaces2PromptBody')}

+
    + {affectedWorkspaceTitles.map((title) => ( +
  • {title}
  • + ))} +
+
+ + + + + + ); +}; + +export default Workspace2ClosePromptModal; diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.module.scss b/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.module.scss new file mode 100644 index 000000000..12a08399a --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace2-close-prompt.module.scss @@ -0,0 +1,8 @@ +@use '@carbon/layout'; + +.workspaceList { + li { + margin-left: layout.$spacing-05; + list-style: initial; + } +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx b/packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx new file mode 100644 index 000000000..71f8963b3 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace2.component.tsx @@ -0,0 +1,189 @@ +import React, { useContext, useEffect, type ReactNode } from 'react'; +import { Header, HeaderGlobalAction, HeaderGlobalBar, HeaderMenuButton, HeaderName } from '@carbon/react'; +import { DownToBottom, Maximize, Minimize } from '@carbon/react/icons'; +import { SingleSpaContext } from 'single-spa-react'; +import { isDesktop, useLayoutType } from '@openmrs/esm-react-utils'; +import { getOpenedWindowIndexByWorkspace } from '@openmrs/esm-extensions'; +import { getCoreTranslation } from '@openmrs/esm-translations'; +import classNames from 'classnames'; +import { ArrowLeftIcon, ArrowRightIcon, CloseIcon } from '../icons'; +import styles from './workspace2.module.scss'; +import { useWorkspace2Store } from './workspace2'; +interface Workspace2Props { + title: string; + children: ReactNode; + hasUnsavedChanges?: boolean; +} + +export interface Workspace2DefinitionProps< + WorkspaceProps extends object = object, + WindowProps extends object = object, + GroupProps extends object = object, +> { + workspaceName: string; + + /** + * This function launches a child workspace. Unlike `launchWorkspace()`, this function is meant + * to be called from the a workspace, and it does not allow passing (or changing) + * the window props or group props + * @param workspaceName + * @param workspaceProps + */ + launchChildWorkspace(workspaceName: string, workspaceProps?: Props): void; + + /** + * closes the current workspace, along with its children. + * @param closeWindow If true, the workspace's window, along with all workspaces within it, will be closed as well + * @param discardUnsavedChanges If true, the "unsaved changes" modal will be supressed, and the value of `hasUnsavedChanges` will be ignored. Use this when closing the workspace immediately after changes are saved. + * @returns a Promise that resolves to true if the workspace is closed, false otherwise. + */ + closeWorkspace(options?: { closeWindow?: boolean; discardUnsavedChanges?: boolean }): Promise; + + workspaceProps: WorkspaceProps | null; + windowProps: WindowProps | null; + groupProps: GroupProps | null; +} + +export type Workspace2Definition< + WorkspaceProps extends object, + WindowProps extends object, + GroupProps extends object, +> = React.FC>; + +/** + * The Workspace2 component is used as a top-level container to render + * its children as content within a workspace. When creating a workspace + * component, `` should be the top-level component returned, + * wrapping all of the workspace content. + * @experimental + */ +export const Workspace2: React.FC = ({ title, children, hasUnsavedChanges }) => { + const layout = useLayoutType(); + const { + setWindowMaximized, + hideWindow, + closeWorkspace, + setHasUnsavedChanges, + openedWindows, + openedGroup, + registeredGroupsByName, + registeredWindowsByName, + registeredWorkspacesByName, + workspaceTitleByWorkspaceName, + setWorkspaceTitle, + } = useWorkspace2Store(); + const { workspaceName } = useContext(SingleSpaContext); + + const openedWindowIndex = getOpenedWindowIndexByWorkspace(workspaceName); + + const openedWindow = openedWindows[openedWindowIndex]; + const openedWorkspace = openedWindow?.openedWorkspaces.find((workspace) => workspace.workspaceName === workspaceName); + + useEffect(() => { + if (openedWorkspace?.hasUnsavedChanges != hasUnsavedChanges) { + setHasUnsavedChanges(workspaceName, hasUnsavedChanges ?? false); + } + }, [openedWorkspace, hasUnsavedChanges]); + + useEffect(() => { + if (workspaceTitleByWorkspaceName[workspaceName] !== title) { + setWorkspaceTitle(workspaceName, title); + } + }, [openedWorkspace, title]); + + if (openedWindowIndex < 0 || openedGroup == null || openedWorkspace == null) { + // workspace window / group has likely just closed + return null; + } + + const group = registeredGroupsByName[openedGroup.groupName]; + if (!group) { + throw new Error(`Cannot find registered workspace group ${openedGroup.groupName}`); + } + const workspaceDef = registeredWorkspacesByName[workspaceName]; + const windowName = workspaceDef.window; + const windowDef = registeredWindowsByName[windowName]; + if (!windowDef) { + throw new Error(`Cannot find registered workspace window ${windowName}`); + } + + const { canHide, canMaximize } = windowDef; + const { maximized } = openedWindow; + const width = windowDef?.width ?? 'narrow'; + + return ( +
+
+
+
+
+ {!isDesktop(layout) && !canHide && ( + } + onClick={() => closeWorkspace(workspaceName)} + /> + )} + {title} +
+ + {isDesktop(layout) && ( + <> + {(canMaximize || maximized) && ( + setWindowMaximized(windowName, !maximized)} + > + {maximized ? : } + + )} + {canHide ? ( + hideWindow(windowName)}> + + + ) : ( + closeWorkspace(workspaceName)} + > + + + )} + + )} + {layout === 'tablet' && canHide && ( + closeWorkspace(workspaceName)} + > + + + )} + +
+
{children}
+
+
+
+ ); +}; diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace2.module.scss b/packages/framework/esm-styleguide/src/workspaces2/workspace2.module.scss new file mode 100644 index 000000000..5b1c318a1 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace2.module.scss @@ -0,0 +1,370 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; +@use '../vars' as *; + +$actionPanelOffset: layout.$spacing-09; +$narrowWorkspaceWidth: 26.25rem; +$widerWorkspaceWidth: 32.25rem; +$extraWideWorkspaceWidth: 48.25rem; + +// This container has no layout purpose. +// It is used to set the --workspaceWidth variable. +.workspaceOuterContainer { + &.narrowWorkspace { + --workspaceWidth: #{$narrowWorkspaceWidth}; + } + &.widerWorkspace { + --workspaceWidth: #{$widerWorkspaceWidth}; + } + &.extraWideWorkspace { + --workspaceWidth: #{$extraWideWorkspaceWidth}; + } + + width: var(--workspaceWidth); + display: contents; + + // This container clips the workspace when it slides out of screen + // (via overflow: hidden) + .workspaceMiddleContainer { + width: inherit; + overflow: hidden; + position: absolute; + top: 0; + bottom: 0; + inset-inline-end: $actionPanelOffset; + z-index: 8000; + + transition: width 0.5s ease-in-out; + + &.maximized { + width: calc(100vw - $actionPanelOffset); + } + &.hidden { + width: 0; + } + } + + // This container contains the actual workspace content + // it has sliding transitions when hidden / restored, and + // changes size (width) when maximized + .workspaceInnerContainer { + display: flex; + flex-direction: column; + height: 100%; + width: var(--workspaceWidth); + border-inline-start: 1px solid $text-03; + animation: slideFromRight 0.5s ease-in-out; + transition: + width 0.5s ease-in-out, + transform 0.5s ease-in-out; + + &.maximized { + width: calc(100vw - $actionPanelOffset); + } + &.hidden { + transform: translateX(100%); + } + + @keyframes slideFromRight { + from { + transform: translateX(100%); + } + to { + transform: translateX(0px); + } + } + } + + // The spacer is responsible for taking up space and shrinking the width of the "main" content + // in omrs-apps-container when in non-overlay mode. + // Note that the spacer takes up --workspaceWidth amount of space, but does + // not grow when the workspace is maximized. In other words, it does not shrink the + // "main" content further when workspace is maximized. + .workspaceSpacer { + width: inherit; + height: 100%; + animation: growToWidth 0.5s ease-in-out; + + &.hidden { + animation: shrinkWidth 0.5s ease-in-out forwards; + } + + @keyframes growToWidth { + from { + width: 0; + } + to { + width: inherit; + } + } + + @keyframes shrinkWidth { + from { + width: inherit; + } + to { + width: 0; + } + } + } +} + +.loader { + display: flex; + background-color: $openmrs-background-grey; + justify-content: center; + min-height: layout.$spacing-09; +} + +.hiddenExtraWorkspace { + display: none; +} + +/* Desktop */ +:global(.omrs-breakpoint-gt-tablet) { + .header { + position: relative; + box-sizing: content-box; + border-bottom: 1px solid $text-03; + background-color: $ui-03; + height: var(--workspace-header-height); + + a { + @include type.type-style('heading-compact-02'); + + &:hover { + color: inherit; + } + } + + &:not(.maximizedWindow) { + right: auto; + left: auto; + } + } + + .headerButtons { + button { + background-color: $ui-02; + border-right: 1px solid colors.$gray-20; + + > svg { + fill: colors.$cool-gray-100 !important; + } + + &:hover { + background-color: $ui-01; + } + } + } + + .maximizedWindow { + width: calc(100% - $actionPanelOffset) !important; + } + + .workspaceFixedContainer { + height: 100%; + border-left: 1px solid $text-03; + z-index: 50; + background-color: white; + animation: slideInFromRight 0.5s ease-in-out; + + &.hidden { + animation: slideOutToRight 0.5s ease-in-out forwards; + } + + &.overlay { + position: absolute; + top: 0; + bottom: 0; + } + } + + .extraWideWorkspace { + width: $extraWideWorkspaceWidth; + + .workspaceFixedContainer { + width: $extraWideWorkspaceWidth; + } + + &.hiddenRelative { + margin-right: -$extraWideWorkspaceWidth !important; + } + + .hiddenFixed { + right: -$extraWideWorkspaceWidth !important; + } + } + + .widerWorkspace { + width: $widerWorkspaceWidth; + + .workspaceFixedContainer { + width: $widerWorkspaceWidth; + } + + &.hiddenRelative { + margin-right: -$widerWorkspaceWidth !important; + } + + .hiddenFixed { + right: -$widerWorkspaceWidth !important; + } + } + + .narrowWorkspace { + width: $narrowWorkspaceWidth; + + .workspaceFixedContainer { + width: $narrowWorkspaceWidth; + } + + &.hiddenRelative { + margin-right: -$narrowWorkspaceWidth !important; + } + + .hiddenFixed { + right: -$narrowWorkspaceWidth !important; + } + } + + .workspaceContent { + background-color: $ui-02; + overflow-y: auto; + flex-grow: 1; + } + + // The parcel makes a div, between the workspace container and the workspace itself + .workspaceContent > div { + height: 100%; + } + + .overlay { + background-color: $ui-02; + display: flex; + flex-direction: column; + z-index: 1000; + } + + .overlayHeaderSpacer { + flex-grow: 1; + } +} + +/* Tablet */ +:global(.omrs-breakpoint-lt-desktop) { + .header { + position: relative; + background-color: var(--brand-02); + + a { + color: $ui-02; + } + + button { + color: $ui-02; + background-color: var(--brand-02); + + &:hover { + background-color: var(--brand-03); + } + } + } + + .hiddenFixed { + top: 100% !important; + } + + .workspaceFixedContainer { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 8002; + transition: top 0.5s ease-in-out; + } + + .workspaceContainerWithActionMenu { + &.workspaceFixedContainer { + bottom: var(--bottom-nav-height); + } + } + + .overlay { + background-color: $ui-02; + display: flex; + flex-direction: column; + } + + .overlayCloseButton { + order: 1; + } + + .overlayTitle { + order: 2; + } + + .workspaceContent { + background-color: $ui-01; + overflow-y: auto; + height: 100%; + } + + // The parcel makes a div, between the workspace container and the workspace itself + .workspaceContent > div { + height: 100%; + } + + .dynamicWidth { + width: 100%; + } + + .marginWorkspaceContent { + margin-bottom: var(--bottom-nav-height); + } + + @keyframes slideInFromBottom { + from { + transform: translateX($narrowWorkspaceWidth); + width: 0; + } + to { + transform: translateX(0%); + width: $narrowWorkspaceWidth; + } + } + + @keyframes slideOutToBottom { + from { + transform: translateX(0%); + width: $narrowWorkspaceWidth; + } + to { + transform: translateX($narrowWorkspaceWidth); + width: 0; + } + } +} + +// Overriding styles for RTL support +html[dir='rtl'] { + :global(.omrs-breakpoint-gt-tablet) { + .workspaceContainerWithActionMenu { + &.workspaceFixedContainer { + right: unset; + left: $actionPanelOffset; + } + } + + .workspaceContainerWithoutActionMenu { + height: 100%; + + &.workspaceFixedContainer { + right: unset; + left: 0; + } + } + } +} diff --git a/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts b/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts new file mode 100644 index 000000000..9a9f51531 --- /dev/null +++ b/packages/framework/esm-styleguide/src/workspaces2/workspace2.ts @@ -0,0 +1,550 @@ +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 { showModal } from '../modals'; +import { v4 as uuidV4 } from 'uuid'; +import { shallowEqual } from '@openmrs/esm-utils'; + +/** + * 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( + groupName: string, + groupProps: GroupProps | null, +): Promise { + 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 + * @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() { + const state = workspace2Store.getState(); + const { openedGroup, openedWindows } = state; + if (openedGroup) { + if (openedWindows.length > 0) { + const okToCloseWorkspaces = 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 { + 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: false }); + 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, + hidden: false, + }, + ], + }); + return true; + } else { + return false; + } + } else if (isWindowAlreadyOpened) { + const openedWindow = storeState.openedWindows[openedWindowIndex]; + const groupProps = storeState.openedGroup?.props ?? {}; + 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 (openedWindow.hidden || 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, + hidden: 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, + hidden: 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, + hidden: 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 | null, b: Record | 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 }; + +/** + * 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 { + // if onlyUpToThisWorkspace is provided, we will only loop till we hit that workspace + function getAffectedWorkspacesInWindow(openedWindow: OpenedWindow, onlyUpToThisWorkspace?: string) { + const ret: Array = []; + for (let i = openedWindow.openedWorkspaces.length - 1; i >= 0; i--) { + const openedWorkspace = openedWindow.openedWorkspaces[i]; + + if (openedWorkspace.hasUnsavedChanges || !promptReason.explicit) { + 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); + } + } + + 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], + ), + }); + }); +} + +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, + }; + }, + hideWindow(state: WorkspaceStoreState2, windowName: string) { + const openedWindowIndex = state.openedWindows.findIndex((a) => a.windowName === windowName); + const openedWindows = [...state.openedWindows]; + const currentWindow = { ...openedWindows[openedWindowIndex], hidden: true }; + + openedWindows[openedWindowIndex] = currentWindow; + + return { + ...state, + openedWindows, + }; + }, + restoreWindow(state: WorkspaceStoreState2, windowName: string) { + const openedWindowIndex = state.openedWindows.findIndex((a) => a.windowName === windowName); + const currentWindow = { ...state.openedWindows[openedWindowIndex], hidden: false }; + const openedWindows = [...state.openedWindows.filter((_, i) => i !== openedWindowIndex), currentWindow]; + return { + ...state, + openedWindows, + }; + }, + 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); + + if (window.openedWorkspaces.length === 0) { + // if no workspaces left, remove the window + openedWindows.splice(openedWindowIndex, 1); + } else { + // if there are still workspaces left, just update the window + openedWindows[openedWindowIndex] = window; + } + + return { + ...state, + openedWindows, + }; + }, + openChildWorkspace( + state, + parentWorkspaceName: string, + childWorkspaceName: string, + childWorkspaceProps: Record, + ) { + 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; + if (openedWorkspaces[openedWorkspaces.length - 1].workspaceName !== parentWorkspaceName) { + throw new Error( + `Cannot open child workspace ${childWorkspaceName} from parent workspace ${parentWorkspaceName} as the parent is not the most recently opened workspace within the workspace window`, + ); + } + + return { + openedWindows: state.openedWindows.map((w, i) => { + if (i == openedWindowIndex) { + return { + ...w, + openedWorkspaces: [...w.openedWorkspaces, 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; + +export function useWorkspace2Store() { + return useStoreWithActions(workspace2Store, workspace2StoreActions); +} + +function newOpenedWorkspace(workspaceName: string, workspaceProps: Record | null): OpenedWorkspace { + return { + workspaceName, + props: workspaceProps, + hasUnsavedChanges: false, + uuid: uuidV4(), + }; +} diff --git a/packages/framework/esm-translations/src/translations.ts b/packages/framework/esm-translations/src/translations.ts index 50b9a784d..da3692fbf 100644 --- a/packages/framework/esm-translations/src/translations.ts +++ b/packages/framework/esm-translations/src/translations.ts @@ -35,9 +35,15 @@ const workspaceTranslations = { workspaceHeader: 'Workspace header', }; +const workspace2Translations = { + closeWorkspaces2PromptTitle: 'Close workspace(s)', + closeWorkspaces2PromptBody: 'You are about to close the following workspace(s), which might have unsaved changes:', +}; + export const coreTranslations = { ...addressFields, ...workspaceTranslations, + ...workspace2Translations, actions: 'Actions', address: 'Address', age: 'Age', diff --git a/packages/framework/esm-translations/translations/en.json b/packages/framework/esm-translations/translations/en.json index 632705133..08af9ef00 100644 --- a/packages/framework/esm-translations/translations/en.json +++ b/packages/framework/esm-translations/translations/en.json @@ -15,6 +15,8 @@ "Clinic": "Clinic", "close": "Close", "closeAllOpenedWorkspaces": "Discard changes in {{count}} workspaces", + "closeWorkspaces2PromptBody": "You are about to close the following workspace(s), which might have unsaved changes:", + "closeWorkspaces2PromptTitle": "Close workspace(s)", "closingAllWorkspacesPromptBody": "There may be unsaved changes in the following workspaces. Do you want to discard changes in the following workspaces? {{workspaceNames}}", "closingAllWorkspacesPromptTitle": "You have unsaved changes", "confirm": "Confirm", diff --git a/packages/shell/esm-app-shell/src/index.ejs b/packages/shell/esm-app-shell/src/index.ejs index 921fdfa65..987b64e17 100644 --- a/packages/shell/esm-app-shell/src/index.ejs +++ b/packages/shell/esm-app-shell/src/index.ejs @@ -49,6 +49,7 @@
+