@openmrs/esm-patient-label-printing-app 11.3.1-patch.9508

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.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +37 -0
  2. package/README.md +3 -0
  3. package/dist/173.js +1 -0
  4. package/dist/173.js.map +1 -0
  5. package/dist/300.js +1 -0
  6. package/dist/326.js +1 -0
  7. package/dist/326.js.map +1 -0
  8. package/dist/336.js +1 -0
  9. package/dist/336.js.map +1 -0
  10. package/dist/350.js +2 -0
  11. package/dist/350.js.LICENSE.txt +39 -0
  12. package/dist/350.js.map +1 -0
  13. package/dist/41.js +2 -0
  14. package/dist/41.js.LICENSE.txt +9 -0
  15. package/dist/41.js.map +1 -0
  16. package/dist/588.js +2 -0
  17. package/dist/588.js.LICENSE.txt +50 -0
  18. package/dist/588.js.map +1 -0
  19. package/dist/913.js +2 -0
  20. package/dist/913.js.LICENSE.txt +32 -0
  21. package/dist/913.js.map +1 -0
  22. package/dist/main.js +1 -0
  23. package/dist/main.js.map +1 -0
  24. package/dist/openmrs-esm-patient-label-printing-app.js +1 -0
  25. package/dist/openmrs-esm-patient-label-printing-app.js.buildmanifest.json +344 -0
  26. package/dist/openmrs-esm-patient-label-printing-app.js.map +1 -0
  27. package/dist/routes.json +1 -0
  28. package/jest.config.js +3 -0
  29. package/package.json +52 -0
  30. package/src/config-schema.ts +13 -0
  31. package/src/declarations.d.ts +4 -0
  32. package/src/hooks/useStickerPdfPrinter.test.tsx +237 -0
  33. package/src/hooks/useStickerPdfPrinter.tsx +93 -0
  34. package/src/index.ts +18 -0
  35. package/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx +62 -0
  36. package/src/print-identifier-sticker/print-identifier-sticker-action-button.scss +3 -0
  37. package/src/print-identifier-sticker/print-identifier-sticker-action-button.test.tsx +127 -0
  38. package/src/routes.json +15 -0
  39. package/translations/en.json +3 -0
  40. package/tsconfig.json +4 -0
  41. package/webpack.config.js +1 -0
