diff --git a/packages/esm-patient-banner-app/src/config-schema.ts b/packages/esm-patient-banner-app/src/config-schema.ts index cf780704f9..1d41ba22e6 100644 --- a/packages/esm-patient-banner-app/src/config-schema.ts +++ b/packages/esm-patient-banner-app/src/config-schema.ts @@ -12,58 +12,6 @@ export const configSchema = { '14d4f066-15f5-102d-96e4-000c29c2a5d7', ], }, - printPatientSticker: { - header: { - _type: Type.Object, - _description: 'Configuration properties for patient identifier stickers', - showBarcode: { - _type: Type.Boolean, - _description: 'Whether to display a barcode on the patient sticker', - }, - showLogo: { - _type: Type.Boolean, - _description: 'Whether to display a logo on the patient sticker', - }, - logo: { - _type: Type.String, - _description: 'The URL of the logo to display in the patient sticker', - }, - _default: { - showBarcode: true, - showLogo: true, - logo: '', - }, - }, - fields: { - _type: Type.Array, - _elements: { - _type: Type.String, - }, - _description: 'Patient demographics to include in the patient sticker printout', - _default: ['name', 'dob', 'gender', 'identifier', 'age', 'contact', 'address'], - }, - pageSize: { - _type: Type.String, - _description: - 'Specifies the paper size for printing the sticker. You can define the size using units (e.g., mm, in) or named sizes (e.g., "148mm 210mm", "A1", "A2", "A4", "A5").', - _default: 'A4', - }, - printScale: { - _type: Type.String, - _description: - 'Set the scale for the printed content. A value between 0 and 1 shrinks the content, while a value greater than 1 enlarges it. The scale must be greater than 0.', - _default: '1', - }, - identifiersToDisplay: { - _type: Type.Array, - _elements: { - _type: Type.UUID, - }, - _description: - 'List of UUIDs of patient identifier types to include on the patient sticker. If empty, all identifiers will be displayed.', - _default: [], - }, - }, useRelationshipNameLink: { _type: Type.Boolean, _description: "Whether to use the relationship name as a link to the associated person's patient chart.", @@ -71,20 +19,7 @@ export const configSchema = { }, }; -export type AllowedPatientFields = 'address' | 'age' | 'contact' | 'dob' | 'gender' | 'identifier' | 'name'; - export interface ConfigObject { contactAttributeTypes: Array; - printPatientSticker: { - header: { - showBarcode: boolean; - showLogo: boolean; - logo: string; - }; - fields: Array; - pageSize: string; - printScale: string; - identifiersToDisplay: Array; - }; useRelationshipNameLink: boolean; } diff --git a/packages/esm-patient-banner-app/src/index.ts b/packages/esm-patient-banner-app/src/index.ts index 9da2558d6d..51a377693a 100644 --- a/packages/esm-patient-banner-app/src/index.ts +++ b/packages/esm-patient-banner-app/src/index.ts @@ -1,10 +1,4 @@ -import { - defineConfigSchema, - getAsyncLifecycle, - getSyncLifecycle, - messageOmrsServiceWorker, - restBaseUrl, -} from '@openmrs/esm-framework'; +import { defineConfigSchema, getSyncLifecycle, messageOmrsServiceWorker, restBaseUrl } from '@openmrs/esm-framework'; import { configSchema } from './config-schema'; import deceasedPatientTagComponent from './banner-tags/deceased-patient-tag.extension'; import patientBannerComponent from './banner/patient-banner.component'; @@ -34,19 +28,6 @@ export const deceasedPatientTag = getSyncLifecycle(deceasedPatientTagComponent, export const patientBanner = getSyncLifecycle(patientBannerComponent, options); -export const printIdentifierStickerModal = getAsyncLifecycle( - () => import('./print-identifier-sticker/print-identifier-sticker.modal'), - options, -); - -export const printIdentifierStickerActionButton = getAsyncLifecycle( - () => import('./print-identifier-sticker/print-identifier-sticker-action-button.component'), - { - featureName: 'patient-actions-slot-print-identifier-sticker-button', - moduleName, - }, -); - /* The translations for built-in address fields are kept here in patient-banner. This comment ensures that they are included in the translations files. diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/patient-detail.component.tsx b/packages/esm-patient-banner-app/src/print-identifier-sticker/patient-detail.component.tsx deleted file mode 100644 index ffa4086033..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/patient-detail.component.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import React from 'react'; -import dayjs from 'dayjs'; -import { useTranslation } from 'react-i18next'; -import { age, type CoreTranslationKey, getCoreTranslation, getPatientName, useConfig } from '@openmrs/esm-framework'; -import { type ConfigObject } from '../config-schema'; -import styles from './print-identifier-sticker.scss'; - -export interface PatientDetailProps { - patient: fhir.Patient; -} - -export const PatientName: React.FC = ({ patient }) => { - const { t } = useTranslation(); - return ( -
- - {t('patientNameWithSeparator', 'Patient name:')} - - {getPatientName(patient)} -
- ); -}; - -export const PatientAge: React.FC = ({ patient }) => { - const { t } = useTranslation(); - return ( -
- - {t('patientAge', 'Age:')} - - {age(patient.birthDate)} -
- ); -}; - -export const PatientDob: React.FC = ({ patient }) => { - const { t } = useTranslation(); - return ( -
- - {t('patientDateOfBirthWithSeparator', 'Date of birth:')} - - {dayjs(patient.birthDate).format('DD-MM-YYYY')} -
- ); -}; - -export const PatientGender: React.FC = ({ patient }) => { - const { t } = useTranslation(); - const getGender = (gender: string): string => { - switch (gender) { - case 'male': - return getCoreTranslation('male', 'Male'); - case 'female': - return getCoreTranslation('female', 'Female'); - case 'other': - return getCoreTranslation('other', 'Other'); - case 'unknown': - return getCoreTranslation('unknown', 'Unknown'); - default: - return gender; - } - }; - return ( -
- - {t('patientGenderWithSeparator', 'Gender:')} - - {getGender(patient.gender)} -
- ); -}; - -export const PatientIdentifier: React.FC = ({ patient }) => { - const { printPatientSticker } = useConfig(); - const { identifiersToDisplay } = printPatientSticker ?? {}; - const patientIdentifiers = - (identifiersToDisplay ?? []).length === 0 - ? patient.identifier - : patient.identifier?.filter((identifier) => identifiersToDisplay.includes(identifier.type.coding[0].code)); - return ( -
- {patientIdentifiers?.map((identifier) => ( -
- - {identifier.type.text}: - - {identifier.value} -
- ))} -
- ); -}; - -export const PatientContact: React.FC = ({ patient }) => { - const { t } = useTranslation(); - - if (!patient?.telecom?.length) { - return null; - } - - return ( -
- - {t('telephoneNumberWithSeparator', 'Telephone number:')} - - {patient.telecom?.[0]?.value} -
- ); -}; - -export const PatientAddress: React.FC = ({ patient }) => { - const address = patient?.address?.find((a) => a.use === 'home'); - const getAddressKey = (url: string) => url.split('#')[1]; - - return ( - <> - {address ? ( - Object.entries(address) - .filter(([key]) => key !== 'id' && key !== 'use') - .map(([key, value]) => - key === 'extension' ? ( - address.extension?.[0]?.extension?.map((add, i) => ( -
- - {getCoreTranslation( - getAddressKey(add.url) as CoreTranslationKey, - getAddressKey(add.url) as CoreTranslationKey, - )} - : - - {add.valueString} -
- )) - ) : ( -
- {getCoreTranslation(key as CoreTranslationKey, key)}: - {value} -
- ), - ) - ) : ( -
  • --
  • - )} - - ); -}; diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx b/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx deleted file mode 100644 index 8ccaaf6f06..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { OverflowMenuItem } from '@carbon/react'; -import { showModal, useFeatureFlag } from '@openmrs/esm-framework'; -import styles from './action-button.scss'; - -interface PrintIdentifierStickerOverflowMenuItemProps { - patient: fhir.Patient; -} - -const PrintIdentifierStickerOverflowMenuItem: React.FC = ({ patient }) => { - const { t } = useTranslation(); - const canPrintPatientIdentifierSticker = useFeatureFlag('print-patient-identifier-sticker'); - - const handleLaunchModal = useCallback(() => { - const dispose = showModal('print-identifier-sticker-modal', { - closeModal: () => dispose(), - patient, - }); - }, [patient]); - - if (!patient || !canPrintPatientIdentifierSticker) { - return null; - } - - return ( - - ); -}; - -export default PrintIdentifierStickerOverflowMenuItem; diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-modal.test.tsx b/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-modal.test.tsx deleted file mode 100644 index 25133c1f0d..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker-modal.test.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from 'react'; -import Barcode from 'react-barcode'; -import userEvent from '@testing-library/user-event'; -import { render, screen } from '@testing-library/react'; -import { useReactToPrint } from 'react-to-print'; -import { age, getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework'; -import { mockFhirPatient } from '__mocks__'; -import { type ConfigObject, configSchema } from '../config-schema'; -import { getByTextWithMarkup } from 'tools'; -import PrintIdentifierSticker from './print-identifier-sticker.modal'; - -const mockedCloseModal = jest.fn(); -const mockedUseConfig = jest.mocked(useConfig); -const mockedUseReactToPrint = jest.mocked(useReactToPrint); - -const defaultConfig: ConfigObject = getDefaultsFromConfigSchema(configSchema); - -jest.mock('react-to-print', () => ({ - ...jest.requireActual('react-to-print'), - useReactToPrint: jest.fn(), -})); - -jest.mock('react-barcode', () => jest.fn().mockReturnValue(
    )); - -describe('PrintIdentifierStickerModal', () => { - beforeEach(() => { - mockedUseConfig.mockReturnValue(getDefaultsFromConfigSchema(configSchema)); - }); - - it('renders the print modal', async () => { - const user = userEvent.setup(); - const mockHandlePrint = jest.fn(); - mockedUseReactToPrint.mockReturnValue(mockHandlePrint); - - renderPrintIdentifierStickerModal(); - - expect(screen.getByText(/print identifier sticker/i)).toBeInTheDocument(); - const printButton = screen.getByRole('button', { name: /print/i }); - const cancelButton = screen.getByRole('button', { name: /cancel/i }); - - await user.click(cancelButton); - expect(mockedCloseModal).toHaveBeenCalledTimes(1); - - await user.click(printButton); - expect(mockHandlePrint).toHaveBeenCalledTimes(1); - }); - - it('renders a barcode if enabled via config', async () => { - mockedUseConfig.mockReturnValue({ - ...defaultConfig, - printPatientSticker: { - ...defaultConfig.printPatientSticker, - header: { - showBarcode: true, - showLogo: true, - logo: '', - }, - }, - }); - - renderPrintIdentifierStickerModal(); - - expect(screen.getByTestId('barcode')).toBeInTheDocument(); - expect(Barcode).toHaveBeenCalledWith( - { - value: '100008E', - width: 2, - background: '#f4f4f4', - displayValue: true, - renderer: 'img', - font: 'IBM Plex Sans', - format: 'CODE39', - textAlign: 'center', - textPosition: 'bottom', - fontSize: 16, - }, - {}, - ); - expect(screen.getByTestId('openmrs-logo')).toBeInTheDocument(); - }); - - it("should not render a barcode if it's disabled via config", async () => { - mockedUseConfig.mockReturnValue({ - ...defaultConfig, - printPatientSticker: { - ...defaultConfig.printPatientSticker, - header: { - showBarcode: false, - showLogo: false, - logo: '', - }, - }, - }); - - renderPrintIdentifierStickerModal(); - - expect(screen.queryByTestId('barcode')).not.toBeInTheDocument(); - expect(screen.queryByTestId('openmrs-logo')).not.toBeInTheDocument(); - }); - - it('renders a custom implementation logo if passed via config', () => { - mockedUseConfig.mockReturnValue({ - ...defaultConfig, - printPatientSticker: { - ...defaultConfig.printPatientSticker, - header: { - showBarcode: true, - showLogo: true, - logo: '/openmrs/spa/logo.png', - }, - }, - }); - - renderPrintIdentifierStickerModal(); - - expect(screen.getByRole('img')).toHaveAttribute('src', '/openmrs/spa/logo.png'); - }); - - it("renders the patient's details in the print modal", () => { - renderPrintIdentifierStickerModal(); - - expect(getByTextWithMarkup(/Joshua Johnson/i)).toBeInTheDocument(); - expect(getByTextWithMarkup(/\+255777053243/i)).toBeInTheDocument(); - expect(getByTextWithMarkup(/100008E/i)).toBeInTheDocument(); - expect(getByTextWithMarkup(age(mockFhirPatient.birthDate))).toBeInTheDocument(); - }); -}); - -function renderPrintIdentifierStickerModal() { - return render(); -} diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.modal.tsx b/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.modal.tsx deleted file mode 100644 index fe631be05c..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.modal.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import Barcode from 'react-barcode'; -import { useTranslation } from 'react-i18next'; -import { useReactToPrint } from 'react-to-print'; -import { Button, InlineLoading, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; -import { getPatientName, showSnackbar, useConfig, getCoreTranslation } from '@openmrs/esm-framework'; -import { type ConfigObject } from '../config-schema'; -import { defaultBarcodeParams, getPatientField } from './print-identifier-sticker.resource'; -import styles from './print-identifier-sticker.scss'; - -interface PrintIdentifierStickerProps { - closeModal: () => void; - patient: fhir.Patient; -} - -interface PrintComponentProps extends Partial { - patient: fhir.Patient; -} - -const PrintIdentifierSticker: React.FC = ({ closeModal, patient }) => { - const { t } = useTranslation(); - const { printPatientSticker } = useConfig(); - const { pageSize, printScale = '1' } = printPatientSticker ?? {}; - const contentToPrintRef = useRef(null); - const onBeforeGetContentResolve = useRef<() => void | null>(null); - const [isPrinting, setIsPrinting] = useState(false); - const headerTitle = t('patientIdentifierSticker', 'Patient identifier sticker'); - - useEffect(() => { - if (isPrinting && onBeforeGetContentResolve.current) { - onBeforeGetContentResolve.current(); - } - }, [isPrinting]); - - const handleBeforeGetContent = useCallback( - () => - new Promise((resolve) => { - if (patient && headerTitle) { - onBeforeGetContentResolve.current = resolve; - setIsPrinting(true); - } - }), - [headerTitle, patient], - ); - - const handleAfterPrint = useCallback(() => { - onBeforeGetContentResolve.current = null; - setIsPrinting(false); - closeModal(); - }, [closeModal]); - - const handlePrintError = useCallback((errorLocation, error) => { - onBeforeGetContentResolve.current = null; - - showSnackbar({ - isLowContrast: false, - kind: 'error', - title: getCoreTranslation('printError', 'Print error'), - subtitle: - getCoreTranslation('printErrorExplainer', 'An error occurred in "{{errorLocation}}": ', { errorLocation }) + - error, - }); - - setIsPrinting(false); - }, []); - - const handleInitiatePrint = useCallback( - (printWindow: HTMLIFrameElement | null): Promise => { - return new Promise((resolve) => { - if (printWindow) { - const printContent = printWindow.contentDocument || printWindow.contentWindow?.document; - if (printContent) { - printContent.documentElement.style.setProperty('--print-scale', printScale); - printWindow.contentWindow?.print(); - resolve(); - } - } - }); - }, - [printScale], - ); - - const handlePrint = useReactToPrint({ - content: () => contentToPrintRef.current, - documentTitle: `${getPatientName(patient)} - ${headerTitle}`, - onAfterPrint: handleAfterPrint, - onBeforeGetContent: handleBeforeGetContent, - onPrintError: handlePrintError, - print: handleInitiatePrint, - copyStyles: true, - }); - - return ( - <> - - -
    - - -
    -
    - - - - - - ); -}; - -const PrintComponent = ({ patient }: PrintComponentProps) => { - const { printPatientSticker } = useConfig(); - const primaryIdentifierValue = patient?.identifier?.find((identifier) => identifier.use === 'official')?.value; - return ( -
    -
    - {printPatientSticker?.header?.showBarcode && ( - - )} - {printPatientSticker?.header?.showLogo && ( -
    - -
    - )} -
    - {printPatientSticker.fields.map((field) => { - const Component = getPatientField(field); - return ; - })} -
    - ); -}; - -const ImplementationLogo: React.FC = () => { - const { t } = useTranslation(); - const { printPatientSticker } = useConfig(); - - return printPatientSticker?.header?.logo ? ( - {t('implementationLogo', - ) : ( - // TODO: Figure out why #omrs-logo-full-mono sprite is not working - - - - ); -}; - -export default PrintIdentifierSticker; diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.resource.ts b/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.resource.ts deleted file mode 100644 index ef32c1272e..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.resource.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type Options } from 'react-barcode'; -import { type AllowedPatientFields } from '../config-schema'; -import { - PatientAddress, - PatientAge, - PatientContact, - type PatientDetailProps, - PatientDob, - PatientGender, - PatientIdentifier, - PatientName, -} from './patient-detail.component'; - -export const defaultBarcodeParams: Options = { - width: 2, - format: 'CODE39', - background: '#f4f4f4', - displayValue: true, - renderer: 'img', - font: 'IBM Plex Sans', - textAlign: 'center', - textPosition: 'bottom', - fontSize: 16, -}; - -export function getPatientField(field: AllowedPatientFields): React.FC { - switch (field) { - case 'name': - return PatientName; - case 'age': - return PatientAge; - case 'dob': - return PatientDob; - case 'gender': - return PatientGender; - case 'identifier': - return PatientIdentifier; - case 'contact': - return PatientContact; - case 'address': - return PatientAddress; - default: - console.error(`Invalid patient field: ${field}`); - return null; - } -} diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.scss b/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.scss deleted file mode 100644 index f6dd205b2a..0000000000 --- a/packages/esm-patient-banner-app/src/print-identifier-sticker/print-identifier-sticker.scss +++ /dev/null @@ -1,88 +0,0 @@ -@use '@carbon/layout'; -@use '@carbon/type'; -@use '@openmrs/esm-styleguide/src/vars' as *; - -.stickerContainer { - padding: layout.$spacing-03 layout.$spacing-05; -} - -.documentHeader { - display: flex; - justify-content: space-between; - align-items: center; - border-bottom: 1px solid $brand-01; - margin-bottom: layout.$spacing-05; -} - -.row { - margin: layout.$spacing-03 0 0; - display: flex; - flex-flow: row wrap; - gap: layout.$spacing-05; -} - -.gridRow { - padding: 0px; - display: flex; - flex-direction: row; - justify-content: space-between; - - div { - margin-left: 0px; - margin-bottom: 5px; - } -} - -.patientName { - font-size: layout.$spacing-06; - font-weight: bolder; - margin-bottom: 5px; -} - -.button { - :global(.cds--inline-loading) { - min-height: layout.$spacing-05 !important; - } -} - -.detailsGrid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: layout.$spacing-03 0; - margin-top: layout.$spacing-03; -} - -.loader { - :global(.cds--inline-loading__text) { - @include type.type-style('body-01'); - } -} - -.strong { - @include type.type-style('heading-04'); - font-weight: 700; - margin-inline-end: layout.$spacing-03; -} - -.patientDetail { - @include type.type-style('heading-03'); -} - -.implementationLogo { - width: 40%; -} - -@media print { - html, - body { - height: 100vh; - margin: 0 !important; - padding: 0 !important; - overflow: hidden; - } - - .stickerContainer { - transform: scale(var(--print-scale)); - transform-origin: left top; - } -} diff --git a/packages/esm-patient-banner-app/src/routes.json b/packages/esm-patient-banner-app/src/routes.json index 6f54188a2b..678dced63d 100644 --- a/packages/esm-patient-banner-app/src/routes.json +++ b/packages/esm-patient-banner-app/src/routes.json @@ -24,26 +24,6 @@ "component": "patientBanner", "online": true, "offline": true - }, - { - "name": "print-identifier-sticker-button", - "slot": "patient-actions-slot", - "component": "printIdentifierStickerActionButton", - "online": true, - "offline": true - } - ], - "modals": [ - { - "name": "print-identifier-sticker-modal", - "component": "printIdentifierStickerModal" - } - ], - "featureFlags": [ - { - "description": "Features to support printing a patient identifier sticker", - "flagName": "print-patient-identifier-sticker", - "label": "Print patient identifier sticker" } ] } diff --git a/packages/esm-patient-banner-app/translations/en.json b/packages/esm-patient-banner-app/translations/en.json index d6f528ff35..a21571c7d0 100644 --- a/packages/esm-patient-banner-app/translations/en.json +++ b/packages/esm-patient-banner-app/translations/en.json @@ -9,16 +9,8 @@ "deceased": "Deceased", "district": "District", "from_lower": "from", - "implementationLogo": "Implementation logo", - "patientAge": "Age:", - "patientDateOfBirthWithSeparator": "Date of birth:", - "patientGenderWithSeparator": "Gender:", - "patientIdentifierSticker": "Patient identifier sticker", - "patientNameWithSeparator": "Patient name:", "postalCode": "Postal code", - "printIdentifierSticker": "Print identifier sticker", "started": "Started", "state": "State", - "stateProvince": "State", - "telephoneNumberWithSeparator": "Telephone number:" + "stateProvince": "State" } diff --git a/packages/esm-patient-label-printing-app/README.md b/packages/esm-patient-label-printing-app/README.md new file mode 100644 index 0000000000..1a7c2da18e --- /dev/null +++ b/packages/esm-patient-label-printing-app/README.md @@ -0,0 +1,3 @@ +# esm-patient-label-printing-app + +The Patient label printing frontend module provides a flexible and extensible printing mechanism that integrates easily into various parts of the application. Its core architecture supports print actions across diverse contexts—such as patient management apps like queues, clerical views, and billing interfaces—enabling fast, standardized printing of documents like patient records, receipts, and other critical materials. diff --git a/packages/esm-patient-label-printing-app/jest.config.js b/packages/esm-patient-label-printing-app/jest.config.js new file mode 100644 index 0000000000..0352f6214c --- /dev/null +++ b/packages/esm-patient-label-printing-app/jest.config.js @@ -0,0 +1,3 @@ +const rootConfig = require('../../jest.config.js'); + +module.exports = rootConfig; diff --git a/packages/esm-patient-label-printing-app/package.json b/packages/esm-patient-label-printing-app/package.json new file mode 100644 index 0000000000..5514fed7c9 --- /dev/null +++ b/packages/esm-patient-label-printing-app/package.json @@ -0,0 +1,51 @@ +{ + "name": "@openmrs/esm-patient-label-printing-app", + "version": "11.3.0", + "license": "MPL-2.0", + "description": "Patient Label Printing frontend module for O3", + "browser": "dist/openmrs-esm-patient-label-printing-app.js", + "main": "src/index.ts", + "source": true, + "scripts": { + "start": "openmrs develop", + "serve": "webpack serve --mode=development", + "debug": "npm run serve", + "build": "webpack --mode production --color", + "analyze": "webpack --mode=production --env analyze=true", + "lint": "cross-env eslint src --ext tsx,ts --fix --max-warnings=0", + "test": "cross-env TZ=UTC jest --config jest.config.js --verbose false --passWithNoTests --color", + "test:watch": "cross-env TZ=UTC jest --watch --config jest.config.js --color", + "coverage": "yarn test --coverage", + "typescript": "tsc", + "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.modal.tsx' 'src/**/*.extension.tsx' 'src/**/*.workspace.tsx' 'src/**/*.hook.tsx' 'src/index.ts' --config ../../tools/i18next-parser.config.js" + }, + "browserslist": [ + "extends browserslist-config-openmrs" + ], + "keywords": [ + "openmrs" + ], + "homepage": "https://github.com/openmrs/openmrs-esm-patient-chart#readme", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/openmrs/openmrs-esm-patient-chart.git" + }, + "bugs": { + "url": "https://github.com/openmrs/openmrs-esm-patient-chart/issues" + }, + "dependencies": { + "@carbon/react": "^1.83.0" + }, + "peerDependencies": { + "@openmrs/esm-framework": "8.x", + "react": "18.x", + "react-i18next": "16.x", + "react-router-dom": "6.x" + }, + "devDependencies": { + "webpack": "^5.99.9" + } +} diff --git a/packages/esm-patient-label-printing-app/src/config-schema.ts b/packages/esm-patient-label-printing-app/src/config-schema.ts new file mode 100644 index 0000000000..4c0df275a4 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/config-schema.ts @@ -0,0 +1,13 @@ +import { Type } from '@openmrs/esm-framework'; + +export const configSchema = { + showPrintIdentifierStickerButton: { + _type: Type.Boolean, + _description: "Whether to display the 'Print identifier sticker' button in the patient banner", + _default: false, + }, +}; + +export interface ConfigObject { + showPrintIdentifierStickerButton: boolean; +} diff --git a/packages/esm-patient-label-printing-app/src/declarations.d.ts b/packages/esm-patient-label-printing-app/src/declarations.d.ts new file mode 100644 index 0000000000..2978cbc493 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/declarations.d.ts @@ -0,0 +1,4 @@ +declare module '*.scss' { + const content: { [className: string]: string }; + export default content; +} diff --git a/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.test.tsx b/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.test.tsx new file mode 100644 index 0000000000..95f29b2790 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.test.tsx @@ -0,0 +1,237 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { useStickerPdfPrinter } from './useStickerPdfPrinter'; + +describe('useStickerPdfPrinter', () => { + let mockContentWindow: any; + let afterPrintHandler: (() => void) | null = null; + + beforeEach(() => { + afterPrintHandler = null; + + // Create a mock contentWindow with all required methods + mockContentWindow = { + print: jest.fn(), + focus: jest.fn(), + addEventListener: jest.fn((event: string, handler: () => void) => { + if (event === 'afterprint') { + afterPrintHandler = handler; + } + }), + }; + + // Mock HTMLIFrameElement.prototype.contentWindow to return our mock + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + configurable: true, + get: () => mockContentWindow, + }); + + Object.defineProperty(HTMLIFrameElement.prototype, 'src', { + configurable: true, + set: function (value) { + this._src = value; + // Trigger onload asynchronously to simulate real behavior + if (this.onload) { + Promise.resolve().then(() => { + if (this.onload) { + this.onload({} as Event); + } + }); + } + }, + get: function () { + return this._src; + }, + }); + + // Mock document.hasFocus to support the polling mechanism + document.hasFocus = jest.fn().mockReturnValue(false); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + afterPrintHandler = null; + }); + + const waitForIframeLoad = () => { + // Wait for next tick to allow iframe onload to trigger + return new Promise((resolve) => setTimeout(resolve, 0)); + }; + + const triggerPrintCompletion = () => { + // Simulate the print dialog closing by triggering afterprint event + if (afterPrintHandler) { + afterPrintHandler(); + } + }; + + it('should provide printPdf function and isPrinting state', () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + expect(result.current.isPrinting).toBe(false); + expect(typeof result.current.printPdf).toBe('function'); + }); + + it('should set isPrinting to true when printing starts', () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + act(() => { + result.current.printPdf('http://example.com/test.pdf'); + }); + + expect(result.current.isPrinting).toBe(true); + }); + + it('should reject concurrent print requests with an error', async () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + act(() => { + result.current.printPdf('http://example.com/test.pdf'); + }); + + await expect(result.current.printPdf('http://example.com/test2.pdf')).rejects.toThrow('Print already in progress'); + }); + + it('should reset isPrinting to false when printing completes', async () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + act(() => { + result.current.printPdf('http://example.com/test.pdf'); + }); + + expect(result.current.isPrinting).toBe(true); + + // Wait for iframe to load + await act(async () => { + await waitForIframeLoad(); + }); + + // Simulate print completion + act(() => { + triggerPrintCompletion(); + }); + + await waitFor(() => { + expect(result.current.isPrinting).toBe(false); + }); + }); + + it('should return a promise that resolves when printing completes', async () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + let resolved = false; + let printPromise: Promise; + + act(() => { + printPromise = result.current.printPdf('http://example.com/test.pdf').then(() => { + resolved = true; + }); + }); + + expect(resolved).toBe(false); + + // Wait for iframe to load + await act(async () => { + await waitForIframeLoad(); + }); + + // Simulate print completion + act(() => { + triggerPrintCompletion(); + }); + + await waitFor(() => { + expect(resolved).toBe(true); + }); + + await printPromise!; + }); + + it('should allow printing again after previous print completes', async () => { + const { result } = renderHook(() => useStickerPdfPrinter()); + + // First print + act(() => { + result.current.printPdf('http://example.com/test1.pdf'); + }); + + await act(async () => { + await waitForIframeLoad(); + }); + + act(() => { + triggerPrintCompletion(); + }); + + await waitFor(() => { + expect(result.current.isPrinting).toBe(false); + }); + + // Second print should succeed + act(() => { + result.current.printPdf('http://example.com/test2.pdf'); + }); + + expect(result.current.isPrinting).toBe(true); + + await act(async () => { + await waitForIframeLoad(); + }); + + act(() => { + triggerPrintCompletion(); + }); + + await waitFor(() => { + expect(result.current.isPrinting).toBe(false); + }); + }); + + it('should reset isPrinting after timeout when print cannot be detected as complete', async () => { + jest.useFakeTimers(); + const { result } = renderHook(() => useStickerPdfPrinter()); + + act(() => { + result.current.printPdf('http://example.com/test.pdf'); + }); + + expect(result.current.isPrinting).toBe(true); + + // Fast-forward time to trigger iframe load, then advance past timeout + // The iframe onload will be triggered via Promise.resolve() which needs runAllTimers + await act(async () => { + await jest.runAllTimersAsync(); + }); + + // Verify timeout mechanism resets isPrinting (afterprint never fired) + expect(result.current.isPrinting).toBe(false); + }); + + it('should handle errors gracefully and reset isPrinting state', async () => { + // Mock contentWindow to return null to simulate an error + Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { + configurable: true, + get: () => null, + }); + + const { result } = renderHook(() => useStickerPdfPrinter()); + + let resolved = false; + act(() => { + result.current.printPdf('http://example.com/test.pdf').then(() => { + resolved = true; + }); + }); + + // Wait for iframe to attempt loading and trigger error path + await act(async () => { + await waitForIframeLoad(); + }); + + await waitFor(() => { + expect(result.current.isPrinting).toBe(false); + }); + + expect(resolved).toBe(true); + }); +}); diff --git a/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.tsx b/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.tsx new file mode 100644 index 0000000000..43a0500238 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/hooks/useStickerPdfPrinter.tsx @@ -0,0 +1,93 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useStickerPdfPrinter = () => { + const { t } = useTranslation(); + const iframeRef = useRef(null); + const [isPrinting, setIsPrinting] = useState(false); + + const printPdf = useCallback( + (url: string) => { + if (isPrinting) { + return Promise.reject(new Error(t('printInProgress', 'Print already in progress'))); + } + + return new Promise((resolve) => { + setIsPrinting(true); + + if (!iframeRef.current) { + const iframe = document.createElement('iframe'); + iframe.name = 'pdfPrinterFrame'; + iframe.setAttribute('aria-hidden', 'true'); + Object.assign(iframe.style, { + position: 'fixed', + width: '0', + height: '0', + border: 'none', + visibility: 'hidden', + pointerEvents: 'none', + }); + iframeRef.current = iframe; + document.body.appendChild(iframe); + } + + const iframe = iframeRef.current; + let hasClosed = false; + + const handleLoad = () => { + try { + const contentWindow = iframe.contentWindow; + if (!contentWindow) throw new Error('No content window'); + + const cleanup = () => { + if (hasClosed) return; + hasClosed = true; + setIsPrinting(false); + resolve(); + }; + + try { + contentWindow.addEventListener('afterprint', cleanup, { once: true }); + } catch (e) { + // Cross-origin, use polling fallback + } + + contentWindow.focus(); + contentWindow.print(); + + let wasFocused = false; + const pollInterval = setInterval(() => { + const hasFocus = document.hasFocus(); + if (hasFocus && wasFocused) cleanup(); + if (!hasFocus) wasFocused = true; + }, 250); + + setTimeout(cleanup, 30000); + setTimeout(() => clearInterval(pollInterval), 30000); + } catch (error) { + setIsPrinting(false); + resolve(); + } + }; + + iframe.onload = handleLoad; + iframe.onerror = () => { + setIsPrinting(false); + resolve(); + }; + iframe.src = url; + }); + }, + [t, isPrinting], + ); + + useEffect(() => { + return () => { + if (iframeRef.current?.parentNode) { + iframeRef.current.parentNode.removeChild(iframeRef.current); + } + }; + }, []); + + return { printPdf, isPrinting }; +}; diff --git a/packages/esm-patient-label-printing-app/src/index.ts b/packages/esm-patient-label-printing-app/src/index.ts new file mode 100644 index 0000000000..f357c6bbbe --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/index.ts @@ -0,0 +1,18 @@ +import { defineConfigSchema, getAsyncLifecycle } from '@openmrs/esm-framework'; +import { configSchema } from './config-schema'; + +const moduleName = '@openmrs/esm-patient-label-printing-app'; + +export const importTranslation = require.context('../translations', false, /.json$/, 'lazy'); + +export function startupApp() { + defineConfigSchema(moduleName, configSchema); +} + +export const printIdentifierStickerActionButton = getAsyncLifecycle( + () => import('./print-identifier-sticker/print-identifier-sticker-action-button.component'), + { + featureName: 'patient-actions-slot-print-identifier-sticker-button', + moduleName, + }, +); diff --git a/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx b/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx new file mode 100644 index 0000000000..505ec35692 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.component.tsx @@ -0,0 +1,62 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { OverflowMenuItem } from '@carbon/react'; +import { showSnackbar, getCoreTranslation, useConfig, UserHasAccess, restBaseUrl } from '@openmrs/esm-framework'; +import styles from './print-identifier-sticker-action-button.scss'; +import { useStickerPdfPrinter } from '../hooks/useStickerPdfPrinter'; +import type { ConfigObject } from '../config-schema'; + +interface PrintIdentifierStickerOverflowMenuItemProps { + patient: fhir.Patient; +} + +const PrintIdentifierStickerOverflowMenuItem: React.FC = ({ patient }) => { + const { t } = useTranslation(); + const { showPrintIdentifierStickerButton } = useConfig(); + const { printPdf, isPrinting } = useStickerPdfPrinter(); + + const isVisible = useMemo(() => { + if (!patient?.id) return false; + return showPrintIdentifierStickerButton; + }, [showPrintIdentifierStickerButton, patient?.id]); + + const getPdfUrl = useCallback(() => { + if (!patient?.id) { + throw new Error(t('patientIdNotFound', 'Patient ID not found')); + } + return `${window.openmrsBase}${restBaseUrl}/patientdocuments/patientIdSticker?patientUuid=${patient.id}`; + }, [patient?.id, t]); + + const handlePrint = useCallback(async () => { + if (isPrinting) return; + + try { + await printPdf(getPdfUrl()); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + showSnackbar({ + kind: 'error', + title: getCoreTranslation('printError', 'Print Error'), + subtitle: getCoreTranslation('printErrorExplainer', '', { errorLocation: errorMessage }), + }); + } + }, [getPdfUrl, printPdf, isPrinting]); + + const buttonText = useMemo(() => { + return isPrinting + ? getCoreTranslation('printing', 'Printing...') + : getCoreTranslation('printIdentifierSticker', 'Print identifier sticker'); + }, [isPrinting]); + + if (!isVisible) { + return null; + } + + return ( + + + + ); +}; + +export default PrintIdentifierStickerOverflowMenuItem; diff --git a/packages/esm-patient-banner-app/src/print-identifier-sticker/action-button.scss b/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.scss similarity index 100% rename from packages/esm-patient-banner-app/src/print-identifier-sticker/action-button.scss rename to packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.scss diff --git a/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.test.tsx b/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.test.tsx new file mode 100644 index 0000000000..4e6a061404 --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/print-identifier-sticker/print-identifier-sticker-action-button.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { screen } from '@testing-library/react'; +import { getDefaultsFromConfigSchema, showSnackbar, useConfig, UserHasAccess } from '@openmrs/esm-framework'; +import { mockFhirPatient } from '__mocks__'; +import { renderWithSwr } from 'tools'; +import { useStickerPdfPrinter } from '../hooks/useStickerPdfPrinter'; +import { configSchema, type ConfigObject } from '../config-schema'; +import PrintIdentifierStickerOverflowMenuItem from './print-identifier-sticker-action-button.component'; + +jest.mock('../hooks/useStickerPdfPrinter'); +jest.mock('@openmrs/esm-framework', () => ({ + ...jest.requireActual('@openmrs/esm-framework'), + UserHasAccess: jest.fn(({ children }) => children), +})); + +const mockUseConfig = jest.mocked(useConfig); +const mockShowSnackbar = jest.mocked(showSnackbar); +const mockUseStickerPdfPrinter = jest.mocked(useStickerPdfPrinter); +const mockUserHasAccess = jest.mocked(UserHasAccess); +const mockPrintPdf = jest.fn(); + +describe('PrintIdentifierStickerOverflowMenuItem', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ + ...getDefaultsFromConfigSchema(configSchema), + showPrintIdentifierStickerButton: true, + } as ConfigObject); + mockPrintPdf.mockResolvedValue(undefined); + mockUseStickerPdfPrinter.mockReturnValue({ + printPdf: mockPrintPdf, + isPrinting: false, + }); + }); + + it('renders the print button when enabled in config', () => { + renderWithSwr(); + + expect(screen.getByRole('menuitem', { name: /print identifier sticker/i })).toBeInTheDocument(); + }); + + it('does not render the button when disabled in config', () => { + mockUseConfig.mockReturnValue({ + ...getDefaultsFromConfigSchema(configSchema), + showPrintIdentifierStickerButton: false, + } as ConfigObject); + + renderWithSwr(); + + expect(screen.queryByRole('menuitem', { name: /print identifier sticker/i })).not.toBeInTheDocument(); + }); + + it('does not render the button when patient ID is missing', () => { + const patientWithoutId = { ...mockFhirPatient, id: undefined } as fhir.Patient; + + renderWithSwr(); + + expect(screen.queryByRole('menuitem', { name: /print identifier sticker/i })).not.toBeInTheDocument(); + }); + + it('triggers print when button is clicked', async () => { + const user = userEvent.setup(); + renderWithSwr(); + + const printButton = screen.getByRole('menuitem', { name: /print identifier sticker/i }); + await user.click(printButton); + + expect(mockPrintPdf).toHaveBeenCalledTimes(1); + expect(mockPrintPdf).toHaveBeenCalledWith(expect.stringContaining(mockFhirPatient.id)); + }); + + it('shows error notification when print fails', async () => { + const user = userEvent.setup(); + const errorMessage = 'Network error'; + mockPrintPdf.mockRejectedValueOnce(new Error(errorMessage)); + + renderWithSwr(); + + const printButton = screen.getByRole('menuitem', { name: /print identifier sticker/i }); + await user.click(printButton); + + expect(mockShowSnackbar).toHaveBeenCalledWith({ + kind: 'error', + title: 'Print error', + subtitle: expect.stringContaining(errorMessage), + }); + }); + + it('shows loading state when printing', () => { + mockUseStickerPdfPrinter.mockReturnValue({ + printPdf: mockPrintPdf, + isPrinting: true, + }); + + renderWithSwr(); + + const printButton = screen.getByRole('menuitem', { name: /printing/i }); + expect(printButton).toBeInTheDocument(); + expect(printButton).toBeDisabled(); + }); + + it('prevents multiple print calls when already printing', async () => { + const user = userEvent.setup(); + mockUseStickerPdfPrinter.mockReturnValue({ + printPdf: mockPrintPdf, + isPrinting: true, + }); + + renderWithSwr(); + + const printButton = screen.getByRole('menuitem', { name: /printing/i }); + await user.click(printButton); + + expect(mockPrintPdf).not.toHaveBeenCalled(); + }); + + it('checks for the correct privilege when rendering', () => { + renderWithSwr(); + + expect(mockUserHasAccess).toHaveBeenCalledWith( + expect.objectContaining({ + privilege: 'App: Can generate a Patient Identity Sticker', + }), + expect.anything(), + ); + }); +}); diff --git a/packages/esm-patient-label-printing-app/src/routes.json b/packages/esm-patient-label-printing-app/src/routes.json new file mode 100644 index 0000000000..ab573f7f7c --- /dev/null +++ b/packages/esm-patient-label-printing-app/src/routes.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.openmrs.org/routes.schema.json", + "backendDependencies": { + "patientdocuments": "^1.0.0-SNAPSHOT" + }, + "extensions": [ + { + "name": "print-identifier-sticker-button", + "slot": "patient-actions-slot", + "component": "printIdentifierStickerActionButton", + "online": true, + "offline": true + } + ] +} diff --git a/packages/esm-patient-label-printing-app/translations/en.json b/packages/esm-patient-label-printing-app/translations/en.json new file mode 100644 index 0000000000..e768524041 --- /dev/null +++ b/packages/esm-patient-label-printing-app/translations/en.json @@ -0,0 +1,3 @@ +{ + "patientIdNotFound": "Patient ID not found" +} diff --git a/packages/esm-patient-label-printing-app/tsconfig.json b/packages/esm-patient-label-printing-app/tsconfig.json new file mode 100644 index 0000000000..29fd6726f2 --- /dev/null +++ b/packages/esm-patient-label-printing-app/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "../../tools/setup-tests.ts"], +} diff --git a/packages/esm-patient-label-printing-app/webpack.config.js b/packages/esm-patient-label-printing-app/webpack.config.js new file mode 100644 index 0000000000..2c74029c85 --- /dev/null +++ b/packages/esm-patient-label-printing-app/webpack.config.js @@ -0,0 +1 @@ +module.exports = require('openmrs/default-webpack-config'); diff --git a/yarn.lock b/yarn.lock index 25302c95e2..1ac633d8b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5021,6 +5021,20 @@ __metadata: languageName: unknown linkType: soft +"@openmrs/esm-patient-label-printing-app@workspace:packages/esm-patient-label-printing-app": + version: 0.0.0-use.local + resolution: "@openmrs/esm-patient-label-printing-app@workspace:packages/esm-patient-label-printing-app" + dependencies: + "@carbon/react": "npm:^1.83.0" + webpack: "npm:^5.99.9" + peerDependencies: + "@openmrs/esm-framework": 8.x + react: 18.x + react-i18next: 16.x + react-router-dom: 6.x + languageName: unknown + linkType: soft + "@openmrs/esm-patient-lists-app@workspace:packages/esm-patient-lists-app": version: 0.0.0-use.local resolution: "@openmrs/esm-patient-lists-app@workspace:packages/esm-patient-lists-app"