@openmrs/esm-styleguide 11.0.1-pre.5806 → 11.0.1-pre.5830

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
- [0] Successfully compiled: 117 files, copied 171 files with swc (270.19ms)
1
+ [0] Successfully compiled: 117 files, copied 171 files with swc (326.45ms)
2
2
  [0] swc --strip-leading-paths --copy-files src -d dist && svgo -rf dist --quiet exited with code 0
3
3
  [1] tsc --project tsconfig.build.json exited with code 0
@@ -1,5 +1,6 @@
1
1
  /** @module @category Workspace */
2
2
  import React from 'react';
3
+ import { type OpenedWindow } from '@openmrs/esm-extensions';
3
4
  export interface ActionMenuButtonProps2 {
4
5
  icon: (props: object) => JSX.Element;
5
6
  label: string;
@@ -18,6 +19,20 @@ export interface ActionMenuButtonProps2 {
18
19
  *
19
20
  */
20
21
  onBeforeWorkspaceLaunch?: () => Promise<boolean>;
22
+ /**
23
+ * An optional callback function to determine whether the action menu button
24
+ * should be disabled based on the openedWindows state of the workspace group
25
+ * @param openedWindows
26
+ * @returns true if the action menu button should be disabled and false if it should be enabled
27
+ */
28
+ disabled?: (openedWindows: Array<OpenedWindow>) => boolean;
29
+ /**
30
+ * An optional callback function to determine whether the action menu button
31
+ * should be hidden based on the openedWindows state of the workspace group
32
+ * @param openedWindows
33
+ * @returns true if the action menu button should be hidden and false if it should be shown
34
+ */
35
+ hidden?: (openedWindows: Array<OpenedWindow>) => boolean;
21
36
  }
22
37
  /**
23
38
  * The ActionMenuButton2 component is used to render a button in the action menu of a workspace group.
@@ -1 +1 @@
1
- {"version":3,"file":"action-menu-button2.component.d.ts","sourceRoot":"","sources":["../../../src/workspaces2/action-menu2/action-menu-button2.component.tsx"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,KAAqB,MAAM,OAAO,CAAC;AA6B1C,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,iBAAiB,EAAE;QACjB,aAAa,EAAE,MAAM,CAAC;QACtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACrC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACnC,CAAC;IAEF;;;;;;;OAOG;IACH,uBAAuB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;CAClD;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CA6E9D,CAAC"}
1
+ {"version":3,"file":"action-menu-button2.component.d.ts","sourceRoot":"","sources":["../../../src/workspaces2/action-menu2/action-menu-button2.component.tsx"],"names":[],"mappings":"AAAA,kCAAkC;AAClC,OAAO,KAAqB,MAAM,OAAO,CAAC;AAK1C,OAAO,EAAE,KAAK,YAAY,EAAE,MAAM,yBAAyB,CAAC;AAwB5D,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,GAAG,CAAC,OAAO,CAAC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,CAAC,EAAE,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC;IACtC,iBAAiB,EAAE;QACjB,aAAa,EAAE,MAAM,CAAC;QACtB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;QACrC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;KACnC,CAAC;IAEF;;;;;;;OAOG;IACH,uBAAuB,CAAC,EAAE,MAAM,OAAO,CAAC,OAAO,CAAC,CAAC;IAEjD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,aAAa,EAAE,KAAK,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC;IAE3D;;;;;OAKG;IACH,MAAM,CAAC,EAAE,CAAC,aAAa,EAAE,KAAK,CAAC,YAAY,CAAC,KAAK,OAAO,CAAC;CAC1D;AAED;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,iBAAiB,EAAE,KAAK,CAAC,EAAE,CAAC,sBAAsB,CAqF9D,CAAC"}
@@ -34,7 +34,7 @@ function Tags({ getIcon, hasUnsavedChanges, tagContent }) {
34
34
  * 2. restores the workspace window if it is opened and unfocused; or
35
35
  * 3. launches a workspace from within that window, if the window is not opened.
36
36
  *
37
- */ export const ActionMenuButton2 = ({ icon: getIcon, label, tagContent, workspaceToLaunch, onBeforeWorkspaceLaunch })=>{
37
+ */ export const ActionMenuButton2 = ({ icon: getIcon, label, tagContent, workspaceToLaunch, onBeforeWorkspaceLaunch, hidden, disabled })=>{
38
38
  const layout = useLayoutType();
39
39
  const { openedWindows, restoreWindow, hideWindow, isMostRecentlyOpenedWindowHidden } = useWorkspace2Store();
40
40
  const { extension } = useContext(ComponentContext);
@@ -60,6 +60,9 @@ function Tags({ getIcon, hasUnsavedChanges, tagContent }) {
60
60
  }
61
61
  }
62
62
  };
63
+ if (hidden?.(openedWindows)) {
64
+ return null;
65
+ }
63
66
  if (layout === 'tablet' || layout === 'phone') {
64
67
  return /*#__PURE__*/ _jsxs(Button, {
65
68
  className: classNames(styles.container, {
@@ -72,6 +75,7 @@ function Tags({ getIcon, hasUnsavedChanges, tagContent }) {
72
75
  role: "button",
73
76
  tabIndex: 0,
74
77
  size: "md",
78
+ disabled: disabled?.(openedWindows),
75
79
  children: [
76
80
  /*#__PURE__*/ _jsx("span", {
77
81
  className: styles.elementContainer,
@@ -99,6 +103,7 @@ function Tags({ getIcon, hasUnsavedChanges, tagContent }) {
99
103
  label: label,
100
104
  onClick: onClick,
101
105
  size: "md",
106
+ disabled: disabled?.(openedWindows),
102
107
  children: /*#__PURE__*/ _jsx("div", {
103
108
  className: styles.elementContainer,
104
109
  children: /*#__PURE__*/ _jsx(Tags, {
@@ -1 +1 @@
1
- {"version":3,"file":"active-workspace-window.component.d.ts","sourceRoot":"","sources":["../../src/workspaces2/active-workspace-window.component.tsx"],"names":[],"mappings":"AAAA,OAAO,KAAuD,MAAM,OAAO,CAAC;AAM5E,OAAO,EACL,KAAK,YAAY,EAKlB,MAAM,yBAAyB,CAAC;AAOjC,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAGzE,UAAU,oBAAoB;IAC5B,YAAY,EAAE,YAAY,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IACvC,OAAO,EAAE,sBAAsB,CAAC;IAChC;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAKD;;;GAGG;AACH,QAAA,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,oBAAoB,CA4DzD,CAAC;AA0RF,eAAe,qBAAqB,CAAC"}
1
+ {"version":3,"file":"active-workspace-window.component.d.ts","sourceRoot":"","sources":["../../src/workspaces2/active-workspace-window.component.tsx"],"names":[],"mappings":"AAAA,OAAO,KAA+D,MAAM,OAAO,CAAC;AAapF,OAAO,EACL,KAAK,YAAY,EAKlB,MAAM,yBAAyB,CAAC;AAQjC,OAAO,EAAE,KAAK,sBAAsB,EAAE,MAAM,4BAA4B,CAAC;AAGzE,UAAU,oBAAoB;IAC5B,YAAY,EAAE,YAAY,CAAC;IAC3B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,IAAI,CAAC;IACvC,OAAO,EAAE,sBAAsB,CAAC;IAChC;;;;OAIG;IACH,YAAY,EAAE,OAAO,CAAC;IACtB,cAAc,EAAE,OAAO,CAAC;CACzB;AAKD;;;GAGG;AACH,QAAA,MAAM,qBAAqB,EAAE,KAAK,CAAC,EAAE,CAAC,oBAAoB,CAuEzD,CAAC;AAmSF,eAAe,qBAAqB,CAAC"}
@@ -1,11 +1,12 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import React, { useEffect, useMemo, useState } from "react";
2
+ import React, { useEffect, useMemo, useRef, useState } from "react";
3
3
  import classNames from "classnames";
4
4
  import Parcel from "single-spa-react/parcel";
5
- import { Header, HeaderGlobalAction, HeaderGlobalBar, HeaderName, InlineLoading } from "@carbon/react";
5
+ import { Header, HeaderGlobalAction, HeaderGlobalBar, HeaderName, InlineLoading, InlineNotification } from "@carbon/react";
6
6
  import { DownToBottom, Maximize, Minimize } from "@carbon/react/icons";
7
7
  import { createParcelMounter, workspace2Store } from "@openmrs/esm-extensions";
8
8
  import { isDesktop, useLayoutType, useStore } from "@openmrs/esm-react-utils";
9
+ import { reportError } from "@openmrs/esm-error-handling";
9
10
  import { loadLifeCycles } from "@openmrs/esm-routes";
10
11
  import { getCoreTranslation } from "@openmrs/esm-translations";
11
12
  import { ArrowRightIcon, CloseIcon } from "../icons/index.js";
@@ -28,6 +29,9 @@ const selectRegisteredWorkspacesByName = (state)=>state.registeredWorkspacesByNa
28
29
  const [lifeCyclesByUuid, setLifeCyclesByUuid] = useState({});
29
30
  // Reactive so the effect retries when a workspace registers after mount.
30
31
  const registeredWorkspacesByName = useStore(workspace2Store, selectRegisteredWorkspacesByName);
32
+ // The effect reloads every workspace whenever any of them changes (for example, its title), so
33
+ // track which workspaces have already reported a load failure to report each one only once.
34
+ const reportedFailureUuids = useRef(new Set());
31
35
  useEffect(()=>{
32
36
  let cancelled = false;
33
37
  Promise.all(openedWorkspaces.map(async (openedWorkspace)=>{
@@ -36,15 +40,26 @@ const selectRegisteredWorkspacesByName = (state)=>state.registeredWorkspacesByNa
36
40
  return null;
37
41
  }
38
42
  const { moduleName, component } = workspaceDef;
39
- return [
40
- openedWorkspace.uuid,
41
- await loadLifeCycles(moduleName, component)
42
- ];
43
+ try {
44
+ return [
45
+ openedWorkspace.uuid,
46
+ await loadLifeCycles(moduleName, component)
47
+ ];
48
+ } catch (error) {
49
+ if (!cancelled && !reportedFailureUuids.current.has(openedWorkspace.uuid)) {
50
+ reportedFailureUuids.current.add(openedWorkspace.uuid);
51
+ reportError(error);
52
+ }
53
+ return [
54
+ openedWorkspace.uuid,
55
+ null
56
+ ];
57
+ }
43
58
  })).then((entries)=>{
44
59
  if (!cancelled) {
45
60
  setLifeCyclesByUuid(Object.fromEntries(entries.filter((entry)=>entry != null)));
46
61
  }
47
- });
62
+ }, reportError);
48
63
  return ()=>{
49
64
  cancelled = true;
50
65
  };
@@ -141,7 +156,17 @@ const ActiveWorkspace = ({ lifeCycle, openedWorkspace, openedWindow, groupProps,
141
156
  [styles.hiddenExtraWorkspace]: hideBehindLeaf
142
157
  }),
143
158
  ...props
144
- }, openedWorkspace.workspaceName) : null, [
159
+ }, openedWorkspace.workspaceName) : lifeCycle === null ? /*#__PURE__*/ _jsx("div", {
160
+ className: classNames(styles.workspaceContent, {
161
+ [styles.hiddenExtraWorkspace]: hideBehindLeaf
162
+ }),
163
+ children: /*#__PURE__*/ _jsx(InlineNotification, {
164
+ kind: "error",
165
+ title: getCoreTranslation('error'),
166
+ subtitle: getCoreTranslation('somethingWentWrongTryReloading'),
167
+ hideCloseButton: true
168
+ })
169
+ }) : null, [
145
170
  lifeCycle,
146
171
  openedWorkspace.workspaceName,
147
172
  props,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-styleguide",
3
- "version": "11.0.1-pre.5806",
3
+ "version": "11.0.1-pre.5830",
4
4
  "license": "MPL-2.0",
5
5
  "description": "The styleguide for OpenMRS SPA",
6
6
  "module": "dist/internal.js",
@@ -79,18 +79,18 @@
79
79
  "uuid": "^11.1.1"
80
80
  },
81
81
  "peerDependencies": {
82
- "@openmrs/esm-api": "^11.0.1-pre.5806",
83
- "@openmrs/esm-config": "^11.0.1-pre.5806",
84
- "@openmrs/esm-emr-api": "^11.0.1-pre.5806",
85
- "@openmrs/esm-error-handling": "^11.0.1-pre.5806",
86
- "@openmrs/esm-extensions": "^11.0.1-pre.5806",
87
- "@openmrs/esm-globals": "^11.0.1-pre.5806",
88
- "@openmrs/esm-navigation": "^11.0.1-pre.5806",
89
- "@openmrs/esm-react-utils": "^11.0.1-pre.5806",
90
- "@openmrs/esm-routes": "^11.0.1-pre.5806",
91
- "@openmrs/esm-state": "^11.0.1-pre.5806",
92
- "@openmrs/esm-translations": "^11.0.1-pre.5806",
93
- "@openmrs/esm-utils": "^11.0.1-pre.5806",
82
+ "@openmrs/esm-api": "^11.0.1-pre.5830",
83
+ "@openmrs/esm-config": "^11.0.1-pre.5830",
84
+ "@openmrs/esm-emr-api": "^11.0.1-pre.5830",
85
+ "@openmrs/esm-error-handling": "^11.0.1-pre.5830",
86
+ "@openmrs/esm-extensions": "^11.0.1-pre.5830",
87
+ "@openmrs/esm-globals": "^11.0.1-pre.5830",
88
+ "@openmrs/esm-navigation": "^11.0.1-pre.5830",
89
+ "@openmrs/esm-react-utils": "^11.0.1-pre.5830",
90
+ "@openmrs/esm-routes": "^11.0.1-pre.5830",
91
+ "@openmrs/esm-state": "^11.0.1-pre.5830",
92
+ "@openmrs/esm-translations": "^11.0.1-pre.5830",
93
+ "@openmrs/esm-utils": "^11.0.1-pre.5830",
94
94
  "dayjs": "1.x",
95
95
  "i18next": "25.x",
96
96
  "react": "18.x",
@@ -99,18 +99,18 @@
99
99
  "swr": "2.x"
100
100
  },
101
101
  "devDependencies": {
102
- "@openmrs/esm-api": "11.0.1-pre.5806",
103
- "@openmrs/esm-config": "11.0.1-pre.5806",
104
- "@openmrs/esm-emr-api": "11.0.1-pre.5806",
105
- "@openmrs/esm-error-handling": "11.0.1-pre.5806",
106
- "@openmrs/esm-extensions": "11.0.1-pre.5806",
107
- "@openmrs/esm-globals": "11.0.1-pre.5806",
108
- "@openmrs/esm-navigation": "11.0.1-pre.5806",
109
- "@openmrs/esm-react-utils": "11.0.1-pre.5806",
110
- "@openmrs/esm-routes": "11.0.1-pre.5806",
111
- "@openmrs/esm-state": "11.0.1-pre.5806",
112
- "@openmrs/esm-translations": "11.0.1-pre.5806",
113
- "@openmrs/esm-utils": "11.0.1-pre.5806",
102
+ "@openmrs/esm-api": "11.0.1-pre.5830",
103
+ "@openmrs/esm-config": "11.0.1-pre.5830",
104
+ "@openmrs/esm-emr-api": "11.0.1-pre.5830",
105
+ "@openmrs/esm-error-handling": "11.0.1-pre.5830",
106
+ "@openmrs/esm-extensions": "11.0.1-pre.5830",
107
+ "@openmrs/esm-globals": "11.0.1-pre.5830",
108
+ "@openmrs/esm-navigation": "11.0.1-pre.5830",
109
+ "@openmrs/esm-react-utils": "11.0.1-pre.5830",
110
+ "@openmrs/esm-routes": "11.0.1-pre.5830",
111
+ "@openmrs/esm-state": "11.0.1-pre.5830",
112
+ "@openmrs/esm-translations": "11.0.1-pre.5830",
113
+ "@openmrs/esm-utils": "11.0.1-pre.5830",
114
114
  "@swc/cli": "0.8.1",
115
115
  "@swc/core": "1.15.21",
116
116
  "@types/geopattern": "^1.2.9",
@@ -47,6 +47,22 @@ export interface ActionMenuButtonProps2 {
47
47
  *
48
48
  */
49
49
  onBeforeWorkspaceLaunch?: () => Promise<boolean>;
50
+
51
+ /**
52
+ * An optional callback function to determine whether the action menu button
53
+ * should be disabled based on the openedWindows state of the workspace group
54
+ * @param openedWindows
55
+ * @returns true if the action menu button should be disabled and false if it should be enabled
56
+ */
57
+ disabled?: (openedWindows: Array<OpenedWindow>) => boolean;
58
+
59
+ /**
60
+ * An optional callback function to determine whether the action menu button
61
+ * should be hidden based on the openedWindows state of the workspace group
62
+ * @param openedWindows
63
+ * @returns true if the action menu button should be hidden and false if it should be shown
64
+ */
65
+ hidden?: (openedWindows: Array<OpenedWindow>) => boolean;
50
66
  }
51
67
 
52
68
  /**
@@ -69,6 +85,8 @@ export const ActionMenuButton2: React.FC<ActionMenuButtonProps2> = ({
69
85
  tagContent,
70
86
  workspaceToLaunch,
71
87
  onBeforeWorkspaceLaunch,
88
+ hidden,
89
+ disabled,
72
90
  }) => {
73
91
  const layout = useLayoutType();
74
92
  const { openedWindows, restoreWindow, hideWindow, isMostRecentlyOpenedWindowHidden } = useWorkspace2Store();
@@ -99,6 +117,10 @@ export const ActionMenuButton2: React.FC<ActionMenuButtonProps2> = ({
99
117
  }
100
118
  };
101
119
 
120
+ if (hidden?.(openedWindows)) {
121
+ return null;
122
+ }
123
+
102
124
  if (layout === 'tablet' || layout === 'phone') {
103
125
  return (
104
126
  <Button
@@ -112,6 +134,7 @@ export const ActionMenuButton2: React.FC<ActionMenuButtonProps2> = ({
112
134
  role="button"
113
135
  tabIndex={0}
114
136
  size="md"
137
+ disabled={disabled?.(openedWindows)}
115
138
  >
116
139
  <span className={styles.elementContainer}>
117
140
  <Tags hasUnsavedChanges={hasUnsavedChanges} getIcon={getIcon} tagContent={tagContent} />
@@ -134,6 +157,7 @@ export const ActionMenuButton2: React.FC<ActionMenuButtonProps2> = ({
134
157
  label={label}
135
158
  onClick={onClick}
136
159
  size="md"
160
+ disabled={disabled?.(openedWindows)}
137
161
  >
138
162
  <div className={styles.elementContainer}>
139
163
  <Tags hasUnsavedChanges={hasUnsavedChanges} getIcon={getIcon} tagContent={tagContent} />
@@ -0,0 +1,94 @@
1
+ import React from 'react';
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { render, screen } from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import { ComponentContext, useLayoutType } from '@openmrs/esm-react-utils';
6
+ import { type OpenedWindow } from '@openmrs/esm-extensions';
7
+ import { launchWorkspace2, useWorkspace2Store } from '../workspace2';
8
+ import { ActionMenuButton2 } from './action-menu-button2.component';
9
+
10
+ vi.mock('../workspace2', () => ({
11
+ launchWorkspace2: vi.fn(),
12
+ useWorkspace2Store: vi.fn(),
13
+ }));
14
+
15
+ vi.mock('@openmrs/esm-react-utils', async () => ({
16
+ ...(await vi.importActual<object>('@openmrs/esm-react-utils')),
17
+ useLayoutType: vi.fn(),
18
+ }));
19
+
20
+ const mockUseLayoutType = vi.mocked(useLayoutType);
21
+ const mockUseWorkspace2Store = vi.mocked(useWorkspace2Store);
22
+ const mockLaunchWorkspace2 = vi.mocked(launchWorkspace2);
23
+
24
+ const mockHideWindow = vi.fn();
25
+
26
+ const openedWindows = [{ windowName: 'test-window', openedWorkspaces: [] }] as unknown as Array<OpenedWindow>;
27
+
28
+ function renderButton(props: Partial<React.ComponentProps<typeof ActionMenuButton2>> = {}) {
29
+ return render(
30
+ <ComponentContext.Provider value={{ extension: { extensionId: 'test-window' } } as any}>
31
+ <ActionMenuButton2
32
+ icon={() => <svg data-testid="icon" />}
33
+ label="Test button"
34
+ workspaceToLaunch={{ workspaceName: 'test-workspace' }}
35
+ {...props}
36
+ />
37
+ </ComponentContext.Provider>,
38
+ );
39
+ }
40
+
41
+ describe('ActionMenuButton2 hidden and disabled', () => {
42
+ beforeEach(() => {
43
+ mockUseLayoutType.mockReturnValue('small-desktop');
44
+ mockUseWorkspace2Store.mockReturnValue({
45
+ openedWindows,
46
+ restoreWindow: vi.fn(),
47
+ hideWindow: mockHideWindow,
48
+ isMostRecentlyOpenedWindowHidden: false,
49
+ } as any);
50
+ });
51
+
52
+ it('renders an enabled button by default', () => {
53
+ renderButton();
54
+ expect(screen.getByRole('button')).toBeEnabled();
55
+ });
56
+
57
+ it('renders nothing when hidden returns true, passing openedWindows', () => {
58
+ const hidden = vi.fn().mockReturnValue(true);
59
+ renderButton({ hidden });
60
+ expect(hidden).toHaveBeenCalledWith(openedWindows);
61
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
62
+ });
63
+
64
+ it('renders the button when hidden returns false', () => {
65
+ renderButton({ hidden: () => false });
66
+ expect(screen.getByRole('button')).toBeInTheDocument();
67
+ });
68
+
69
+ it('disables the button when disabled returns true, passing openedWindows', async () => {
70
+ const disabled = vi.fn().mockReturnValue(true);
71
+ renderButton({ disabled });
72
+ expect(disabled).toHaveBeenCalledWith(openedWindows);
73
+ const button = screen.getByRole('button');
74
+ expect(button).toBeDisabled();
75
+ await userEvent.click(button);
76
+ expect(mockLaunchWorkspace2).not.toHaveBeenCalled();
77
+ expect(mockHideWindow).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it('keeps the button enabled when disabled returns false', () => {
81
+ renderButton({ disabled: () => false });
82
+ expect(screen.getByRole('button')).toBeEnabled();
83
+ });
84
+
85
+ it('supports hidden and disabled on tablet layout', () => {
86
+ mockUseLayoutType.mockReturnValue('tablet');
87
+ const { unmount } = renderButton({ disabled: () => true });
88
+ expect(screen.getByRole('button')).toBeDisabled();
89
+ unmount();
90
+
91
+ renderButton({ hidden: () => true });
92
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
93
+ });
94
+ });
@@ -1,8 +1,15 @@
1
- import React, { type ReactNode, useEffect, useMemo, useState } from 'react';
1
+ import React, { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
2
2
  import classNames from 'classnames';
3
3
  import Parcel from 'single-spa-react/parcel';
4
4
  import { type ParcelConfig } from 'single-spa';
5
- import { Header, HeaderGlobalAction, HeaderGlobalBar, HeaderName, InlineLoading } from '@carbon/react';
5
+ import {
6
+ Header,
7
+ HeaderGlobalAction,
8
+ HeaderGlobalBar,
9
+ HeaderName,
10
+ InlineLoading,
11
+ InlineNotification,
12
+ } from '@carbon/react';
6
13
  import { DownToBottom, Maximize, Minimize } from '@carbon/react/icons';
7
14
  import {
8
15
  type OpenedWindow,
@@ -12,6 +19,7 @@ import {
12
19
  type WorkspaceStoreState2,
13
20
  } from '@openmrs/esm-extensions';
14
21
  import { isDesktop, useLayoutType, useStore } from '@openmrs/esm-react-utils';
22
+ import { reportError } from '@openmrs/esm-error-handling';
15
23
  import { loadLifeCycles } from '@openmrs/esm-routes';
16
24
  import { getCoreTranslation } from '@openmrs/esm-translations';
17
25
  import { ArrowRightIcon, CloseIcon } from '../icons';
@@ -55,28 +63,39 @@ const ActiveWorkspaceWindow: React.FC<WorkspaceWindowProps> = ({
55
63
  // the previous workspace's component until the reload resolves. The parcel
56
64
  // mounts whatever config it first receives, so the wrong component would then
57
65
  // render with the new workspace's props.
58
- const [lifeCyclesByUuid, setLifeCyclesByUuid] = useState<Record<string, ParcelConfig>>({});
66
+ const [lifeCyclesByUuid, setLifeCyclesByUuid] = useState<Record<string, ParcelConfig | null>>({});
59
67
  // Reactive so the effect retries when a workspace registers after mount.
60
68
  const registeredWorkspacesByName = useStore(workspace2Store, selectRegisteredWorkspacesByName);
69
+ // The effect reloads every workspace whenever any of them changes (for example, its title), so
70
+ // track which workspaces have already reported a load failure to report each one only once.
71
+ const reportedFailureUuids = useRef(new Set<string>());
61
72
 
62
73
  useEffect(() => {
63
74
  let cancelled = false;
64
75
  Promise.all(
65
- openedWorkspaces.map(async (openedWorkspace): Promise<[string, ParcelConfig] | null> => {
76
+ openedWorkspaces.map(async (openedWorkspace): Promise<[string, ParcelConfig | null] | null> => {
66
77
  const workspaceDef = registeredWorkspacesByName[openedWorkspace.workspaceName];
67
78
  if (!workspaceDef) {
68
79
  return null;
69
80
  }
70
81
  const { moduleName, component } = workspaceDef;
71
- return [openedWorkspace.uuid, await loadLifeCycles(moduleName, component)];
82
+ try {
83
+ return [openedWorkspace.uuid, await loadLifeCycles(moduleName, component)];
84
+ } catch (error) {
85
+ if (!cancelled && !reportedFailureUuids.current.has(openedWorkspace.uuid)) {
86
+ reportedFailureUuids.current.add(openedWorkspace.uuid);
87
+ reportError(error);
88
+ }
89
+ return [openedWorkspace.uuid, null];
90
+ }
72
91
  }),
73
92
  ).then((entries) => {
74
93
  if (!cancelled) {
75
94
  setLifeCyclesByUuid(
76
- Object.fromEntries(entries.filter((entry): entry is [string, ParcelConfig] => entry != null)),
95
+ Object.fromEntries(entries.filter((entry): entry is [string, ParcelConfig | null] => entry != null)),
77
96
  );
78
97
  }
79
- });
98
+ }, reportError);
80
99
  return () => {
81
100
  cancelled = true;
82
101
  };
@@ -103,7 +122,7 @@ const ActiveWorkspaceWindow: React.FC<WorkspaceWindowProps> = ({
103
122
  };
104
123
 
105
124
  interface ActiveWorkspaceProps {
106
- lifeCycle: ParcelConfig | undefined;
125
+ lifeCycle: ParcelConfig | null | undefined;
107
126
  openedWorkspace: OpenedWorkspace;
108
127
  openedWindow: OpenedWindow;
109
128
  groupProps: Record<string, any> | null;
@@ -207,6 +226,15 @@ const ActiveWorkspace: React.FC<ActiveWorkspaceProps> = ({
207
226
  wrapClassName={classNames(styles.workspaceContent, { [styles.hiddenExtraWorkspace]: hideBehindLeaf })}
208
227
  {...props}
209
228
  />
229
+ ) : lifeCycle === null ? (
230
+ <div className={classNames(styles.workspaceContent, { [styles.hiddenExtraWorkspace]: hideBehindLeaf })}>
231
+ <InlineNotification
232
+ kind="error"
233
+ title={getCoreTranslation('error')}
234
+ subtitle={getCoreTranslation('somethingWentWrongTryReloading')}
235
+ hideCloseButton
236
+ />
237
+ </div>
210
238
  ) : null,
211
239
  [lifeCycle, openedWorkspace.workspaceName, props, hideBehindLeaf],
212
240
  );
@@ -2,12 +2,15 @@ import React from 'react';
2
2
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
  import { act, render, screen } from '@testing-library/react';
4
4
  import { workspace2Store, type OpenedWindow, type OpenedWorkspace } from '@openmrs/esm-extensions';
5
+ import { reportError } from '@openmrs/esm-error-handling';
5
6
  import { loadLifeCycles } from '@openmrs/esm-routes';
6
7
  import ActiveWorkspaceWindow from './active-workspace-window.component';
7
8
  import styles from './workspace2.module.scss';
8
9
  import { type Workspace2InternalProps } from './workspace2.component';
9
10
  import { type WorkspaceWindowActions } from './workspace-window-actions';
10
11
 
12
+ vi.mock('@openmrs/esm-error-handling', () => ({ reportError: vi.fn() }));
13
+
11
14
  vi.mock('@openmrs/esm-routes', () => ({
12
15
  loadLifeCycles: vi.fn(),
13
16
  }));
@@ -186,6 +189,82 @@ describe('ActiveWorkspaceWindow', () => {
186
189
  expect(screen.getByTestId('parcel')).toHaveTextContent('admit-lifecycle');
187
190
  });
188
191
 
192
+ it.each([false, true])(
193
+ 'reports failed loading with renderChrome=%s while rendering other workspaces',
194
+ async (renderChrome) => {
195
+ const error = new Error('Module failed to load');
196
+ mockLoadLifeCycles.mockImplementation((_moduleName, component) =>
197
+ component === 'form' ? Promise.reject(error) : Promise.resolve({ name: 'admit-lifecycle' } as never),
198
+ );
199
+ render(
200
+ renderWindow(
201
+ makeOpenedWindow([
202
+ makeOpenedWorkspace('form-workspace', 'uuid-form'),
203
+ makeOpenedWorkspace('admit-workspace', 'uuid-admit'),
204
+ ]),
205
+ makeActions(),
206
+ renderChrome,
207
+ ),
208
+ );
209
+
210
+ await flushLifeCycles();
211
+
212
+ expect(reportError).toHaveBeenCalledWith(error);
213
+ expect(screen.getByText('Something went wrong. Please try reloading.')).toBeInTheDocument();
214
+ expect(screen.getByTestId('parcel')).toHaveTextContent('admit-lifecycle');
215
+ expect(screen.queryByText('Loading')).not.toBeInTheDocument();
216
+ },
217
+ );
218
+
219
+ it('does not report a failed load for a workspace that has since been replaced', async () => {
220
+ // The form lifecycle rejects only when we release it, after the admit workspace has replaced it.
221
+ let rejectForm: (error: Error) => void;
222
+ mockLoadLifeCycles.mockImplementation((_moduleName, component) => {
223
+ if (component === 'form') {
224
+ return new Promise((_resolve, reject) => {
225
+ rejectForm = reject;
226
+ }) as never;
227
+ }
228
+ return Promise.resolve({ name: 'admit-lifecycle' } as never);
229
+ });
230
+
231
+ const actions = makeActions();
232
+ const { rerender } = render(
233
+ renderWindow(makeOpenedWindow([makeOpenedWorkspace('form-workspace', 'uuid-form')]), actions),
234
+ );
235
+ rerender(renderWindow(makeOpenedWindow([makeOpenedWorkspace('admit-workspace', 'uuid-admit')]), actions));
236
+ await flushLifeCycles();
237
+
238
+ await act(async () => {
239
+ rejectForm(new Error('Module failed to load'));
240
+ await Promise.resolve();
241
+ });
242
+
243
+ expect(reportError).not.toHaveBeenCalled();
244
+ expect(screen.queryByText('Something went wrong. Please try reloading.')).not.toBeInTheDocument();
245
+ expect(screen.getByTestId('parcel')).toHaveTextContent('admit-lifecycle');
246
+ });
247
+
248
+ it('reports a failed load only once when the opened workspaces change', async () => {
249
+ const error = new Error('Module failed to load');
250
+ mockLoadLifeCycles.mockImplementation((_moduleName, component) =>
251
+ component === 'form' ? Promise.reject(error) : Promise.resolve({ name: 'admit-lifecycle' } as never),
252
+ );
253
+ const actions = makeActions();
254
+ const formWorkspace = makeOpenedWorkspace('form-workspace', 'uuid-form');
255
+ const admitWorkspace = makeOpenedWorkspace('admit-workspace', 'uuid-admit');
256
+
257
+ const { rerender } = render(renderWindow(makeOpenedWindow([formWorkspace, admitWorkspace]), actions));
258
+ await flushLifeCycles();
259
+
260
+ // A title change creates a new openedWorkspaces array, which reloads every workspace.
261
+ rerender(renderWindow(makeOpenedWindow([formWorkspace, { ...admitWorkspace, title: 'Admit' }]), actions));
262
+ await flushLifeCycles();
263
+
264
+ expect(mockLoadLifeCycles).toHaveBeenCalledTimes(4);
265
+ expect(reportError).toHaveBeenCalledTimes(1);
266
+ });
267
+
189
268
  it("passes the workspace's registered meta to the parcel via workspaceMeta", async () => {
190
269
  mockLoadLifeCycles.mockResolvedValue({ name: 'form-lifecycle' } as never);
191
270