@openmrs/esm-patient-common-lib 13.0.2-pre.12852 → 13.0.2-pre.12863

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openmrs/esm-patient-common-lib",
3
- "version": "13.0.2-pre.12852",
3
+ "version": "13.0.2-pre.12863",
4
4
  "license": "MPL-2.0",
5
5
  "description": "Library for common patient chart components",
6
6
  "browser": "dist/openmrs-esm-patient-common-lib.js",
@@ -1,4 +1,5 @@
1
1
  import type { Encounter, OpenmrsResource, Visit, Workspace2DefinitionProps } from '@openmrs/esm-framework';
2
+ import type { PatientWorkspaceWindowProps } from '../../workspaces';
2
3
 
3
4
  export interface Concept extends OpenmrsResource {
4
5
  name?: {
@@ -321,29 +322,36 @@ export interface TestOrderBasketItem extends OrderBasketItem {
321
322
  specimenSource?: string;
322
323
  }
323
324
 
324
- export interface OrderBasketWindowProps {
325
- encounterUuid: string;
326
- onOrderBasketSubmitted?: (encounterUuid: string, postedOrders: Array<Order>) => void;
327
- }
328
-
329
- export interface ExportedOrderBasketWindowProps {
330
- encounterUuid: string;
331
- drugOrderWorkspaceName: string;
332
- labOrderWorkspaceName: string;
333
- generalOrderWorkspaceName: string;
325
+ /**
326
+ * Window props of the order basket window (`patient-chart-order-basket`), taken by the order basket
327
+ * and the add drug / lab / general order workspaces. In the patient chart, the patient / visit are
328
+ * supplied by `getPatientChartWindowProps`; other apps supply them themselves, along with the
329
+ * names of the child workspaces they have registered into the window.
330
+ */
331
+ export interface OrderBasketWindowProps extends PatientWorkspaceWindowProps {
332
+ /**
333
+ * Names of the workspaces the order basket launches as child workspaces. Optional: if none of the
334
+ * three is supplied, the patient chart's own `add-drug-order`, `add-lab-order` and
335
+ * `orderable-concept-workspace` are used. If any is supplied, only the supplied ones are launchable,
336
+ * so a host can limit the basket to the order types it registered workspaces for.
337
+ */
338
+ drugOrderWorkspaceName?: string;
339
+ labOrderWorkspaceName?: string;
340
+ generalOrderWorkspaceName?: string;
334
341
  /**
335
342
  * Name of a workspace, registered by the host into the order basket window, that renders the
336
343
  * allergy form. Supplied when the order basket runs outside the patient chart so the allergy
337
344
  * "+" affordance can launch the form in the host's workspace group instead of the chart's.
338
345
  */
339
346
  allergyFormWorkspaceName?: string;
340
- patient: fhir.Patient;
341
- patientUuid: string;
342
- visitContext: Visit;
343
- mutateVisitContext: () => void;
344
347
  onOrderBasketSubmitted?: (encounterUuid: string, postedOrders: Array<Order>) => void;
345
348
  /**
346
349
  * An optional array of order type UUIDs to display. If not provided, all panels are shown.
347
350
  */
348
351
  visibleOrderPanels?: Array<string>;
349
352
  }
353
+
354
+ /**
355
+ * @deprecated Use `OrderBasketWindowProps`.
356
+ */
357
+ export type ExportedOrderBasketWindowProps = OrderBasketWindowProps;
@@ -1,4 +1,5 @@
1
1
  import { type Actions, createGlobalStore, useStoreWithActions, type Visit } from '@openmrs/esm-framework';
2
+ import { type PatientWorkspaceGroupProps } from '../workspaces';
2
3
 
3
4
  export interface PatientChartStore {
4
5
  patientUuid: string;
@@ -8,10 +9,16 @@ export interface PatientChartStore {
8
9
  /**
9
10
  * The uuid of the visit context the patient-chart workspace group was last launched with.
10
11
  * Note that when the visit context is changed, the workspaces group (with stale visit context)
11
- * must close and reopen, and during that process its visitContext might differ from
12
+ * must close and reopen, and during that process its visitContext might differ from
12
13
  * `store.visitContext`
13
14
  */
14
15
  workspaceGroupVisitUuid?: string | null;
16
+ /**
17
+ * The group props the patient-chart workspace group was last launched with. The workspace system
18
+ * only relaunches the group when the patient or visit uuid changes, so these objects can be older
19
+ * than `patient` / `visitContext` above. See `getPatientChartWindowProps`.
20
+ */
21
+ workspaceGroupProps?: PatientWorkspaceGroupProps | null;
15
22
  }
16
23
 
17
24
  const patientChartStoreName = 'patient-chart-global-store';
@@ -22,6 +29,7 @@ const patientChartStore = createGlobalStore<PatientChartStore>(patientChartStore
22
29
  visitContext: null,
23
30
  mutateVisitContext: null,
24
31
  workspaceGroupVisitUuid: null,
32
+ workspaceGroupProps: null,
25
33
  });
26
34
 
27
35
  const patientChartStoreActions = {
@@ -37,8 +45,28 @@ const patientChartStoreActions = {
37
45
  * Records the visit context the patient-chart workspace group was last launched with.
38
46
  * Only the patient chart should call this, after it launches its workspace group.
39
47
  */
40
- export function setPatientChartWorkspaceGroupVisitUuid(workspaceGroupVisitUuid: string | null) {
41
- patientChartStore.setState({ workspaceGroupVisitUuid });
48
+ export function setPatientChartWorkspaceGroupVisitUuid(
49
+ workspaceGroupVisitUuid: string | null,
50
+ workspaceGroupProps?: PatientWorkspaceGroupProps | null,
51
+ ) {
52
+ patientChartStore.setState(
53
+ workspaceGroupProps === undefined ? { workspaceGroupVisitUuid } : { workspaceGroupVisitUuid, workspaceGroupProps },
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Non-reactive read of the patient chart store, for use inside callbacks (e.g. at the moment a
59
+ * workspace is launched). Like `usePatientChartStore`, it only returns the store values if
60
+ * `patientUuid` matches the patient in the store; otherwise it returns null. If `patientUuid` is
61
+ * omitted, it returns the store values of whichever patient the patient chart currently has open
62
+ * (or null if it has none).
63
+ */
64
+ export function getPatientChartStoreState(patientUuid?: string): PatientChartStore | null {
65
+ const state = patientChartStore.getState();
66
+ if (patientUuid === undefined) {
67
+ return state.patientUuid ? state : null;
68
+ }
69
+ return state.patientUuid === patientUuid ? state : null;
42
70
  }
43
71
 
44
72
  /**
@@ -65,6 +93,7 @@ export function usePatientChartStore(patientUuid: string) {
65
93
  patientUuid: null,
66
94
  visitContext: null,
67
95
  workspaceGroupVisitUuid: null,
96
+ workspaceGroupProps: null,
68
97
  };
69
98
  return fakeStore;
70
99
  }
@@ -1,9 +1,16 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
2
  import { act, renderHook } from '@testing-library/react';
3
- import { showModal, useFeatureFlag, type Visit } from '@openmrs/esm-framework';
3
+ import { launchWorkspace2, showModal, useFeatureFlag, useLayoutType, type Visit } from '@openmrs/esm-framework';
4
4
  import { setPatientChartWorkspaceGroupVisitUuid, usePatientChartStore } from './store/patient-chart-store';
5
5
  import { useSystemVisitSetting } from './useSystemVisitSetting';
6
- import { useStartVisitIfNeeded } from './workspaces';
6
+ import {
7
+ getPatientAndVisitProps,
8
+ getPatientChartWindowProps,
9
+ useActionMenuButtonLaunchProps,
10
+ useLaunchWorkspaceRequiringVisit,
11
+ useStartVisitIfNeeded,
12
+ useShowPatientBanner,
13
+ } from './workspaces';
7
14
 
8
15
  vi.mock('./useSystemVisitSetting', () => ({
9
16
  useSystemVisitSetting: vi.fn(),
@@ -167,3 +174,277 @@ describe('useStartVisitIfNeeded', () => {
167
174
  await expect(promise).resolves.toBe(false);
168
175
  });
169
176
  });
177
+
178
+ describe('getPatientChartWindowProps', () => {
179
+ const mutateVisitContext = vi.fn();
180
+
181
+ afterEach(() => {
182
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
183
+ act(() => {
184
+ result.current.setVisitContext(null, null);
185
+ result.current.setPatient(null);
186
+ setPatientChartWorkspaceGroupVisitUuid(null, null);
187
+ });
188
+ });
189
+
190
+ it('returns the props the workspace group was launched with, not the latest store values', () => {
191
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
192
+ const groupProps = {
193
+ patient,
194
+ patientUuid: patient.id,
195
+ visitContext: visit,
196
+ mutateVisitContext,
197
+ showPatientBanner: 'tabletOnly' as const,
198
+ };
199
+ act(() => result.current.setPatient(patient));
200
+ act(() => {
201
+ result.current.setVisitContext({ ...visit }, vi.fn());
202
+ setPatientChartWorkspaceGroupVisitUuid(visit.uuid, groupProps);
203
+ });
204
+
205
+ expect(getPatientChartWindowProps(patient.id)).toEqual({ patient, patientUuid: patient.id, visitContext: visit });
206
+ expect(getPatientChartWindowProps(patient.id).visitContext).toBe(visit);
207
+ });
208
+
209
+ it('falls back to the store values when no workspace group was launched', () => {
210
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
211
+ act(() => result.current.setPatient(patient));
212
+ act(() => result.current.setVisitContext(visit, mutateVisitContext));
213
+
214
+ expect(getPatientChartWindowProps(patient.id)).toEqual({
215
+ patient,
216
+ patientUuid: patient.id,
217
+ visitContext: visit,
218
+ });
219
+ });
220
+
221
+ it('ignores group props that belong to the previous patient', () => {
222
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
223
+ const previousPatientGroupProps = {
224
+ patient: { ...patient, id: 'previous-patient-uuid' },
225
+ patientUuid: 'previous-patient-uuid',
226
+ visitContext: visit,
227
+ mutateVisitContext,
228
+ showPatientBanner: 'tabletOnly' as const,
229
+ };
230
+ act(() => result.current.setPatient(patient));
231
+ act(() => setPatientChartWorkspaceGroupVisitUuid(visit.uuid, previousPatientGroupProps));
232
+
233
+ expect(getPatientChartWindowProps(patient.id)).toEqual({
234
+ patient,
235
+ patientUuid: patient.id,
236
+ visitContext: null,
237
+ });
238
+ });
239
+
240
+ it("does not return another patient's chart context", () => {
241
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
242
+ act(() => result.current.setPatient(patient));
243
+ act(() => result.current.setVisitContext(visit, mutateVisitContext));
244
+
245
+ expect(getPatientChartWindowProps('other-patient-uuid')).toEqual({
246
+ patient: null,
247
+ patientUuid: 'other-patient-uuid',
248
+ visitContext: null,
249
+ });
250
+ });
251
+ });
252
+
253
+ describe('getPatientAndVisitProps', () => {
254
+ it('prefers window props over the deprecated workspace props', () => {
255
+ const otherPatient = { id: 'other' } as fhir.Patient;
256
+ expect(
257
+ getPatientAndVisitProps(
258
+ { patient, patientUuid: patient.id, visitContext: visit },
259
+ { patient: otherPatient, patientUuid: 'other', visitContext: null },
260
+ ),
261
+ ).toEqual({ patient, patientUuid: patient.id, visitContext: visit });
262
+ });
263
+
264
+ it("falls back to the patient's id when only a patient is given", () => {
265
+ expect(getPatientAndVisitProps({ patient, visitContext: visit }, undefined)).toEqual({
266
+ patient,
267
+ patientUuid: patient.id,
268
+ visitContext: visit,
269
+ });
270
+ });
271
+
272
+ it('falls back to workspace props when there are no window props', () => {
273
+ expect(getPatientAndVisitProps(null, { patient, patientUuid: patient.id, visitContext: visit })).toEqual({
274
+ patient,
275
+ patientUuid: patient.id,
276
+ visitContext: visit,
277
+ });
278
+ });
279
+
280
+ it('falls back to group props when a launch supplies neither window props nor workspace props', () => {
281
+ expect(getPatientAndVisitProps(null, undefined, { patient, patientUuid: patient.id, visitContext: visit })).toEqual(
282
+ { patient, patientUuid: patient.id, visitContext: visit },
283
+ );
284
+ });
285
+
286
+ it("doesn't mix the group's visit into a patient given by the window", () => {
287
+ expect(
288
+ getPatientAndVisitProps({ patient, patientUuid: patient.id, visitContext: null }, undefined, {
289
+ patient,
290
+ patientUuid: patient.id,
291
+ visitContext: visit,
292
+ }),
293
+ ).toEqual({ patient, patientUuid: patient.id, visitContext: null });
294
+ });
295
+
296
+ it('prefers workspace props over group props', () => {
297
+ const otherPatient = { id: 'other' } as fhir.Patient;
298
+ expect(
299
+ getPatientAndVisitProps(
300
+ undefined,
301
+ { patient, patientUuid: patient.id, visitContext: visit },
302
+ { patient: otherPatient, patientUuid: 'other', visitContext: null },
303
+ ),
304
+ ).toEqual({ patient, patientUuid: patient.id, visitContext: visit });
305
+ });
306
+ });
307
+
308
+ describe('useLaunchWorkspaceRequiringVisit', () => {
309
+ const mockLaunchWorkspace2 = vi.mocked(launchWorkspace2);
310
+
311
+ beforeEach(() => {
312
+ mockUseSystemVisitSetting.mockReturnValue({
313
+ systemVisitEnabled: false,
314
+ errorFetchingSystemVisitSetting: null,
315
+ isLoadingSystemVisitSetting: false,
316
+ });
317
+ });
318
+
319
+ afterEach(() => {
320
+ const { result } = renderHook(() => usePatientChartStore(patient.id));
321
+ act(() => {
322
+ result.current.setVisitContext(null, null);
323
+ result.current.setPatient(null);
324
+ setPatientChartWorkspaceGroupVisitUuid(null, null);
325
+ });
326
+ mockLaunchWorkspace2.mockClear();
327
+ });
328
+
329
+ it('supplies the patient chart window props, which the caller can override', async () => {
330
+ const { result } = renderHook(() => ({
331
+ store: usePatientChartStore(patient.id),
332
+ launch: useLaunchWorkspaceRequiringVisit(patient.id, 'some-workspace'),
333
+ }));
334
+ act(() => result.current.store.setPatient(patient));
335
+ act(() => result.current.store.setVisitContext(visit, null));
336
+
337
+ await act(async () => result.current.launch({ a: 1 } as any, { extra: true }));
338
+
339
+ await vi.waitFor(() => expect(mockLaunchWorkspace2).toHaveBeenCalledTimes(1));
340
+ expect(mockLaunchWorkspace2).toHaveBeenCalledWith(
341
+ 'some-workspace',
342
+ { a: 1 },
343
+ { patient, patientUuid: patient.id, visitContext: visit, extra: true },
344
+ undefined,
345
+ );
346
+ });
347
+
348
+ it('supplies the window props of the visit started by the prompt, not the visit-less props from before it', async () => {
349
+ mockUseSystemVisitSetting.mockReturnValue({
350
+ systemVisitEnabled: true,
351
+ errorFetchingSystemVisitSetting: null,
352
+ isLoadingSystemVisitSetting: false,
353
+ });
354
+ mockUseFeatureFlag.mockReturnValue(false);
355
+ mockShowModal.mockReturnValue(vi.fn());
356
+ const groupPropsWithoutVisit = {
357
+ patient,
358
+ patientUuid: patient.id,
359
+ visitContext: null,
360
+ mutateVisitContext: null,
361
+ showPatientBanner: 'tabletOnly' as const,
362
+ };
363
+ const { result } = renderHook(() => ({
364
+ store: usePatientChartStore(patient.id),
365
+ launch: useActionMenuButtonLaunchProps(groupPropsWithoutVisit, 'some-workspace'),
366
+ }));
367
+ act(() => result.current.store.setPatient(patient));
368
+ act(() => setPatientChartWorkspaceGroupVisitUuid(null, groupPropsWithoutVisit));
369
+
370
+ // The button holds the props of the render it was clicked in, and does not wait for the launch to happen
371
+ expect(result.current.launch.workspaceToLaunch.windowProps.visitContext).toBeNull();
372
+ let shouldLaunch: Promise<boolean>;
373
+ act(() => {
374
+ shouldLaunch = result.current.launch.onBeforeWorkspaceLaunch();
375
+ });
376
+ const { onVisitStarted } = getModalProps('start-visit-dialog');
377
+
378
+ // The visit is started, and the chart relaunches its workspace group with it
379
+ await act(async () => {
380
+ result.current.store.setVisitContext(visit, null);
381
+ onVisitStarted();
382
+ });
383
+ expect(mockLaunchWorkspace2).not.toHaveBeenCalled();
384
+ const groupPropsWithVisit = {
385
+ patient,
386
+ patientUuid: patient.id,
387
+ visitContext: visit,
388
+ mutateVisitContext: null,
389
+ showPatientBanner: 'tabletOnly' as const,
390
+ };
391
+ await act(async () => setPatientChartWorkspaceGroupVisitUuid(visit.uuid, groupPropsWithVisit));
392
+
393
+ await expect(shouldLaunch).resolves.toBe(false);
394
+ await vi.waitFor(() => expect(mockLaunchWorkspace2).toHaveBeenCalledTimes(1));
395
+ const windowProps = mockLaunchWorkspace2.mock.calls[0][2] as Record<string, unknown>;
396
+ expect(windowProps).toMatchObject({ patientUuid: patient.id });
397
+ expect(windowProps.visitContext).toBe(visit);
398
+ });
399
+
400
+ it('lets the action menu button launch with its own window props when the visit did not change', async () => {
401
+ mockUseSystemVisitSetting.mockReturnValue({
402
+ systemVisitEnabled: true,
403
+ errorFetchingSystemVisitSetting: null,
404
+ isLoadingSystemVisitSetting: false,
405
+ });
406
+ const groupPropsWithVisit = {
407
+ patient,
408
+ patientUuid: patient.id,
409
+ visitContext: visit,
410
+ mutateVisitContext: null,
411
+ showPatientBanner: 'tabletOnly' as const,
412
+ };
413
+ const { result } = renderHook(() => ({
414
+ store: usePatientChartStore(patient.id),
415
+ launch: useActionMenuButtonLaunchProps(groupPropsWithVisit, 'some-workspace'),
416
+ }));
417
+ act(() => result.current.store.setPatient(patient));
418
+ act(() => result.current.store.setVisitContext(visit, null));
419
+ act(() => setPatientChartWorkspaceGroupVisitUuid(visit.uuid, groupPropsWithVisit));
420
+
421
+ await expect(result.current.launch.onBeforeWorkspaceLaunch()).resolves.toBe(true);
422
+ expect(mockLaunchWorkspace2).not.toHaveBeenCalled();
423
+ expect(result.current.launch.workspaceToLaunch).toMatchObject({
424
+ workspaceName: 'some-workspace',
425
+ windowProps: { patient, patientUuid: patient.id, visitContext: visit },
426
+ });
427
+ });
428
+ });
429
+
430
+ describe('useShowPatientBanner', () => {
431
+ const mockUseLayoutType = vi.mocked(useLayoutType);
432
+
433
+ function show(groupProps: object | null) {
434
+ return renderHook(() => useShowPatientBanner(groupProps)).result.current;
435
+ }
436
+
437
+ it("shows the banner at every width by default, as ward and other hosts' group props don't set a mode", () => {
438
+ mockUseLayoutType.mockReturnValue('large-desktop');
439
+ expect(show({})).toBe(true);
440
+ expect(show({ wardPatient: {} })).toBe(true);
441
+ expect(show(null)).toBe(true);
442
+ });
443
+
444
+ it("shows the banner on tablet only when the group asks for 'tabletOnly'", () => {
445
+ mockUseLayoutType.mockReturnValue('large-desktop');
446
+ expect(show({ showPatientBanner: 'tabletOnly' })).toBe(false);
447
+ mockUseLayoutType.mockReturnValue('tablet');
448
+ expect(show({ showPatientBanner: 'tabletOnly' })).toBe(true);
449
+ });
450
+ });
package/src/workspaces.ts CHANGED
@@ -4,10 +4,11 @@ import {
4
4
  navigate,
5
5
  showModal,
6
6
  useFeatureFlag,
7
+ useLayoutType,
7
8
  type Visit,
8
9
  type Workspace2DefinitionProps,
9
10
  } from '@openmrs/esm-framework';
10
- import { usePatientChartStore } from './store/patient-chart-store';
11
+ import { getPatientChartStoreState, usePatientChartStore } from './store/patient-chart-store';
11
12
  import { useSystemVisitSetting } from './useSystemVisitSetting';
12
13
 
13
14
  export interface PatientWorkspaceGroupProps {
@@ -15,6 +16,136 @@ export interface PatientWorkspaceGroupProps {
15
16
  patientUuid: string;
16
17
  visitContext: Visit;
17
18
  mutateVisitContext: () => void;
19
+ /**
20
+ * When the workspaces of this group render the patient banner themselves. The patient chart has its own
21
+ * patient header on desktop, so every group props object it builds sets `'tabletOnly'` (required here so
22
+ * that a launch cannot forget it). Other apps' workspace groups don't set it, which means `'always'`.
23
+ * See `useShowPatientBanner`.
24
+ */
25
+ showPatientBanner: PatientBannerMode;
26
+ }
27
+
28
+ export type PatientBannerMode = 'always' | 'tabletOnly';
29
+
30
+ /**
31
+ * The only part of the group props that workspaces shared between the patient chart and other apps
32
+ * may read. Other apps' workspace groups have group props of their own, so everything but this
33
+ * is meaningless to a shared workspace.
34
+ */
35
+ export type PatientBannerGroupProps = Partial<Pick<PatientWorkspaceGroupProps, 'showPatientBanner'>>;
36
+
37
+ /**
38
+ * What a shared workspace may read from its group props: the banner mode, and, as a last resort for
39
+ * launches that supply no window props, the patient / visit of the patient chart's group (see
40
+ * `getPatientAndVisitProps`).
41
+ */
42
+ export type SharedWorkspaceGroupProps = PatientBannerGroupProps &
43
+ Partial<Pick<PatientWorkspaceGroupProps, 'patient' | 'patientUuid' | 'visitContext'>>;
44
+
45
+ /**
46
+ * Whether a workspace should render the patient banner itself, as asked for by its workspace group.
47
+ */
48
+ export function useShowPatientBanner(groupProps: PatientBannerGroupProps | null | undefined): boolean {
49
+ const isTablet = useLayoutType() === 'tablet';
50
+ const showPatientBanner: PatientBannerMode = groupProps?.showPatientBanner ?? 'always';
51
+ return showPatientBanner === 'always' || isTablet;
52
+ }
53
+
54
+ /**
55
+ * The window props taken by workspaces that are shared between the patient chart and other apps
56
+ * (ward, service queues, dispensing, ...). These workspaces never read group props: whoever opens
57
+ * the window supplies the patient / visit the workspace acts on. In the patient chart, use
58
+ * `getPatientChartWindowProps` to build them.
59
+ */
60
+ export interface PatientWorkspaceWindowProps {
61
+ patient: fhir.Patient;
62
+ patientUuid: string;
63
+ visitContext: Visit;
64
+ }
65
+
66
+ /**
67
+ * Window props for the forms dashboard / form entry workspaces (window `patient-chart-clinical-forms`).
68
+ */
69
+ export interface ClinicalFormsWindowProps extends PatientWorkspaceWindowProps {
70
+ /**
71
+ * Name of the form entry workspace the forms dashboard launches as a child workspace.
72
+ * Defaults to the patient chart's `patient-form-entry-workspace`.
73
+ */
74
+ formEntryWorkspaceName?: string;
75
+ }
76
+
77
+ /**
78
+ * The patient / visit props that vitals, visit notes, lab results and the visit form used to take as
79
+ * workspace props. They now take them as window props; the workspace-prop versions are only read
80
+ * as a fallback for callers that have not migrated yet.
81
+ * @deprecated Pass `patient` / `patientUuid` / `visitContext` as window props instead.
82
+ */
83
+ export type DeprecatedPatientWorkspaceProps = Partial<
84
+ Pick<PatientWorkspaceWindowProps, 'patient' | 'patientUuid' | 'visitContext'>
85
+ >;
86
+
87
+ /**
88
+ * Reads the patient / visit a workspace acts on: window props first, falling back to the
89
+ * deprecated workspace props. The deprecated fallback lives here, so it can be deleted once the
90
+ * apps that still pass workspace props (ward, service queues, appointments, patient lists, lab app) have
91
+ * migrated to window props.
92
+ *
93
+ * Only if the launch supplied no patient at all (neither window props nor workspace props), the group
94
+ * props are used as a whole, patient and visit together. The group's visit is never mixed in with a
95
+ * patient given by the window, as a `visitContext` of null there means "no visit".
96
+ *
97
+ * Don't pass group props for a workspace whose `visitContext` window prop means something other than
98
+ * "the visit the user is working in", such as the visit form, where null means "start a new visit".
99
+ */
100
+ export function getPatientAndVisitProps(
101
+ windowProps: Partial<PatientWorkspaceWindowProps> | null | undefined,
102
+ workspaceProps: DeprecatedPatientWorkspaceProps | null | undefined,
103
+ groupProps?: SharedWorkspaceGroupProps | null,
104
+ ): Pick<PatientWorkspaceWindowProps, 'patient' | 'patientUuid' | 'visitContext'> {
105
+ // Only `undefined` falls through to the next source: a null `visitContext` means "no visit"
106
+ const patient = windowProps?.patient !== undefined ? windowProps.patient : workspaceProps?.patient;
107
+ const patientUuid = windowProps?.patientUuid !== undefined ? windowProps.patientUuid : workspaceProps?.patientUuid;
108
+ const visitContext =
109
+ windowProps?.visitContext !== undefined ? windowProps.visitContext : workspaceProps?.visitContext;
110
+ if (patient || patientUuid) {
111
+ return { patient, patientUuid: patientUuid ?? patient?.id, visitContext };
112
+ }
113
+ return { patient: groupProps?.patient, patientUuid: groupProps?.patientUuid, visitContext: groupProps?.visitContext };
114
+ }
115
+
116
+ /**
117
+ * Converts the group props of the patient chart into window props.
118
+ */
119
+ function groupPropsToWindowProps(groupProps: PatientWorkspaceGroupProps): PatientWorkspaceWindowProps {
120
+ const { patient, patientUuid, visitContext } = groupProps;
121
+ return { patient, patientUuid, visitContext };
122
+ }
123
+
124
+ /**
125
+ * Builds the window props the patient chart supplies when it opens a workspace window, from the
126
+ * group props the workspace group is currently open with. Reads the store when called (not when
127
+ * rendered), so call this at launch time.
128
+ *
129
+ * The group props are used rather than the latest store values because the window props of an open
130
+ * window are compared (shallowly) with the window props of every later launch into that window, and
131
+ * an incompatible launch prompts the user to close the window. The store's `patient` / `visitContext`
132
+ * objects are replaced on every revalidation, while the group props stay the same until the
133
+ * patient or visit changes, so reading the group props keeps launches compatible.
134
+ *
135
+ * If `patientUuid` is omitted, the patient the chart currently has open is used.
136
+ */
137
+ export function getPatientChartWindowProps(patientUuid?: string): PatientWorkspaceWindowProps {
138
+ const state = getPatientChartStoreState(patientUuid);
139
+ // The group props are only replaced once a new group launches, so they may still belong to the
140
+ // previous patient right after the chart switched patients. Don't use them in that case.
141
+ if (state?.workspaceGroupProps && state.workspaceGroupProps.patientUuid === state.patientUuid) {
142
+ return groupPropsToWindowProps(state.workspaceGroupProps);
143
+ }
144
+ return {
145
+ patient: state?.patient ?? null,
146
+ patientUuid: state?.patientUuid ?? patientUuid ?? null,
147
+ visitContext: state?.visitContext ?? null,
148
+ };
18
149
  }
19
150
 
20
151
  export interface PatientChartWorkspaceActionButtonProps {
@@ -31,31 +162,90 @@ export function launchPatientChartWithWorkspaceOpen({
31
162
  workspaceName,
32
163
  dashboardName,
33
164
  additionalProps,
165
+ windowProps,
34
166
  }: {
35
167
  patientUuid: string;
36
168
  workspaceName: string;
37
169
  dashboardName?: string;
38
170
  additionalProps?: object;
171
+ /** Window props to launch the workspace with. Workspaces that need the patient or visit read them from here. */
172
+ windowProps?: object;
39
173
  }) {
40
- launchWorkspace2(workspaceName, additionalProps);
174
+ launchWorkspace2(workspaceName, additionalProps, windowProps);
41
175
  navigate({ to: '${openmrsSpaBase}/patient/' + `${patientUuid}/chart` + (dashboardName ? `/${dashboardName}` : '') });
42
176
  }
43
177
 
178
+ /**
179
+ * Returns a callback that launches the workspace, first prompting the user to start a visit if the
180
+ * patient needs one. The patient / visit window props (see `getPatientChartWindowProps`) are supplied
181
+ * by default; any `windowProps` passed to the callback take precedence.
182
+ */
44
183
  export function useLaunchWorkspaceRequiringVisit<T extends object>(patientUuid: string, workspaceName: string) {
45
184
  const startVisitIfNeeded = useStartVisitIfNeeded(patientUuid);
46
185
  const launchPatientWorkspaceCb = useCallback(
47
186
  (workspaceProps?: T, windowProps?: any, groupProps?: any) => {
48
187
  startVisitIfNeeded().then((didStartVisit) => {
49
188
  if (didStartVisit) {
50
- launchWorkspace2(workspaceName, workspaceProps, windowProps, groupProps);
189
+ // Read at launch time: the visit may have just been started by the prompt above.
190
+ launchWorkspace2(
191
+ workspaceName,
192
+ workspaceProps,
193
+ { ...getPatientChartWindowProps(patientUuid), ...windowProps },
194
+ groupProps,
195
+ );
51
196
  }
52
197
  });
53
198
  },
54
- [startVisitIfNeeded, workspaceName],
199
+ [startVisitIfNeeded, workspaceName, patientUuid],
55
200
  );
56
201
  return launchPatientWorkspaceCb;
57
202
  }
58
203
 
204
+ /**
205
+ * Props for an `ActionMenuButton2` that opens a workspace requiring a visit, bridging the group props
206
+ * the button receives to the window props the workspace takes:
207
+ *
208
+ * ```tsx
209
+ * <ActionMenuButton2 {...useActionMenuButtonLaunchProps(groupProps, 'order-basket')} icon={...} label={...} />
210
+ * ```
211
+ *
212
+ * `ActionMenuButton2` launches with the `workspaceToLaunch` of the render the button was clicked in. If the
213
+ * user starts a visit in the prompt, the chart relaunches its workspace group and the button re-renders, but
214
+ * that click handler still holds the old (visit-less) props. So when the visit changed during the prompt,
215
+ * `onBeforeWorkspaceLaunch` launches the workspace itself with the window props read after the prompt, and
216
+ * returns false so the button does not launch with the stale ones.
217
+ *
218
+ * TODO: remove the second launch path once `ActionMenuButton2` can take window props that are evaluated at
219
+ * launch time.
220
+ */
221
+ export function useActionMenuButtonLaunchProps<T extends object>(
222
+ groupProps: PatientWorkspaceGroupProps,
223
+ workspaceName: string,
224
+ workspaceProps?: T,
225
+ ) {
226
+ const { patientUuid } = groupProps;
227
+ const startVisitIfNeeded = useStartVisitIfNeeded(patientUuid);
228
+ const windowProps = groupPropsToWindowProps(groupProps);
229
+ const visitUuid = windowProps.visitContext?.uuid;
230
+
231
+ const onBeforeWorkspaceLaunch = useCallback(async () => {
232
+ if (!(await startVisitIfNeeded())) {
233
+ return false;
234
+ }
235
+ const currentWindowProps = getPatientChartWindowProps(patientUuid);
236
+ if (currentWindowProps.visitContext?.uuid === visitUuid) {
237
+ return true;
238
+ }
239
+ launchWorkspace2(workspaceName, workspaceProps, currentWindowProps);
240
+ return false;
241
+ }, [startVisitIfNeeded, patientUuid, visitUuid, workspaceName, workspaceProps]);
242
+
243
+ return {
244
+ workspaceToLaunch: { workspaceName, workspaceProps, windowProps },
245
+ onBeforeWorkspaceLaunch,
246
+ };
247
+ }
248
+
59
249
  export function useStartVisitIfNeeded(patientUuid: string) {
60
250
  const { visitContext, workspaceGroupVisitUuid } = usePatientChartStore(patientUuid);
61
251
  const { systemVisitEnabled } = useSystemVisitSetting();
@@ -106,6 +296,7 @@ export function useStartVisitIfNeeded(patientUuid: string) {
106
296
  resolve(false);
107
297
  },
108
298
  onVisitStarted: resolveOnceWorkspaceGroupHasVisit,
299
+ patientUuid,
109
300
  });
110
301
  }
111
302
  });