@@ -0,0 +1,237 @@
1
+ import { act, renderHook, waitFor } from '@testing-library/react';
2
+ import { useStickerPdfPrinter } from './useStickerPdfPrinter';
3
+
4
+ describe('useStickerPdfPrinter', () => {
5
+ let mockContentWindow: any;
6
+ let afterPrintHandler: (() => void) | null = null;
7
+
8
+ beforeEach(() => {
9
+ afterPrintHandler = null;
10
+
11
+ // Create a mock contentWindow with all required methods
12
+ mockContentWindow = {
13
+ print: jest.fn(),
14
+ focus: jest.fn(),
15
+ addEventListener: jest.fn((event: string, handler: () => void) => {
16
+ if (event === 'afterprint') {
17
+ afterPrintHandler = handler;
18
+ }
19
+ }),
20
+ };
21
+
22
+ // Mock HTMLIFrameElement.prototype.contentWindow to return our mock
23
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
24
+ configurable: true,
25
+ get: () => mockContentWindow,
26
+ });
27
+
28
+ Object.defineProperty(HTMLIFrameElement.prototype, 'src', {
29
+ configurable: true,
30
+ set: function (value) {
31
+ this._src = value;
32
+ // Trigger onload asynchronously to simulate real behavior
33
+ if (this.onload) {
34
+ Promise.resolve().then(() => {
35
+ if (this.onload) {
36
+ this.onload({} as Event);
37
+ }
38
+ });
39
+ }
40
+ },
41
+ get: function () {
42
+ return this._src;
43
+ },
44
+ });
45
+
46
+ // Mock document.hasFocus to support the polling mechanism
47
+ document.hasFocus = jest.fn().mockReturnValue(false);
48
+ });
49
+
50
+ afterEach(() => {
51
+ jest.restoreAllMocks();
52
+ jest.useRealTimers();
53
+ afterPrintHandler = null;
54
+ });
55
+
56
+ const waitForIframeLoad = () => {
57
+ // Wait for next tick to allow iframe onload to trigger
58
+ return new Promise((resolve) => setTimeout(resolve, 0));
59
+ };
60
+
61
+ const triggerPrintCompletion = () => {
62
+ // Simulate the print dialog closing by triggering afterprint event
63
+ if (afterPrintHandler) {
64
+ afterPrintHandler();
65
+ }
66
+ };
67
+
68
+ it('should provide printPdf function and isPrinting state', () => {
69
+ const { result } = renderHook(() => useStickerPdfPrinter());
70
+
71
+ expect(result.current.isPrinting).toBe(false);
72
+ expect(typeof result.current.printPdf).toBe('function');
73
+ });
74
+
75
+ it('should set isPrinting to true when printing starts', () => {
76
+ const { result } = renderHook(() => useStickerPdfPrinter());
77
+
78
+ act(() => {
79
+ result.current.printPdf('http://example.com/test.pdf');
80
+ });
81
+
82
+ expect(result.current.isPrinting).toBe(true);
83
+ });
84
+
85
+ it('should reject concurrent print requests with an error', async () => {
86
+ const { result } = renderHook(() => useStickerPdfPrinter());
87
+
88
+ act(() => {
89
+ result.current.printPdf('http://example.com/test.pdf');
90
+ });
91
+
92
+ await expect(result.current.printPdf('http://example.com/test2.pdf')).rejects.toThrow('Print already in progress');
93
+ });
94
+
95
+ it('should reset isPrinting to false when printing completes', async () => {
96
+ const { result } = renderHook(() => useStickerPdfPrinter());
97
+
98
+ act(() => {
99
+ result.current.printPdf('http://example.com/test.pdf');
100
+ });
101
+
102
+ expect(result.current.isPrinting).toBe(true);
103
+
104
+ // Wait for iframe to load
105
+ await act(async () => {
106
+ await waitForIframeLoad();
107
+ });
108
+
109
+ // Simulate print completion
110
+ act(() => {
111
+ triggerPrintCompletion();
112
+ });
113
+
114
+ await waitFor(() => {
115
+ expect(result.current.isPrinting).toBe(false);
116
+ });
117
+ });
118
+
119
+ it('should return a promise that resolves when printing completes', async () => {
120
+ const { result } = renderHook(() => useStickerPdfPrinter());
121
+
122
+ let resolved = false;
123
+ let printPromise: Promise<void>;
124
+
125
+ act(() => {
126
+ printPromise = result.current.printPdf('http://example.com/test.pdf').then(() => {
127
+ resolved = true;
128
+ });
129
+ });
130
+
131
+ expect(resolved).toBe(false);
132
+
133
+ // Wait for iframe to load
134
+ await act(async () => {
135
+ await waitForIframeLoad();
136
+ });
137
+
138
+ // Simulate print completion
139
+ act(() => {
140
+ triggerPrintCompletion();
141
+ });
142
+
143
+ await waitFor(() => {
144
+ expect(resolved).toBe(true);
145
+ });
146
+
147
+ await printPromise!;
148
+ });
149
+
150
+ it('should allow printing again after previous print completes', async () => {
151
+ const { result } = renderHook(() => useStickerPdfPrinter());
152
+
153
+ // First print
154
+ act(() => {
155
+ result.current.printPdf('http://example.com/test1.pdf');
156
+ });
157
+
158
+ await act(async () => {
159
+ await waitForIframeLoad();
160
+ });
161
+
162
+ act(() => {
163
+ triggerPrintCompletion();
164
+ });
165
+
166
+ await waitFor(() => {
167
+ expect(result.current.isPrinting).toBe(false);
168
+ });
169
+
170
+ // Second print should succeed
171
+ act(() => {
172
+ result.current.printPdf('http://example.com/test2.pdf');
173
+ });
174
+
175
+ expect(result.current.isPrinting).toBe(true);
176
+
177
+ await act(async () => {
178
+ await waitForIframeLoad();
179
+ });
180
+
181
+ act(() => {
182
+ triggerPrintCompletion();
183
+ });
184
+
185
+ await waitFor(() => {
186
+ expect(result.current.isPrinting).toBe(false);
187
+ });
188
+ });
189
+
190
+ it('should reset isPrinting after timeout when print cannot be detected as complete', async () => {
191
+ jest.useFakeTimers();
192
+ const { result } = renderHook(() => useStickerPdfPrinter());
193
+
194
+ act(() => {
195
+ result.current.printPdf('http://example.com/test.pdf');
196
+ });
197
+
198
+ expect(result.current.isPrinting).toBe(true);
199
+
200
+ // Fast-forward time to trigger iframe load, then advance past timeout
201
+ // The iframe onload will be triggered via Promise.resolve() which needs runAllTimers
202
+ await act(async () => {
203
+ await jest.runAllTimersAsync();
204
+ });
205
+
206
+ // Verify timeout mechanism resets isPrinting (afterprint never fired)
207
+ expect(result.current.isPrinting).toBe(false);
208
+ });
209
+
210
+ it('should handle errors gracefully and reset isPrinting state', async () => {
211
+ // Mock contentWindow to return null to simulate an error
212
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
213
+ configurable: true,
214
+ get: () => null,
215
+ });
216
+
217
+ const { result } = renderHook(() => useStickerPdfPrinter());
218
+
219
+ let resolved = false;
220
+ act(() => {
221
+ result.current.printPdf('http://example.com/test.pdf').then(() => {
222
+ resolved = true;
223
+ });
224
+ });
225
+
226
+ // Wait for iframe to attempt loading and trigger error path
227
+ await act(async () => {
228
+ await waitForIframeLoad();
229
+ });
230
+
231
+ await waitFor(() => {
232
+ expect(result.current.isPrinting).toBe(false);
233
+ });
234
+
235
+ expect(resolved).toBe(true);
236
+ });
237
+ });
@@ -0,0 +1,93 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+
4
+ export const useStickerPdfPrinter = () => {
5
+ const { t } = useTranslation();
6
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
7
+ const [isPrinting, setIsPrinting] = useState(false);
8
+
9
+ const printPdf = useCallback(
10
+ (url: string) => {
11
+ if (isPrinting) {
12
+ return Promise.reject(new Error(t('printInProgress', 'Print already in progress')));
13
+ }
14
+
15
+ return new Promise<void>((resolve) => {
16
+ setIsPrinting(true);
17
+
18
+ if (!iframeRef.current) {
19
+ const iframe = document.createElement('iframe');
20
+ iframe.name = 'pdfPrinterFrame';
21
+ iframe.setAttribute('aria-hidden', 'true');
22
+ Object.assign(iframe.style, {
23
+ position: 'fixed',
24
+ width: '0',
25
+ height: '0',
26
+ border: 'none',
27
+ visibility: 'hidden',
28
+ pointerEvents: 'none',
29
+ });
30
+ iframeRef.current = iframe;
31
+ document.body.appendChild(iframe);
32
+ }
33
+
34
+ const iframe = iframeRef.current;
35
+ let hasClosed = false;
36
+
37
+ const handleLoad = () => {
38
+ try {
39
+ const contentWindow = iframe.contentWindow;
40
+ if (!contentWindow) throw new Error('No content window');
41
+
42
+ const cleanup = () => {
43
+ if (hasClosed) return;
44
+ hasClosed = true;
45
+ setIsPrinting(false);
46
+ resolve();
47
+ };
48
+
49
+ try {
50
+ contentWindow.addEventListener('afterprint', cleanup, { once: true });
51
+ } catch (e) {
52
+ // Cross-origin, use polling fallback
53
+ }
54
+
55
+ contentWindow.focus();
56
+ contentWindow.print();
57
+
58
+ let wasFocused = false;
59
+ const pollInterval = setInterval(() => {
60
+ const hasFocus = document.hasFocus();
61
+ if (hasFocus && wasFocused) cleanup();
62
+ if (!hasFocus) wasFocused = true;
63
+ }, 250);
64
+
65
+ setTimeout(cleanup, 30000);
66
+ setTimeout(() => clearInterval(pollInterval), 30000);
67
+ } catch (error) {
68
+ setIsPrinting(false);
69
+ resolve();
70
+ }
71
+ };
72
+
73
+ iframe.onload = handleLoad;
74
+ iframe.onerror = () => {
75
+ setIsPrinting(false);
76
+ resolve();
77
+ };
78
+ iframe.src = url;
79
+ });
80
+ },
81
+ [t, isPrinting],
82
+ );
83
+
84
+ useEffect(() => {
85
+ return () => {
86
+ if (iframeRef.current?.parentNode) {
87
+ iframeRef.current.parentNode.removeChild(iframeRef.current);
88
+ }
89
+ };
90
+ }, []);
91
+
92
+ return { printPdf, isPrinting };
93
+ };
package/src/index.ts ADDED
@@ -0,0 +1,18 @@
1
+ import { defineConfigSchema, getAsyncLifecycle } from '@openmrs/esm-framework';
2
+ import { configSchema } from './config-schema';
3
+
4
+ const moduleName = '@openmrs/esm-patient-label-printing-app';
5
+
6
+ export const importTranslation = require.context('../translations', false, /.json$/, 'lazy');
7
+
8
+ export function startupApp() {
9
+ defineConfigSchema(moduleName, configSchema);
10
+ }
11
+
12
+ export const printIdentifierStickerActionButton = getAsyncLifecycle(
13
+ () => import('./print-identifier-sticker/print-identifier-sticker-action-button.component'),
14
+ {
15
+ featureName: 'patient-actions-slot-print-identifier-sticker-button',
16
+ moduleName,
17
+ },
18
+ );
@@ -0,0 +1,62 @@
1
+ import React, { useCallback, useMemo } from 'react';
2
+ import { useTranslation } from 'react-i18next';
3
+ import { OverflowMenuItem } from '@carbon/react';
4
+ import { showSnackbar, getCoreTranslation, useConfig, UserHasAccess, restBaseUrl } from '@openmrs/esm-framework';
5
+ import styles from './print-identifier-sticker-action-button.scss';
6
+ import { useStickerPdfPrinter } from '../hooks/useStickerPdfPrinter';
7
+ import type { ConfigObject } from '../config-schema';
8
+
9
+ interface PrintIdentifierStickerOverflowMenuItemProps {
10
+ patient: fhir.Patient;
11
+ }
12
+
13
+ const PrintIdentifierStickerOverflowMenuItem: React.FC<PrintIdentifierStickerOverflowMenuItemProps> = ({ patient }) => {
14
+ const { t } = useTranslation();
15
+ const { showPrintIdentifierStickerButton } = useConfig<ConfigObject>();
16
+ const { printPdf, isPrinting } = useStickerPdfPrinter();
17
+
18
+ const isVisible = useMemo(() => {
19
+ if (!patient?.id) return false;
20
+ return showPrintIdentifierStickerButton;
21
+ }, [showPrintIdentifierStickerButton, patient?.id]);
22
+
23
+ const getPdfUrl = useCallback(() => {
24
+ if (!patient?.id) {
25
+ throw new Error(t('patientIdNotFound', 'Patient ID not found'));
26
+ }
27
+ return `${window.openmrsBase}${restBaseUrl}/patientdocuments/patientIdSticker?patientUuid=${patient.id}`;
28
+ }, [patient?.id, t]);
29
+
30
+ const handlePrint = useCallback(async () => {
31
+ if (isPrinting) return;
32
+
33
+ try {
34
+ await printPdf(getPdfUrl());
35
+ } catch (error) {
36
+ const errorMessage = error instanceof Error ? error.message : String(error);
37
+ showSnackbar({
38
+ kind: 'error',
39
+ title: getCoreTranslation('printError', 'Print Error'),
40
+ subtitle: getCoreTranslation('printErrorExplainer', '', { errorLocation: errorMessage }),
41
+ });
42
+ }
43
+ }, [getPdfUrl, printPdf, isPrinting]);
44
+
45
+ const buttonText = useMemo(() => {
46
+ return isPrinting
47
+ ? getCoreTranslation('printing', 'Printing...')
48
+ : getCoreTranslation('printIdentifierSticker', 'Print identifier sticker');
49
+ }, [isPrinting]);
50
+
51
+ if (!isVisible) {
52
+ return null;
53
+ }
54
+
55
+ return (
56
+ <UserHasAccess privilege="App: Can generate a Patient Identity Sticker">
57
+ <OverflowMenuItem className={styles.menuitem} itemText={buttonText} onClick={handlePrint} disabled={isPrinting} />
58
+ </UserHasAccess>
59
+ );
60
+ };
61
+
62
+ export default PrintIdentifierStickerOverflowMenuItem;
@@ -0,0 +1,3 @@
1
+ .menuitem {
2
+ max-width: none;
3
+ }
@@ -0,0 +1,127 @@
1
+ import React from 'react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import { screen } from '@testing-library/react';
4
+ import { getDefaultsFromConfigSchema, showSnackbar, useConfig, UserHasAccess } from '@openmrs/esm-framework';
5
+ import { mockFhirPatient } from '__mocks__';
6
+ import { renderWithSwr } from 'tools';
7
+ import { useStickerPdfPrinter } from '../hooks/useStickerPdfPrinter';
8
+ import { configSchema, type ConfigObject } from '../config-schema';
9
+ import PrintIdentifierStickerOverflowMenuItem from './print-identifier-sticker-action-button.component';
10
+
11
+ jest.mock('../hooks/useStickerPdfPrinter');
12
+ jest.mock('@openmrs/esm-framework', () => ({
13
+ ...jest.requireActual('@openmrs/esm-framework'),
14
+ UserHasAccess: jest.fn(({ children }) => children),
15
+ }));
16
+
17
+ const mockUseConfig = jest.mocked(useConfig<ConfigObject>);
18
+ const mockShowSnackbar = jest.mocked(showSnackbar);
19
+ const mockUseStickerPdfPrinter = jest.mocked(useStickerPdfPrinter);
20
+ const mockUserHasAccess = jest.mocked(UserHasAccess);
21
+ const mockPrintPdf = jest.fn();
22
+
23
+ describe('PrintIdentifierStickerOverflowMenuItem', () => {
24
+ beforeEach(() => {
25
+ mockUseConfig.mockReturnValue({
26
+ ...getDefaultsFromConfigSchema(configSchema),
27
+ showPrintIdentifierStickerButton: true,
28
+ } as ConfigObject);
29
+ mockPrintPdf.mockResolvedValue(undefined);
30
+ mockUseStickerPdfPrinter.mockReturnValue({
31
+ printPdf: mockPrintPdf,
32
+ isPrinting: false,
33
+ });
34
+ });
35
+
36
+ it('renders the print button when enabled in config', () => {
37
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
38
+
39
+ expect(screen.getByRole('menuitem', { name: /print identifier sticker/i })).toBeInTheDocument();
40
+ });
41
+
42
+ it('does not render the button when disabled in config', () => {
43
+ mockUseConfig.mockReturnValue({
44
+ ...getDefaultsFromConfigSchema(configSchema),
45
+ showPrintIdentifierStickerButton: false,
46
+ } as ConfigObject);
47
+
48
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
49
+
50
+ expect(screen.queryByRole('menuitem', { name: /print identifier sticker/i })).not.toBeInTheDocument();
51
+ });
52
+
53
+ it('does not render the button when patient ID is missing', () => {
54
+ const patientWithoutId = { ...mockFhirPatient, id: undefined } as fhir.Patient;
55
+
56
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={patientWithoutId} />);
57
+
58
+ expect(screen.queryByRole('menuitem', { name: /print identifier sticker/i })).not.toBeInTheDocument();
59
+ });
60
+
61
+ it('triggers print when button is clicked', async () => {
62
+ const user = userEvent.setup();
63
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
64
+
65
+ const printButton = screen.getByRole('menuitem', { name: /print identifier sticker/i });
66
+ await user.click(printButton);
67
+
68
+ expect(mockPrintPdf).toHaveBeenCalledTimes(1);
69
+ expect(mockPrintPdf).toHaveBeenCalledWith(expect.stringContaining(mockFhirPatient.id));
70
+ });
71
+
72
+ it('shows error notification when print fails', async () => {
73
+ const user = userEvent.setup();
74
+ const errorMessage = 'Network error';
75
+ mockPrintPdf.mockRejectedValueOnce(new Error(errorMessage));
76
+
77
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
78
+
79
+ const printButton = screen.getByRole('menuitem', { name: /print identifier sticker/i });
80
+ await user.click(printButton);
81
+
82
+ expect(mockShowSnackbar).toHaveBeenCalledWith({
83
+ kind: 'error',
84
+ title: 'Print error',
85
+ subtitle: expect.stringContaining(errorMessage),
86
+ });
87
+ });
88
+
89
+ it('shows loading state when printing', () => {
90
+ mockUseStickerPdfPrinter.mockReturnValue({
91
+ printPdf: mockPrintPdf,
92
+ isPrinting: true,
93
+ });
94
+
95
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
96
+
97
+ const printButton = screen.getByRole('menuitem', { name: /printing/i });
98
+ expect(printButton).toBeInTheDocument();
99
+ expect(printButton).toBeDisabled();
100
+ });
101
+
102
+ it('prevents multiple print calls when already printing', async () => {
103
+ const user = userEvent.setup();
104
+ mockUseStickerPdfPrinter.mockReturnValue({
105
+ printPdf: mockPrintPdf,
106
+ isPrinting: true,
107
+ });
108
+
109
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
110
+
111
+ const printButton = screen.getByRole('menuitem', { name: /printing/i });
112
+ await user.click(printButton);
113
+
114
+ expect(mockPrintPdf).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it('checks for the correct privilege when rendering', () => {
118
+ renderWithSwr(<PrintIdentifierStickerOverflowMenuItem patient={mockFhirPatient} />);
119
+
120
+ expect(mockUserHasAccess).toHaveBeenCalledWith(
121
+ expect.objectContaining({
122
+ privilege: 'App: Can generate a Patient Identity Sticker',
123
+ }),
124
+ expect.anything(),
125
+ );
126
+ });
127
+ });
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://json.openmrs.org/routes.schema.json",
3
+ "backendDependencies": {
4
+ "patientdocuments": "^1.0.0-SNAPSHOT"
5
+ },
6
+ "extensions": [
7
+ {
8
+ "name": "print-identifier-sticker-button",
9
+ "slot": "patient-actions-slot",
10
+ "component": "printIdentifierStickerActionButton",
11
+ "online": true,
12
+ "offline": true
13
+ }
14
+ ]
15
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "patientIdNotFound": "Patient ID not found"
3
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*", "../../tools/setup-tests.ts"],
4
+ }
@@ -0,0 +1 @@
1
+ module.exports = require('openmrs/default-webpack-config');