diff --git a/packages/esm-patient-registration-app/README.md b/packages/esm-patient-registration-app/README.md new file mode 100644 index 00000000..11e9d4b6 --- /dev/null +++ b/packages/esm-patient-registration-app/README.md @@ -0,0 +1,7 @@ +# esm-patient-registration-app + +## Configuring the Registration App to collect custom observations + +[PR-221](https://github.com/openmrs/openmrs-esm-patient-management/pull/221) made it possible to configure the registration app to include obs, as demoed in the gif video below, using fieldDefinitions: + +![Peek 2022-07-13 15-14](https://user-images.githubusercontent.com/1031876/178846444-ac4da88a-073f-4ed2-bf00-a07cf3ab6d2f.gif) diff --git a/packages/esm-patient-registration-app/docs/images/patient-registration-hierarchy.png b/packages/esm-patient-registration-app/docs/images/patient-registration-hierarchy.png new file mode 100644 index 00000000..998ec1e5 Binary files /dev/null and b/packages/esm-patient-registration-app/docs/images/patient-registration-hierarchy.png differ diff --git a/packages/esm-patient-registration-app/jest.config.js b/packages/esm-patient-registration-app/jest.config.js new file mode 100644 index 00000000..0352f621 --- /dev/null +++ b/packages/esm-patient-registration-app/jest.config.js @@ -0,0 +1,3 @@ +const rootConfig = require('../../jest.config.js'); + +module.exports = rootConfig; diff --git a/packages/esm-patient-registration-app/package.json b/packages/esm-patient-registration-app/package.json new file mode 100644 index 00000000..007e6887 --- /dev/null +++ b/packages/esm-patient-registration-app/package.json @@ -0,0 +1,60 @@ +{ + "name": "@ampath/esm-patient-registration-app", + "version": "6.0.0", + "description": "Patient registration microfrontend for the OpenMRS SPA", + "browser": "dist/ampath-esm-patient-registration-app.js", + "main": "src/index.ts", + "source": true, + "license": "MPL-2.0", + "homepage": "https://github.com/ampath/ampath-esm-3.x#readme", + "scripts": { + "start": "openmrs develop", + "serve": "webpack serve --mode=development", + "debug": "npm run serve", + "build": "webpack --mode production", + "analyze": "webpack --mode=production --env.analyze=true", + "lint": "cross-env eslint src --ext ts,tsx", + "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/index.ts'" + }, + "browserslist": [ + "extends browserslist-config-openmrs" + ], + "keywords": [ + "openmrs" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/ampath/ampath-esm-3.x.git" + }, + "bugs": { + "url": "https://github.com/ampath/ampath-esm-3.x/issues" + }, + "dependencies": { + "@carbon/react": "~1.37.0", + "core-js-pure": "^3.34.0", + "formik": "^2.1.5", + "geopattern": "^1.2.3", + "lodash-es": "^4.17.15", + "react-avatar": "^5.0.3", + "uuid": "^8.3.2", + "yup": "^0.29.1" + }, + "peerDependencies": { + "@openmrs/esm-framework": "5.x", + "dayjs": "1.x", + "react": "18.x", + "react-i18next": "11.x", + "react-router-dom": "6.x", + "swr": "2.x" + }, + "devDependencies": { + "webpack": "^5.74.0" + } +} diff --git a/packages/esm-patient-registration-app/src/add-patient-link.scss b/packages/esm-patient-registration-app/src/add-patient-link.scss new file mode 100644 index 00000000..0df1784a --- /dev/null +++ b/packages/esm-patient-registration-app/src/add-patient-link.scss @@ -0,0 +1,3 @@ +.slotStyles { + background-color: transparent; +} diff --git a/packages/esm-patient-registration-app/src/add-patient-link.test.tsx b/packages/esm-patient-registration-app/src/add-patient-link.test.tsx new file mode 100644 index 00000000..0d94c313 --- /dev/null +++ b/packages/esm-patient-registration-app/src/add-patient-link.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render } from '@testing-library/react'; +import { navigate } from '@openmrs/esm-framework'; +import Root from './add-patient-link'; + +const mockedNavigate = navigate as jest.Mock; + +describe('Add patient link component', () => { + it('renders an "Add Patient" button and triggers navigation on click', async () => { + const user = userEvent.setup(); + + const { getByRole } = render(); + const addButton = getByRole('button', { name: /add patient/i }); + + await user.click(addButton); + + expect(mockedNavigate).toHaveBeenCalledWith({ to: '${openmrsSpaBase}/patient-registration' }); + }); +}); diff --git a/packages/esm-patient-registration-app/src/add-patient-link.tsx b/packages/esm-patient-registration-app/src/add-patient-link.tsx new file mode 100644 index 00000000..19d77bdc --- /dev/null +++ b/packages/esm-patient-registration-app/src/add-patient-link.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { HeaderGlobalAction } from '@carbon/react'; +import { UserFollow } from '@carbon/react/icons'; +import { navigate } from '@openmrs/esm-framework'; +import styles from './add-patient-link.scss'; + +export default function Root() { + const addPatient = React.useCallback(() => navigate({ to: '${openmrsSpaBase}/patient-registration' }), []); + + return ( + + + + ); +} diff --git a/packages/esm-patient-registration-app/src/config-schema.ts b/packages/esm-patient-registration-app/src/config-schema.ts new file mode 100644 index 00000000..a2b35c60 --- /dev/null +++ b/packages/esm-patient-registration-app/src/config-schema.ts @@ -0,0 +1,410 @@ +import { Type, validator, validators } from '@openmrs/esm-framework'; + +export interface SectionDefinition { + id: string; + name?: string; + fields: Array; +} + +export interface FieldDefinition { + id: string; + type: string; + label?: string; + uuid: string; + placeholder?: string; + showHeading: boolean; + validation?: { + required: boolean; + matches?: string; + }; + answerConceptSetUuid?: string; + customConceptAnswers?: Array; +} +export interface CustomConceptAnswer { + uuid: string; + label?: string; +} +export interface Gender { + label?: string; + value: string; +} + +export interface RegistrationConfig { + sections: Array; + sectionDefinitions: Array; + fieldDefinitions: Array; + fieldConfigurations: { + name: { + displayMiddleName: boolean; + allowUnidentifiedPatients: boolean; + defaultUnknownGivenName: string; + defaultUnknownFamilyName: string; + displayCapturePhoto: boolean; + displayReverseFieldOrder: boolean; + }; + gender: Array; + address: { + useAddressHierarchy: { + enabled: boolean; + useQuickSearch: boolean; + searchAddressByLevel: boolean; + }; + }; + dateOfBirth: { + allowEstimatedDateOfBirth: boolean; + useEstimatedDateOfBirth: { + enabled: boolean; + dayOfMonth: number; + month: number; + }; + }; + phone: { + personAttributeUuid: string; + }; + }; + links: { + submitButton: string; + }; + concepts: { + patientPhotoUuid: string; + }; + defaultPatientIdentifierTypes: Array; + registrationObs: { + encounterTypeUuid: string | null; + encounterProviderRoleUuid: string; + registrationFormUuid: string | null; + }; +} + +export const builtInSections: Array = [ + { + id: 'demographics', + name: 'Basic Info', + fields: ['name', 'gender', 'dob', 'id'], + }, + { id: 'contact', name: 'Contact Details', fields: ['address', 'phone'] }, + { id: 'death', name: 'Death Info', fields: [] }, + { id: 'relationships', name: 'Relationships', fields: [] }, +]; + +// These fields are handled specially in field.component.tsx +export const builtInFields = ['name', 'gender', 'dob', 'id', 'address', 'phone'] as const; + +export const esmPatientRegistrationSchema = { + sections: { + _type: Type.Array, + _default: ['demographics', 'contact', 'relationships'], + _description: `An array of strings which are the keys from 'sectionDefinitions' or any of the following built-in sections: '${builtInSections + .map((s) => s.id) + .join("', '")}'.`, + _elements: { + _type: Type.String, + }, + }, + sectionDefinitions: { + _type: Type.Array, + _elements: { + id: { + _type: Type.String, + _description: `How this section will be referred to in the \`sections\` configuration. To override a built-in section, use that section's id. The built in section ids are '${builtInSections + .map((s) => s.id) + .join("', '")}'.`, + }, + name: { + _type: Type.String, + _description: 'The title to display at the top of the section.', + }, + fields: { + _type: Type.Array, + _default: [], + _description: `The parts to include in the section. Can be any of the following built-in fields: ${builtInFields.join( + ', ', + )}. Can also be an id from an object in the \`fieldDefinitions\` array, which you can use to define custom fields.`, + _elements: { _type: Type.String }, + }, + }, + _default: [], + }, + fieldDefinitions: { + _type: Type.Array, + _elements: { + id: { + _type: Type.String, + _description: + 'How this field will be referred to in the `fields` element of the `sectionDefinitions` configuration.', + }, + type: { + _type: Type.String, + _description: "How this field's data will be stored—a person attribute or an obs.", + _validators: [validators.oneOf(['person attribute', 'obs'])], + }, + uuid: { + _type: Type.UUID, + _description: "Person attribute type UUID that this field's data should be saved to.", + }, + showHeading: { + _type: Type.Boolean, + _description: 'Whether to show a heading above the person attribute field.', + _default: false, + }, + label: { + _type: Type.String, + _default: null, + _description: 'The label of the input. By default, uses the metadata `display` attribute.', + }, + placeholder: { + _type: Type.String, + _default: '', + _description: 'Placeholder that will appear in the input.', + }, + validation: { + required: { _type: Type.Boolean, _default: false }, + matches: { + _type: Type.String, + _default: null, + _description: 'Optional RegEx for testing the validity of the input.', + }, + }, + answerConceptSetUuid: { + _type: Type.ConceptUuid, + _default: null, + _description: + 'For coded questions only. A concept which has the possible responses either as answers or as set members.', + }, + customConceptAnswers: { + _type: Type.Array, + _elements: { + uuid: { + _type: Type.UUID, + _description: 'Answer concept UUID', + }, + label: { + _type: Type.String, + _default: null, + _description: 'The custom label for the answer concept.', + }, + }, + _default: [], + _description: + 'For coded questions only (obs or person attrbute). A list of custom concept answers. Overrides answers that come from the obs concept or from `answerSetConceptUuid`.', + }, + }, + // Do not add fields here. If you want to add a field in code, add it to built-in fields above. + _default: [], + _description: + 'Definitions for custom fields that can be used in sectionDefinitions. Can also be used to override built-in fields.', + }, + fieldConfigurations: { + name: { + displayMiddleName: { _type: Type.Boolean, _default: true }, + allowUnidentifiedPatients: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to allow registering unidentified patients.', + }, + defaultUnknownGivenName: { + _type: Type.String, + _default: 'UNKNOWN', + _description: 'The given/first name to record for unidentified patients.', + }, + defaultUnknownFamilyName: { + _type: Type.String, + _default: 'UNKNOWN', + _description: 'The family/last name to record for unidentified patients.', + }, + displayCapturePhoto: { + _type: Type.Boolean, + _default: true, + _description: 'Whether to display capture patient photo slot on name field', + }, + displayReverseFieldOrder: { + _type: Type.Boolean, + _default: false, + _description: "Whether to display the name fields in the order 'Family name' -> 'Middle name' -> 'First name'", + }, + }, + gender: { + _type: Type.Array, + _elements: { + value: { + _type: Type.String, + _description: + 'Value that will be sent to the server. Limited to FHIR-supported values for Administrative Gender', + _validators: [validators.oneOf(['male', 'female', 'other', 'unknown'])], + }, + label: { + _type: Type.String, + _default: null, + _description: + 'The label displayed for the sex option, if it should be different from the value (the value will be translated; the English "translation" is upper-case).', + }, + }, + _default: [ + { + value: 'male', + }, + { + value: 'female', + }, + { + value: 'other', + }, + { + value: 'unknown', + }, + ], + _description: + 'The options for sex selection during patient registration. This is Administrative Gender as it is called by FHIR (Possible options are limited to those defined in FHIR Administrative Gender, see https://hl7.org/fhir/R4/valueset-administrative-gender.html).', + }, + address: { + useAddressHierarchy: { + enabled: { + _type: Type.Boolean, + _description: 'Whether to use the Address hierarchy in the registration form or not', + _default: true, + }, + useQuickSearch: { + _type: Type.Boolean, + _description: + 'Whether to use the quick searching through the address saved in the database pre-fill the form.', + _default: true, + }, + searchAddressByLevel: { + _type: Type.Boolean, + _description: + "Whether to fill the addresses by levels, i.e. County => subCounty, the current field is dependent on it's previous field.", + _default: false, + }, + useAddressHierarchyLabel: { + _type: Type.Object, + _description: 'Whether to use custom labels for address hierarchy', + _default: {}, + }, + }, + }, + dateOfBirth: { + allowEstimatedDateOfBirth: { + _type: Type.Boolean, + _description: 'Whether to allow estimated date of birth for a patient during registration', + _default: true, + }, + useEstimatedDateOfBirth: { + enabled: { + _type: Type.Boolean, + _description: 'Whether to use a fixed day and month for estimated date of birth', + _default: false, + }, + dayOfMonth: { + _type: Type.Number, + _description: 'The custom day of the month use on the estimated date of birth', + _default: 0, + }, + month: { + _type: Type.Number, + _description: 'The custom month to use on the estimated date of birth i.e 0 = Jan & 11 = Dec', + _default: 0, + }, + }, + }, + phone: { + personAttributeUuid: { + _type: Type.UUID, + _default: '14d4f066-15f5-102d-96e4-000c29c2a5d7', + _description: 'The UUID of the phone number person attribute type', + }, + }, + }, + links: { + submitButton: { + _type: Type.String, + _default: '${openmrsSpaBase}/patient/${patientUuid}/chart', + _validators: [validators.isUrlWithTemplateParameters(['patientUuid'])], + }, + }, + concepts: { + patientPhotoUuid: { + _type: Type.ConceptUuid, + _default: '736e8771-e501-4615-bfa7-570c03f4bef5', + }, + }, + defaultPatientIdentifierTypes: { + _type: Type.Array, + _elements: { + _type: Type.PatientIdentifierTypeUuid, + }, + _default: [], + }, + registrationObs: { + encounterTypeUuid: { + _type: Type.UUID, + _default: null, + _description: + 'Obs created during registration will be associated with an encounter of this type. This must be set in order to use fields of type `obs`.', + }, + encounterProviderRoleUuid: { + _type: Type.UUID, + _default: 'a0b03050-c99b-11e0-9572-0800200c9a66', + _description: "The provider role to use for the registration encounter. Default is 'Unkown'.", + }, + registrationFormUuid: { + _type: Type.UUID, + _default: null, + _description: + 'The form UUID to associate with the registration encounter. By default no form will be associated.', + }, + }, + _validators: [ + validator( + (config: RegistrationConfig) => + !config.fieldDefinitions.some((d) => d.type == 'obs') || config.registrationObs.encounterTypeUuid != null, + "If fieldDefinitions contains any fields of type 'obs', `registrationObs.encounterTypeUuid` must be specified.", + ), + validator( + (config: RegistrationConfig) => + config.sections.every((s) => + [...builtInSections, ...config.sectionDefinitions].map((sDef) => sDef.id).includes(s), + ), + (config: RegistrationConfig) => { + const allowedSections = [...builtInSections, ...config.sectionDefinitions].map((sDef) => sDef.id); + const badSection = config.sections.find((s) => !allowedSections.includes(s)); + return ( + `'${badSection}' is not a valid section ID. Valid section IDs include the built-in sections ${stringifyDefinitions( + builtInSections, + )}` + + (config.sectionDefinitions.length + ? `; and the defined sections ${stringifyDefinitions(config.sectionDefinitions)}.` + : '.') + ); + }, + ), + validator( + (config: RegistrationConfig) => + config.sectionDefinitions.every((sectionDefinition) => + sectionDefinition.fields.every((f) => + [...builtInFields, ...config.fieldDefinitions.map((fDef) => fDef.id)].includes(f), + ), + ), + (config: RegistrationConfig) => { + const allowedFields = [...builtInFields, ...config.fieldDefinitions.map((fDef) => fDef.id)]; + const badSection = config.sectionDefinitions.find((sectionDefinition) => + sectionDefinition.fields.some((f) => !allowedFields.includes(f)), + ); + const badField = badSection.fields.find((f) => !allowedFields.includes(f)); + return ( + `The section definition '${ + badSection.id + }' contains an invalid field '${badField}'. 'fields' can only contain the built-in fields '${builtInFields.join( + "', '", + )}'` + + (config.fieldDefinitions.length + ? `; or the defined fields ${stringifyDefinitions(config.fieldDefinitions)}.` + : '.') + ); + }, + ), + ], +}; + +function stringifyDefinitions(sectionDefinitions: Array) { + return `'${sectionDefinitions.map((s) => s.id).join("', '")}'`; +} diff --git a/packages/esm-patient-registration-app/src/constants.ts b/packages/esm-patient-registration-app/src/constants.ts new file mode 100644 index 00000000..a79f1eb9 --- /dev/null +++ b/packages/esm-patient-registration-app/src/constants.ts @@ -0,0 +1,14 @@ +import { omrsOfflineCachingStrategyHttpHeaderName, type OmrsOfflineHttpHeaders } from '@openmrs/esm-framework'; + +export const personRelationshipRepresentation = + 'custom:(display,uuid,' + + 'personA:(age,display,birthdate,uuid),' + + 'personB:(age,display,birthdate,uuid),' + + 'relationshipType:(uuid,display,description,aIsToB,bIsToA))'; + +export const moduleName = '@ampath/esm-patient-registration-app'; +export const patientRegistration = 'patient-registration'; + +export const cacheForOfflineHeaders: OmrsOfflineHttpHeaders = { + [omrsOfflineCachingStrategyHttpHeaderName]: 'network-first', +}; diff --git a/packages/esm-patient-registration-app/src/declarations.d.ts b/packages/esm-patient-registration-app/src/declarations.d.ts new file mode 100644 index 00000000..28644571 --- /dev/null +++ b/packages/esm-patient-registration-app/src/declarations.d.ts @@ -0,0 +1,6 @@ +declare module '@carbon/react'; +declare module '*.css'; +declare module '*.scss'; +declare module '*.png'; +declare module '*.svg'; +declare type SideNavProps = {}; diff --git a/packages/esm-patient-registration-app/src/index.ts b/packages/esm-patient-registration-app/src/index.ts new file mode 100644 index 00000000..e5e8031e --- /dev/null +++ b/packages/esm-patient-registration-app/src/index.ts @@ -0,0 +1,71 @@ +import { registerBreadcrumbs, defineConfigSchema, getAsyncLifecycle, getSyncLifecycle } from '@openmrs/esm-framework'; +import { esmPatientRegistrationSchema } from './config-schema'; +import { moduleName, patientRegistration } from './constants'; +import { setupOffline } from './offline'; +import rootComponent from './root.component'; +import addPatientLinkComponent from './add-patient-link'; +import patientPhotoComponent from './widgets/display-photo.component'; +import editPatientDetailsButtonComponent from './widgets/edit-patient-details-button.component'; +import VerificationModal from './patient-verification/verification.component'; + +export const importTranslation = require.context('../translations', false, /.json$/, 'lazy'); + +const options = { + featureName: 'Patient Registration', + moduleName, +}; + +export function startupApp() { + defineConfigSchema(moduleName, esmPatientRegistrationSchema); + + registerBreadcrumbs([ + { + path: `${window.spaBase}/${patientRegistration}`, + // t('patientRegistrationBreadcrumb', 'Patient Registration') + title: () => + Promise.resolve( + window.i18next.t('patientRegistrationBreadcrumb', { defaultValue: 'Patient Registration', ns: moduleName }), + ), + parent: `${window.spaBase}/home`, + }, + { + path: `${window.spaBase}/patient/:patientUuid/edit`, + // t('editPatientDetailsBreadcrumb', 'Edit patient details') + title: () => + Promise.resolve( + window.i18next.t('editPatientDetailsBreadcrumb', { defaultValue: 'Edit patient details', ns: moduleName }), + ), + parent: `${window.spaBase}/patient/:patientUuid/chart`, + }, + ]); + + setupOffline(); +} + +export const root = getSyncLifecycle(rootComponent, options); + +export const editPatient = getSyncLifecycle(rootComponent, { + featureName: 'edit-patient-details-form', + moduleName, +}); + +export const addPatientLink = getSyncLifecycle(addPatientLinkComponent, options); + +export const cancelPatientEditModal = getAsyncLifecycle( + () => import('./widgets/cancel-patient-edit.component'), + options, +); + +export const patientPhoto = getSyncLifecycle(patientPhotoComponent, options); + +export const editPatientDetailsButton = getSyncLifecycle(editPatientDetailsButtonComponent, { + featureName: 'edit-patient-details', + moduleName, +}); + +export const deleteIdentifierConfirmationModal = getAsyncLifecycle( + () => import('./widgets/delete-identifier-confirmation-modal'), + options, +); + +export const clientRegistryModal = getSyncLifecycle(VerificationModal, options); diff --git a/packages/esm-patient-registration-app/src/nav-link.test.tsx b/packages/esm-patient-registration-app/src/nav-link.test.tsx new file mode 100644 index 00000000..36428973 --- /dev/null +++ b/packages/esm-patient-registration-app/src/nav-link.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import Root from './nav-link'; + +describe('Nav link component', () => { + it('renders a link to the patient registration page', () => { + const { getByText } = render(); + const linkElement = getByText('Patient Registration'); + + expect(linkElement).toBeInTheDocument(); + expect(linkElement).toHaveAttribute('href', '/openmrs/spa/patient-registration'); + }); +}); diff --git a/packages/esm-patient-registration-app/src/nav-link.tsx b/packages/esm-patient-registration-app/src/nav-link.tsx new file mode 100644 index 00000000..a7f68de6 --- /dev/null +++ b/packages/esm-patient-registration-app/src/nav-link.tsx @@ -0,0 +1,10 @@ +import React from 'react'; +import { ConfigurableLink } from '@openmrs/esm-framework'; + +export default function Root() { + return ( + + Patient Registration + + ); +} diff --git a/packages/esm-patient-registration-app/src/offline.resources.ts b/packages/esm-patient-registration-app/src/offline.resources.ts new file mode 100644 index 00000000..8f6ea3ff --- /dev/null +++ b/packages/esm-patient-registration-app/src/offline.resources.ts @@ -0,0 +1,155 @@ +import React from 'react'; +import find from 'lodash-es/find'; +import camelCase from 'lodash-es/camelCase'; +import escapeRegExp from 'lodash-es/escapeRegExp'; +import { getConfig, messageOmrsServiceWorker, openmrsFetch, type Session } from '@openmrs/esm-framework'; +import type { + PatientIdentifierType, + FetchedPatientIdentifierType, + AddressTemplate, +} from './patient-registration/patient-registration.types'; +import { cacheForOfflineHeaders, moduleName } from './constants'; + +export interface Resources { + addressTemplate: AddressTemplate; + currentSession: Session; + relationshipTypes: any; + identifierTypes: Array; +} + +export const ResourcesContext = React.createContext(null); + +export async function fetchCurrentSession(): Promise { + const { data } = await cacheAndFetch('/ws/rest/v1/session'); + return data; +} + +export async function fetchAddressTemplate() { + const { data } = await cacheAndFetch('/ws/rest/v1/addresstemplate'); + return data; +} + +export async function fetchAllRelationshipTypes() { + const { data } = await cacheAndFetch('/ws/rest/v1/relationshiptype?v=default'); + return data; +} + +export async function fetchAllFieldDefinitionTypes() { + const config = await getConfig(moduleName); + + if (!config.fieldDefinitions) { + return; + } + + const fieldDefinitionPromises = config.fieldDefinitions.map((def) => fetchFieldDefinitionType(def)); + + const fieldDefinitionResults = await Promise.all(fieldDefinitionPromises); + + const mergedData = fieldDefinitionResults.reduce((merged, result) => { + if (result) { + merged.push(result); + } + return merged; + }, []); + + return mergedData; +} + +async function fetchFieldDefinitionType(fieldDefinition) { + let apiUrl = ''; + + if (fieldDefinition.type === 'person attribute') { + apiUrl = `/ws/rest/v1/personattributetype/${fieldDefinition.uuid}`; + } + + if (fieldDefinition.answerConceptSetUuid) { + await cacheAndFetch(`/ws/rest/v1/concept/${fieldDefinition.answerConceptSetUuid}`); + } + const { data } = await cacheAndFetch(apiUrl); + return data; +} + +export async function fetchPatientIdentifierTypesWithSources(): Promise> { + const patientIdentifierTypes = await fetchPatientIdentifierTypes(); + + // @ts-ignore Reason: The required props of the type are generated below. + const identifierTypes: Array = patientIdentifierTypes.filter(Boolean); + + const [autoGenOptions, ...allIdentifierSources] = await Promise.all([ + fetchAutoGenerationOptions(), + ...identifierTypes.map((identifierType) => fetchIdentifierSources(identifierType.uuid)), + ]); + + for (let i = 0; i < identifierTypes?.length; i++) { + identifierTypes[i].identifierSources = allIdentifierSources[i].data.results.map((source) => { + const option = find(autoGenOptions.data.results, { source: { uuid: source.uuid } }); + source.autoGenerationOption = option; + return source; + }); + } + + return identifierTypes; +} + +async function fetchPatientIdentifierTypes(): Promise> { + const [patientIdentifierTypesResponse, primaryIdentifierTypeResponse] = await Promise.all([ + cacheAndFetch('/ws/rest/v1/patientidentifiertype?v=custom:(display,uuid,name,format,required,uniquenessBehavior)'), + cacheAndFetch('/ws/rest/v1/metadatamapping/termmapping?v=full&code=emr.primaryIdentifierType'), + ]); + + if (patientIdentifierTypesResponse.ok) { + // Primary identifier type is to be kept at the top of the list. + const patientIdentifierTypes = patientIdentifierTypesResponse?.data?.results; + + const primaryIdentifierTypeUuid = primaryIdentifierTypeResponse?.data?.results?.[0]?.metadataUuid; + + let identifierTypes = primaryIdentifierTypeResponse?.ok + ? [ + mapPatientIdentifierType( + patientIdentifierTypes?.find((type) => type.uuid === primaryIdentifierTypeUuid), + true, + ), + ] + : []; + + patientIdentifierTypes.forEach((type) => { + if (type.uuid !== primaryIdentifierTypeUuid) { + identifierTypes.push(mapPatientIdentifierType(type, false)); + } + }); + return identifierTypes; + } + + return []; +} + +async function fetchIdentifierSources(identifierType: string) { + return await cacheAndFetch(`/ws/rest/v1/idgen/identifiersource?v=default&identifierType=${identifierType}`); +} + +async function fetchAutoGenerationOptions(abortController?: AbortController) { + return await cacheAndFetch(`/ws/rest/v1/idgen/autogenerationoption?v=full`); +} + +async function cacheAndFetch(url?: string) { + const abortController = new AbortController(); + + await messageOmrsServiceWorker({ + type: 'registerDynamicRoute', + pattern: escapeRegExp(url), + }); + + return await openmrsFetch(url, { headers: cacheForOfflineHeaders, signal: abortController?.signal }); +} + +function mapPatientIdentifierType(patientIdentifierType, isPrimary) { + return { + name: patientIdentifierType.display, + fieldName: camelCase(patientIdentifierType.name), + required: patientIdentifierType.required, + uuid: patientIdentifierType.uuid, + format: patientIdentifierType.format, + isPrimary, + uniquenessBehavior: patientIdentifierType.uniquenessBehavior, + }; +} diff --git a/packages/esm-patient-registration-app/src/offline.ts b/packages/esm-patient-registration-app/src/offline.ts new file mode 100644 index 00000000..d1882cf8 --- /dev/null +++ b/packages/esm-patient-registration-app/src/offline.ts @@ -0,0 +1,91 @@ +import { + makeUrl, + messageOmrsServiceWorker, + navigate, + setupDynamicOfflineDataHandler, + setupOfflineSync, + type SyncProcessOptions, +} from '@openmrs/esm-framework'; +import { patientRegistration, personRelationshipRepresentation } from './constants'; +import { + fetchAddressTemplate, + fetchAllFieldDefinitionTypes, + fetchAllRelationshipTypes, + fetchCurrentSession, + fetchPatientIdentifierTypesWithSources, +} from './offline.resources'; +import { FormManager } from './patient-registration/form-manager'; +import { type PatientRegistration } from './patient-registration/patient-registration.types'; + +export function setupOffline() { + setupOfflineSync(patientRegistration, [], syncPatientRegistration, { + onBeginEditSyncItem(syncItem) { + navigate({ to: `\${openmrsSpaBase}/patient/${syncItem.content.fhirPatient.id}/edit` }); + }, + }); + + precacheStaticAssets(); + + setupDynamicOfflineDataHandler({ + id: 'esm-patient-registration-app:patient', + type: 'patient', + displayName: 'Patient registration', + async isSynced(patientUuid) { + const expectedUrls = getPatientUrlsToBeCached(patientUuid); + const cache = await caches.open('omrs-spa-cache-v1'); + const keys = (await cache.keys()).map((key) => key.url); + return expectedUrls.every((url) => keys.includes(url)); + }, + async sync(patientUuid) { + const urlsToCache = getPatientUrlsToBeCached(patientUuid); + await Promise.allSettled( + urlsToCache.map(async (url) => { + await messageOmrsServiceWorker({ + type: 'registerDynamicRoute', + url, + }); + + await fetch(url); + }), + ); + }, + }); +} + +function getPatientUrlsToBeCached(patientUuid: string) { + return [ + `/ws/fhir2/R4/Patient/${patientUuid}`, + `/ws/rest/v1/relationship?v=${personRelationshipRepresentation}&person=${patientUuid}`, + `/ws/rest/v1/person/${patientUuid}/attribute`, + `/ws/rest/v1/patient/${patientUuid}/identifier?v=custom:(uuid,identifier,identifierType:(uuid,required,name),preferred)`, + ].map((url) => window.origin + makeUrl(url)); +} + +async function precacheStaticAssets() { + await Promise.all([ + fetchCurrentSession(), + fetchAddressTemplate(), + fetchAllRelationshipTypes(), + fetchAllFieldDefinitionTypes(), + fetchPatientIdentifierTypesWithSources(), + ]); +} + +export async function syncPatientRegistration( + queuedPatient: PatientRegistration, + options: SyncProcessOptions, +) { + await FormManager.savePatientFormOnline( + queuedPatient._patientRegistrationData.isNewPatient, + queuedPatient._patientRegistrationData.formValues, + queuedPatient._patientRegistrationData.patientUuidMap, + queuedPatient._patientRegistrationData.initialAddressFieldValues, + queuedPatient._patientRegistrationData.capturePhotoProps, + queuedPatient._patientRegistrationData.currentLocation, + queuedPatient._patientRegistrationData.initialIdentifierValues, + queuedPatient._patientRegistrationData.currentUser, + queuedPatient._patientRegistrationData.config, + queuedPatient._patientRegistrationData.savePatientTransactionManager, + options.abort, + ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/before-save-prompt.tsx b/packages/esm-patient-registration-app/src/patient-registration/before-save-prompt.tsx new file mode 100644 index 00000000..0db6d689 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/before-save-prompt.tsx @@ -0,0 +1,73 @@ +import type React from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { showModal, navigate } from '@openmrs/esm-framework'; +import { useTranslation } from 'react-i18next'; + +function getUrlWithoutPrefix(url: string) { + return url.split(window['getOpenmrsSpaBase']())?.[1]; +} + +interface BeforeSavePromptProps { + when: boolean; + redirect?: string; +} + +const BeforeSavePrompt: React.FC = ({ when, redirect }) => { + const { t } = useTranslation(); + const ref = useRef(false); + const [localTarget, setTarget] = useState(); + const target = localTarget || redirect; + const cancelUnload = useCallback( + (e: BeforeUnloadEvent) => { + const message = t( + 'discardModalBody', + "The changes you made to this patient's details have not been saved. Discard changes?", + ); + e.preventDefault(); + e.returnValue = message; + return message; + }, + [t], + ); + + const cancelNavigation = useCallback((evt: CustomEvent) => { + if (!evt.detail.navigationIsCanceled && !ref.current) { + ref.current = true; + evt.detail.cancelNavigation(); + const dispose = showModal( + 'cancel-patient-edit-modal', + { + onConfirm: () => { + setTarget(evt.detail.newUrl); + dispose(); + }, + }, + () => { + ref.current = false; + }, + ); + } + }, []); + + useEffect(() => { + if (when && typeof target === 'undefined') { + window.addEventListener('single-spa:before-routing-event', cancelNavigation); + window.addEventListener('beforeunload', cancelUnload); + + return () => { + window.removeEventListener('beforeunload', cancelUnload); + window.removeEventListener('single-spa:before-routing-event', cancelNavigation); + }; + } + }, [target, when, cancelUnload, cancelNavigation]); + + useEffect(() => { + if (typeof target === 'string') { + navigate({ to: `\${openmrsSpaBase}/${getUrlWithoutPrefix(target)}` }); + } + }, [target]); + + return null; +}; + +export default BeforeSavePrompt; diff --git a/packages/esm-patient-registration-app/src/patient-registration/date-util.ts b/packages/esm-patient-registration-app/src/patient-registration/date-util.ts new file mode 100644 index 00000000..51b695ed --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/date-util.ts @@ -0,0 +1,52 @@ +export const generateFormatting = (order: Array, separator: string) => { + const parse = (value: string) => { + const parts = value.split(separator); + const date = new Date(null); + + order.forEach((key, index) => { + switch (key) { + case 'd': + date.setDate(parseInt(parts[index])); + break; + case 'm': + date.setMonth(parseInt(parts[index]) - 1); + break; + case 'Y': + date.setFullYear(parseInt(parts[index])); + break; + } + }); + return date; + }; + + const format = (date: Date) => { + if (date === null) { + return ''; + } else if (!(date instanceof Date)) { + return date; + } else { + const parts = []; + + order.forEach((key, index) => { + switch (key) { + case 'd': + parts[index] = date.getDate(); + break; + case 'm': + parts[index] = date.getMonth() + 1; + break; + case 'Y': + parts[index] = date.getFullYear(); + break; + } + }); + + return parts.join(separator); + } + }; + + const placeHolder = order.map((x) => (x === 'Y' ? 'YYYY' : x + x)).join(separator); + const dateFormat = order.join(separator); + + return { parse, format, placeHolder, dateFormat }; +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts new file mode 100644 index 00000000..95a7ed52 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts @@ -0,0 +1,60 @@ +import { type ConceptResponse } from '../../patient-registration.types'; + +export const useConcept = jest.fn(function mockUseConceptImplementation(uuid: string): { + data: ConceptResponse; + isLoading: boolean; +} { + let data; + if (uuid == 'weight-uuid') { + data = { + uuid: 'weight-uuid', + display: 'Weight (kg)', + datatype: { display: 'Numeric', uuid: 'num' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'chief-complaint-uuid') { + data = { + uuid: 'chief-complaint-uuid', + display: 'Chief Complaint', + datatype: { display: 'Text', uuid: 'txt' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'nationality-uuid') { + data = { + uuid: 'nationality-uuid', + display: 'Nationality', + datatype: { display: 'Coded', uuid: 'cdd' }, + answers: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + setMembers: [], + }; + } + return { + data: data ?? null, + isLoading: !data, + }; +}); + +export const useConceptAnswers = jest.fn((uuid: string) => { + if (uuid == 'nationality-uuid') { + return { + data: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + isLoading: false, + }; + } else if (uuid == 'other-countries-uuid') { + return { + data: [ + { display: 'Kenya', uuid: 'ke' }, + { display: 'Uganda', uuid: 'ug' }, + ], + isLoading: false, + }; + } +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx new file mode 100644 index 00000000..abb5fbe7 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-field.component.tsx @@ -0,0 +1,153 @@ +import React, { useEffect, useState, useContext, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ResourcesContext } from '../../../offline.resources'; +import { SkeletonText, InlineNotification } from '@carbon/react'; +import { Input } from '../../input/basic-input/input/input.component'; +import { useConfig, useConnectivity } from '@openmrs/esm-framework'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { useOrderedAddressHierarchyLevels } from './address-hierarchy.resource'; +import AddressHierarchyLevels from './address-hierarchy-levels.component'; +import AddressSearchComponent from './address-search.component'; +import styles from '../field.scss'; + +function parseString(xmlDockAsString: string) { + const parser = new DOMParser(); + return parser.parseFromString(xmlDockAsString, 'text/xml'); +} + +export const AddressComponent: React.FC = () => { + const [selected, setSelected] = useState(''); + const { addressTemplate } = useContext(ResourcesContext); + const addressLayout = useMemo(() => { + if (!addressTemplate?.lines) { + return []; + } + + const allFields = addressTemplate?.lines?.flat(); + const fields = allFields?.filter(({ isToken }) => isToken === 'IS_ADDR_TOKEN'); + const allRequiredFields = Object.fromEntries(addressTemplate?.requiredElements?.map((curr) => [curr, curr]) || []); + return fields.map(({ displayText, codeName }) => { + return { + id: codeName, + name: codeName, + label: displayText, + required: Boolean(allRequiredFields[codeName]), + }; + }); + }, [addressTemplate]); + + const { t } = useTranslation(); + const config = useConfig(); + const isOnline = useConnectivity(); + const { + fieldConfigurations: { + address: { + useAddressHierarchy: { enabled: addressHierarchyEnabled, useQuickSearch, searchAddressByLevel }, + }, + }, + } = config; + + const { setFieldValue } = useContext(PatientRegistrationContext); + const { orderedFields, isLoadingFieldOrder, errorFetchingFieldOrder } = useOrderedAddressHierarchyLevels(); + + useEffect(() => { + if (addressTemplate?.elementDefaults) { + Object.entries(addressTemplate.elementDefaults).forEach(([name, defaultValue]) => { + setFieldValue(`address.${name}`, defaultValue); + }); + } + }, [addressTemplate, setFieldValue]); + + const orderedAddressFields = useMemo(() => { + if (isLoadingFieldOrder || errorFetchingFieldOrder) { + return []; + } + + const orderMap = Object.fromEntries(orderedFields.map((field, indx) => [field, indx])); + + return [...addressLayout].sort( + (existingField1, existingField2) => orderMap[existingField1.name] - orderMap[existingField2.name], + ); + }, [isLoadingFieldOrder, errorFetchingFieldOrder, orderedFields, addressLayout]); + + if (!addressTemplate) { + return ( + + + + ); + } + + if (!addressHierarchyEnabled || !isOnline) { + return ( + + {addressLayout.map((attributes, index) => ( + + ))} + + ); + } + + if (isLoadingFieldOrder) { + return ( + + + + ); + } + + if (errorFetchingFieldOrder) { + return ( + + + + ); + } + + return ( + + {useQuickSearch && } + {searchAddressByLevel ? ( + + ) : ( + orderedAddressFields.map((attributes, index) => ( + + )) + )} + + ); +}; + +const AddressComponentContainer = ({ children }) => { + const { t } = useTranslation(); + return ( +
+

{t('addressHeader', 'Address')}

+
+ {children} +
+
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy-levels.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy-levels.component.tsx new file mode 100644 index 00000000..23ac2489 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy-levels.component.tsx @@ -0,0 +1,73 @@ +import React, { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useAddressEntries, useAddressEntryFetchConfig } from './address-hierarchy.resource'; +import { useField } from 'formik'; +import ComboInput from '../../input/combo-input/combo-input.component'; + +interface AddressHierarchyLevelsProps { + orderedAddressFields: Array; +} + +const AddressHierarchyLevels: React.FC = ({ orderedAddressFields }) => { + const { t } = useTranslation(); + + return ( + <> + {orderedAddressFields.map((attribute) => ( + + ))} + + ); +}; + +export default AddressHierarchyLevels; + +interface AddressComboBoxProps { + attribute: { + id: string; + name: string; + value: string; + label: string; + required?: boolean; + }; +} + +const AddressComboBox: React.FC = ({ attribute }) => { + const { t } = useTranslation(); + const [field, meta, { setValue }] = useField(`address.${attribute.name}`); + const { fetchEntriesForField, searchString, updateChildElements } = useAddressEntryFetchConfig(attribute.name); + const { entries } = useAddressEntries(fetchEntriesForField, searchString); + const label = t(attribute.label) + (attribute?.required ? '' : ` (${t('optional', 'optional')})`); + + const handleInputChange = useCallback( + (newValue) => { + setValue(newValue); + }, + [setValue], + ); + + const handleSelection = useCallback( + (selectedItem) => { + if (meta.value !== selectedItem) { + setValue(selectedItem); + updateChildElements(); + } + }, + [updateChildElements, meta.value, setValue], + ); + + return ( + + ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy.resource.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy.resource.tsx new file mode 100644 index 00000000..ed9e59ca --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-hierarchy.resource.tsx @@ -0,0 +1,157 @@ +import { useCallback, useContext, useEffect, useMemo } from 'react'; +import { useField } from 'formik'; +import useSWRImmutable from 'swr/immutable'; +import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework'; +import { PatientRegistrationContext } from '../../patient-registration-context'; + +interface AddressFields { + addressField: string; +} + +export function useOrderedAddressHierarchyLevels() { + const url = '/module/addresshierarchy/ajax/getOrderedAddressHierarchyLevels.form'; + const { data, isLoading, error } = useSWRImmutable>, Error>(url, openmrsFetch); + + const results = useMemo( + () => ({ + orderedFields: data?.data?.map((field) => field.addressField), + isLoadingFieldOrder: isLoading, + errorFetchingFieldOrder: error, + }), + [data, isLoading, error], + ); + + return results; +} + +export function useAddressEntries(fetchResults, searchString) { + const encodedSearchString = encodeURIComponent(searchString); + const { data, isLoading, error } = useSWRImmutable>>( + fetchResults + ? `module/addresshierarchy/ajax/getChildAddressHierarchyEntries.form?searchString=${encodedSearchString}` + : null, + openmrsFetch, + ); + + useEffect(() => { + if (error) { + console.error(error); + } + }, [error]); + + const results = useMemo( + () => ({ + entries: data?.data?.map((item) => item.name), + isLoadingAddressEntries: isLoading, + errorFetchingAddressEntries: error, + }), + [data, isLoading, error], + ); + return results; +} + +/** + * This hook is being used to fetch ordered address fields as configured in the address hierarchy + * This hook returns the valid search term for valid fields to get suitable entries for the field + * This also returns the function to reset the lower ordered fields if the value of a field is changed. + */ +export function useAddressEntryFetchConfig(addressField: string) { + const { orderedFields, isLoadingFieldOrder } = useOrderedAddressHierarchyLevels(); + const { setFieldValue } = useContext(PatientRegistrationContext); + const [, { value: addressValues }] = useField('address'); + + const index = useMemo( + () => (!isLoadingFieldOrder ? orderedFields.findIndex((field) => field === addressField) : -1), + [orderedFields, addressField, isLoadingFieldOrder], + ); + + const addressFieldSearchConfig = useMemo(() => { + let fetchEntriesForField = true; + const previousSelectedFields = orderedFields?.slice(0, index) ?? []; + let previousSelectedValues = []; + for (const fieldName of previousSelectedFields) { + if (!addressValues[fieldName]) { + fetchEntriesForField = false; + break; + } + previousSelectedValues.push(addressValues[fieldName]); + } + return { + fetchEntriesForField, + searchString: previousSelectedValues.join('|'), + }; + }, [orderedFields, index, addressValues]); + + const updateChildElements = useCallback(() => { + if (isLoadingFieldOrder) { + return; + } + orderedFields.slice(index + 1).map((fieldName) => { + setFieldValue(`address.${fieldName}`, ''); + }); + }, [index, isLoadingFieldOrder, orderedFields, setFieldValue]); + + const results = useMemo( + () => ({ + ...addressFieldSearchConfig, + updateChildElements, + }), + [addressFieldSearchConfig, updateChildElements], + ); + + return results; +} + +export function useAddressHierarchy(searchString: string, separator: string) { + const { data, error, isLoading } = useSWRImmutable< + FetchResponse< + Array<{ + address: string; + }> + >, + Error + >( + searchString + ? `/module/addresshierarchy/ajax/getPossibleFullAddresses.form?separator=${separator}&searchString=${searchString}` + : null, + openmrsFetch, + ); + + const results = useMemo( + () => ({ + addresses: data?.data?.map((address) => address.address) ?? [], + error, + isLoading, + }), + [data?.data, error, isLoading], + ); + return results; +} + +export function useAddressHierarchyWithParentSearch(addressField: string, parentid: string, query: string) { + const { data, error, isLoading } = useSWRImmutable< + FetchResponse< + Array<{ + uuid: string; + name: string; + }> + >, + Error + >( + query + ? `/module/addresshierarchy/ajax/getPossibleAddressHierarchyEntriesWithParents.form?addressField=${addressField}&limit=20&searchString=${query}&parentUuid=${parentid}` + : null, + openmrsFetch, + ); + + const results = useMemo( + () => ({ + error: error, + isLoading, + addresses: data?.data ?? [], + }), + [data?.data, error, isLoading], + ); + + return results; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.component.tsx new file mode 100644 index 00000000..d763dfd3 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.component.tsx @@ -0,0 +1,85 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { useAddressHierarchy } from './address-hierarchy.resource'; +import { Search } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { useFormikContext } from 'formik'; +import styles from './address-search.scss'; + +interface AddressSearchComponentProps { + addressLayout: Array; +} + +const AddressSearchComponent: React.FC = ({ addressLayout }) => { + const { t } = useTranslation(); + const separator = ' > '; + const searchBox = useRef(null); + const wrapper = useRef(null); + const [searchString, setSearchString] = useState(''); + const { addresses, isLoading, error } = useAddressHierarchy(searchString, separator); + const addressOptions: Array = useMemo(() => { + const options: Set = new Set(); + addresses.forEach((address) => { + const values = address.split(separator); + values.forEach((val, index) => { + if (val.toLowerCase().includes(searchString.toLowerCase())) { + options.add(values.slice(0, index + 1).join(separator)); + } + }); + }); + return [...options]; + }, [addresses, searchString]); + + const { setFieldValue } = useFormikContext(); + + const handleInputChange = (e) => { + setSearchString(e.target.value); + }; + + const handleChange = (address) => { + if (address) { + const values = address.split(separator); + addressLayout.map(({ name }, index) => { + setFieldValue(`address.${name}`, values?.[index] ?? ''); + }); + setSearchString(''); + } + }; + + const handleClickOutsideComponent = (e) => { + if (wrapper.current && !wrapper.current.contains(e.target)) { + setSearchString(''); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutsideComponent); + + return () => { + document.removeEventListener('mousedown', handleClickOutsideComponent); + }; + }, [wrapper]); + + return ( +
+ + {addressOptions.length > 0 && ( + /* Since the input has a marginBottom of 1rem */ +
    + {addressOptions.map((address, index) => ( +
  • handleChange(address)}> + {address} +
  • + ))} +
+ )} +
+ ); +}; + +export default AddressSearchComponent; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.scss b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.scss new file mode 100644 index 00000000..0b88002a --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/address-search.scss @@ -0,0 +1,53 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.label01 { + @include type.type-style('label-01'); +} + +.suggestions { + border-top-width: 0; + list-style: none; + margin-top: 0; + max-height: 20rem; + overflow-y: auto; + padding-left: 0; + width: 100%; + position: absolute; + left: 0; + background-color: #fff; + margin-bottom: 20px; + z-index: 99; + border: 1px solid $ui-03; +} + +.suggestions li { + padding: spacing.$spacing-05; + line-height: 1.29; + color: #525252; + border-bottom: 1px solid #8d8d8d; +} + +.suggestions li:hover { + background-color: #e5e5e5; + color: #161616; + cursor: pointer; +} + +.suggestions li:not(:last-of-type) { + border-bottom: 1px solid #999; +} + +.autocomplete { + position: relative; +} + +.autocompleteSearch { + width: 100%; +} + +.suggestions a { + color: inherit; + text-decoration: none; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/custom-address-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/custom-address-field.component.tsx new file mode 100644 index 00000000..aa88979e --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/custom-address-field.component.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Field } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { Input } from '../../input/basic-input/input/input.component'; +import styles from '../field.scss'; +import { type FieldDefinition } from '../../../config-schema'; + +export interface AddressFieldProps { + fieldDefinition: FieldDefinition; +} + +export const AddressField: React.FC = ({ fieldDefinition }) => { + const { t } = useTranslation(); + + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-hierarchy.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-hierarchy.test.tsx new file mode 100644 index 00000000..860c1cc3 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-hierarchy.test.tsx @@ -0,0 +1,214 @@ +import React from 'react'; +import { cleanup, render, screen } from '@testing-library/react'; +import { AddressComponent } from '../address-field.component'; +import { Formik, Form } from 'formik'; +import { type Resources, ResourcesContext } from '../../../../offline.resources'; +import { PatientRegistrationContext } from '../../../patient-registration-context'; +import { useConfig } from '@openmrs/esm-framework'; +import { useOrderedAddressHierarchyLevels } from '../address-hierarchy.resource'; +import { mockedAddressTemplate, mockedOrderedFields } from '__mocks__'; + +// Mocking the AddressSearchComponent +jest.mock('../address-search.component', () => jest.fn(() =>
)); +// Mocking the AddressHierarchyLevels +jest.mock('../address-hierarchy-levels.component', () => jest.fn(() =>
)); +// Mocking the SkeletonText +jest.mock('@carbon/react', () => ({ + ...jest.requireActual('@carbon/react'), + SkeletonText: jest.fn(() =>
), + InlineNotification: jest.fn(() =>
), +})); + +jest.mock('@openmrs/esm-framework', () => ({ + ...jest.requireActual('@openmrs/esm-framework'), + useConfig: jest.fn(), +})); + +jest.mock('../address-hierarchy.resource', () => ({ + ...(jest.requireActual('../address-hierarchy.resource') as jest.Mock), + useOrderedAddressHierarchyLevels: jest.fn(), +})); + +async function renderAddressHierarchy(addressTemplate = mockedAddressTemplate) { + await render( + + +
+ + + +
+
+
, + ); +} + +describe('Testing address hierarchy', () => { + beforeEach(cleanup); + + it('should render skeleton when address template is loading', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: false, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + // @ts-ignore + renderAddressHierarchy(null); + const skeletonText = screen.getByTestId('skeleton-text'); + expect(skeletonText).toBeInTheDocument(); + }); + + it('should render skeleton when address hierarchy is enabled and addresshierarchy order is loading', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: true, + errorFetchingFieldOrder: null, + })); + renderAddressHierarchy(); + const skeletonText = screen.getByTestId('skeleton-text'); + expect(skeletonText).toBeInTheDocument(); + }); + + it('should render skeleton when address hierarchy is enabled and addresshierarchy order is loading', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: true, + })); + renderAddressHierarchy(); + const inlineNotification = screen.getByTestId('inline-notification'); + expect(inlineNotification).toBeInTheDocument(); + }); + + it('should render the address component with address hierarchy disabled', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: false, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + renderAddressHierarchy(); + const allFields = mockedAddressTemplate.lines.flat().filter(({ isToken }) => isToken === 'IS_ADDR_TOKEN'); + allFields.forEach((field) => { + const textFieldInput = screen.getByLabelText(`${field.displayText} (optional)`); + expect(textFieldInput).toBeInTheDocument(); + }); + }); + + it('should render the fields in order if the address hierarcy is enabled', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + renderAddressHierarchy(); + const allFields = mockedAddressTemplate.lines.flat().filter(({ isToken }) => isToken === 'IS_ADDR_TOKEN'); + const orderMap = Object.fromEntries(mockedOrderedFields.map((field, indx) => [field, indx])); + allFields.sort( + (existingField1, existingField2) => + orderMap[existingField1.codeName ?? 0] - orderMap[existingField2.codeName ?? 0], + ); + allFields.forEach((field) => { + const textFieldInput = screen.getByLabelText(`${field.displayText} (optional)`); + expect(textFieldInput).toBeInTheDocument(); + }); + }); + + it('should render quick search bar on above the fields when address hierarchy is enabled and quicksearch is set to true', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: true, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + renderAddressHierarchy(); + const addressSearchBar = screen.getByTestId('address-search-bar'); + expect(addressSearchBar).toBeInTheDocument(); + }); + + it('should render combo boxes fields when address hierarchy is enabled and searchAddressByLevel is set to true', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: false, + searchAddressByLevel: true, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: [], + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + renderAddressHierarchy(); + const addressHierarchyLevels = screen.getByTestId('address-hierarchy-levels'); + expect(addressHierarchyLevels).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-search-component.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-search-component.test.tsx new file mode 100644 index 00000000..45a8fa88 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/address/tests/address-search-component.test.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { Formik, Form, useFormikContext } from 'formik'; +import { type Resources, ResourcesContext } from '../../../../offline.resources'; +import { PatientRegistrationContext } from '../../../patient-registration-context'; +import { useConfig } from '@openmrs/esm-framework'; +import { useAddressHierarchy, useOrderedAddressHierarchyLevels } from '../address-hierarchy.resource'; +import { mockedAddressTemplate, mockedAddressOptions, mockedOrderedFields } from '__mocks__'; +import AddressSearchComponent from '../address-search.component'; + +useAddressHierarchy; +jest.mock('@openmrs/esm-framework', () => ({ + ...jest.requireActual('@openmrs/esm-framework'), + useConfig: jest.fn(), +})); + +jest.mock('../address-hierarchy.resource', () => ({ + ...(jest.requireActual('../address-hierarchy.resource') as jest.Mock), + useOrderedAddressHierarchyLevels: jest.fn(), + useAddressHierarchy: jest.fn(), +})); + +jest.mock('../../../patient-registration.resource', () => ({ + ...(jest.requireActual('../../../../patient-registration.resource') as jest.Mock), + useAddressHierarchy: jest.fn(), +})); + +jest.mock('formik', () => ({ + ...(jest.requireActual('formik') as jest.Mock), + useFormikContext: jest.fn(() => ({})), +})); + +const allFields = mockedAddressTemplate.lines + .flat() + .filter((field) => field.isToken === 'IS_ADDR_TOKEN') + .map(({ codeName, displayText }) => ({ + id: codeName, + name: codeName, + label: displayText, + })); +const orderMap = Object.fromEntries(mockedOrderedFields.map((field, indx) => [field, indx])); +allFields.sort((existingField1, existingField2) => orderMap[existingField1.name] - orderMap[existingField2.name]); + +async function renderAddressHierarchy(addressTemplate = mockedAddressTemplate) { + await render( + + +
+ + + +
+
+
, + ); +} + +const setFieldValue = jest.fn(); + +describe('Testing address search bar', () => { + beforeEach(() => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: true, + useQuickSearch: true, + searchAddressByLevel: false, + }, + }, + }, + })); + (useOrderedAddressHierarchyLevels as jest.Mock).mockImplementation(() => ({ + orderedFields: mockedOrderedFields, + isLoadingFieldOrder: false, + errorFetchingFieldOrder: null, + })); + (useFormikContext as jest.Mock).mockImplementation(() => ({ + setFieldValue, + })); + }); + + it('should render the search bar', () => { + (useAddressHierarchy as jest.Mock).mockImplementation(() => ({ + addresses: [], + error: null, + isLoading: false, + })); + + renderAddressHierarchy(); + + const searchbox = screen.getByRole('searchbox'); + expect(searchbox).toBeInTheDocument(); + + const ul = screen.queryByRole('list'); + expect(ul).not.toBeInTheDocument(); + }); + + it("should render only the results for the search term matched address' parents", async () => { + const user = userEvent.setup(); + + (useAddressHierarchy as jest.Mock).mockImplementation(() => ({ + addresses: mockedAddressOptions, + error: null, + isLoading: false, + })); + + renderAddressHierarchy(); + + const searchString = 'nea'; + const separator = ' > '; + const options: Set = new Set(); + + mockedAddressOptions.forEach((address) => { + const values = address.split(separator); + values.forEach((val, index) => { + if (val.toLowerCase().includes(searchString.toLowerCase())) { + options.add(values.slice(0, index + 1).join(separator)); + } + }); + }); + + const addressOptions = [...options]; + addressOptions.forEach(async (address) => { + const optionElement = screen.getByText(address); + expect(optionElement).toBeInTheDocument(); + await user.click(optionElement); + const values = address.split(separator); + allFields.map(({ name }, index) => { + expect(setFieldValue).toHaveBeenCalledWith(`address.${name}`, values?.[index]); + }); + }); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx new file mode 100644 index 00000000..f8316a02 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx @@ -0,0 +1,25 @@ +import { useConfig } from '@openmrs/esm-framework'; +import React from 'react'; +import { type RegistrationConfig } from '../../config-schema'; +import { AddressField } from './address/custom-address-field.component'; +import { ObsField } from './obs/obs-field.component'; +import { PersonAttributeField } from './person-attributes/person-attribute-field.component'; + +export interface CustomFieldProps { + name: string; +} + +export function CustomField({ name }: CustomFieldProps) { + const config = useConfig() as RegistrationConfig; + const fieldDefinition = config.fieldDefinitions.filter((def) => def.id == name)[0]; + + if (fieldDefinition.type === 'person attribute') { + return ; + } else if (fieldDefinition.type === 'obs') { + return ; + } else if (fieldDefinition.type === 'address') { + return ; + } else { + return
Error: Unknown field type {fieldDefinition.type}
; + } +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.component.tsx new file mode 100644 index 00000000..50f52962 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.component.tsx @@ -0,0 +1,159 @@ +import React, { type ChangeEvent, useCallback, useContext } from 'react'; +import { ContentSwitcher, DatePicker, DatePickerInput, Layer, Switch, TextInput } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { useField } from 'formik'; +import { generateFormatting } from '../../date-util'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { useConfig } from '@openmrs/esm-framework'; +import { type RegistrationConfig } from '../../../config-schema'; +import styles from '../field.scss'; + +const calcBirthdate = (yearDelta, monthDelta, dateOfBirth) => { + const { enabled, month, dayOfMonth } = dateOfBirth.useEstimatedDateOfBirth; + const startDate = new Date(); + const resultMonth = new Date(startDate.getFullYear() - yearDelta, startDate.getMonth() - monthDelta, 1); + const daysInResultMonth = new Date(resultMonth.getFullYear(), resultMonth.getMonth() + 1, 0).getDate(); + const resultDate = new Date( + resultMonth.getFullYear(), + resultMonth.getMonth(), + Math.min(startDate.getDate(), daysInResultMonth), + ); + return enabled ? new Date(resultDate.getFullYear(), month, dayOfMonth) : resultDate; +}; + +export const DobField: React.FC = () => { + const { t } = useTranslation(); + const { + fieldConfigurations: { dateOfBirth }, + } = useConfig(); + const allowEstimatedBirthDate = dateOfBirth?.allowEstimatedDateOfBirth; + const [{ value: dobUnknown }] = useField('birthdateEstimated'); + const [birthdate, birthdateMeta] = useField('birthdate'); + const [yearsEstimated, yearsEstimateMeta] = useField('yearsEstimated'); + const [monthsEstimated, monthsEstimateMeta] = useField('monthsEstimated'); + const { setFieldValue } = useContext(PatientRegistrationContext); + const { format, placeHolder, dateFormat } = generateFormatting(['d', 'm', 'Y'], '/'); + const today = new Date(); + + const onToggle = useCallback( + (e: { name?: string | number }) => { + setFieldValue('birthdateEstimated', e.name === 'unknown'); + setFieldValue('birthdate', ''); + setFieldValue('yearsEstimated', 0); + setFieldValue('monthsEstimated', ''); + }, + [setFieldValue], + ); + + const onDateChange = useCallback( + (birthdate: Date[]) => { + setFieldValue('birthdate', birthdate[0]); + }, + [setFieldValue], + ); + + const onEstimatedYearsChange = useCallback( + (ev: ChangeEvent) => { + const years = +ev.target.value; + + if (!isNaN(years) && years < 140 && years >= 0) { + setFieldValue('yearsEstimated', years); + setFieldValue('birthdate', calcBirthdate(years, monthsEstimateMeta.value, dateOfBirth)); + } + }, + [setFieldValue, dateOfBirth, monthsEstimateMeta.value], + ); + + const onEstimatedMonthsChange = useCallback( + (ev: ChangeEvent) => { + const months = +ev.target.value; + + if (!isNaN(months)) { + setFieldValue('monthsEstimated', months); + setFieldValue('birthdate', calcBirthdate(yearsEstimateMeta.value, months, dateOfBirth)); + } + }, + [setFieldValue, dateOfBirth, yearsEstimateMeta.value], + ); + + const updateBirthdate = useCallback(() => { + const months = +monthsEstimateMeta.value % 12; + const years = +yearsEstimateMeta.value + Math.floor(monthsEstimateMeta.value / 12); + setFieldValue('yearsEstimated', years); + setFieldValue('monthsEstimated', months > 0 ? months : ''); + setFieldValue('birthdate', calcBirthdate(years, months, dateOfBirth)); + }, [setFieldValue, monthsEstimateMeta, yearsEstimateMeta, dateOfBirth]); + + return ( +
+

{t('birthFieldLabelText', 'Birth')}

+ {(allowEstimatedBirthDate || dobUnknown) && ( +
+
+ {t('dobToggleLabelText', 'Date of Birth Known?')} +
+ + + + +
+ )} + + {!dobUnknown ? ( +
+ + + +
+ ) : ( +
+
+ + + +
+
+ + + +
+
+ )} +
+
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.test.tsx new file mode 100644 index 00000000..9448e1a0 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/dob/dob.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Formik, Form } from 'formik'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DobField } from './dob.component'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { initialFormValues } from '../../patient-registration.component'; +import { type FormValues } from '../../patient-registration.types'; + +jest.mock('@openmrs/esm-framework', () => { + const originalModule = jest.requireActual('@openmrs/esm-framework'); + return { + ...originalModule, + useConfig: jest.fn().mockImplementation(() => ({ + fieldConfigurations: { + dateOfBirth: { + allowEstimatedDateOfBirth: true, + useEstimatedDateOfBirth: { enabled: true, dayOfMonth: 0, month: 0 }, + }, + }, + })), + }; +}); + +describe('Dob', () => { + it('renders the fields in the birth section of the registration form', async () => { + renderDob(); + + expect(screen.getByRole('heading', { name: /birth/i })).toBeInTheDocument(); + expect(screen.getByText(/date of birth known?/i)).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /no/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /yes/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /yes/i })).toHaveAttribute('aria-selected', 'true'); + expect(screen.getByRole('tab', { name: /no/i })).toHaveAttribute('aria-selected', 'false'); + expect(screen.getByRole('textbox', { name: /date of birth/i })).toBeInTheDocument(); + }); + + it('typing in the date picker input sets the date of birth', async () => { + const user = userEvent.setup(); + + renderDob(); + + const dateInput = screen.getByRole('textbox', { name: /date of birth/i }); + expect(dateInput).toBeInTheDocument(); + + await user.type(dateInput, '10/10/2022'); + + expect(screen.getByPlaceholderText('dd/mm/YYYY')).toHaveValue('10/10/2022'); + }); +}); + +function renderDob() { + let formValues: FormValues = initialFormValues; + + render( + {}}> +
+ {}, + setCapturePhotoProps: (value) => {}, + currentPhoto: '', + isOffline: false, + initialFormValues: formValues, + }}> + + +
+
, + ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/field.component.tsx new file mode 100644 index 00000000..bdadee3d --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.component.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { NameField } from './name/name-field.component'; +import { GenderField } from './gender/gender-field.component'; +import { Identifiers } from './id/id-field.component'; +import { DobField } from './dob/dob.component'; +import { reportError, useConfig } from '@openmrs/esm-framework'; +import { builtInFields, type RegistrationConfig } from '../../config-schema'; +import { CustomField } from './custom-field.component'; +import { AddressComponent } from './address/address-field.component'; +import { PhoneField } from './phone/phone-field.component'; + +export interface FieldProps { + name: string; +} + +export function Field({ name }: FieldProps) { + const config = useConfig() as RegistrationConfig; + if ( + !(builtInFields as ReadonlyArray).includes(name) && + !config.fieldDefinitions.some((def) => def.id == name) + ) { + reportError( + `Invalid field name '${name}'. Valid options are '${config.fieldDefinitions + .map((def) => def.id) + .concat(builtInFields) + .join("', '")}'.`, + ); + return null; + } + + switch (name) { + case 'name': + return ; + case 'gender': + return ; + case 'dob': + return ; + case 'address': + return ; + case 'id': + return ; + case 'phone': + return ; + default: + return ; + } +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts new file mode 100644 index 00000000..60a80ae1 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts @@ -0,0 +1,35 @@ +import { type FetchResponse, openmrsFetch, showSnackbar } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; +import { type ConceptAnswers, type ConceptResponse } from '../patient-registration.types'; + +export function useConcept(conceptUuid: string): { data: ConceptResponse; isLoading: boolean } { + const shouldFetch = typeof conceptUuid === 'string' && conceptUuid !== ''; + const { data, error, isLoading } = useSWRImmutable, Error>( + shouldFetch ? `/ws/rest/v1/concept/${conceptUuid}` : null, + openmrsFetch, + ); + if (error) { + showSnackbar({ + title: error.name, + subtitle: error.message, + kind: 'error', + }); + } + return { data: data?.data, isLoading }; +} + +export function useConceptAnswers(conceptUuid: string): { data: Array; isLoading: boolean } { + const shouldFetch = typeof conceptUuid === 'string' && conceptUuid !== ''; + const { data, error, isLoading } = useSWRImmutable, Error>( + shouldFetch ? `/ws/rest/v1/concept/${conceptUuid}` : null, + openmrsFetch, + ); + if (error) { + showSnackbar({ + title: error.name, + subtitle: error.message, + kind: 'error', + }); + } + return { data: data?.data?.answers, isLoading }; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.scss b/packages/esm-patient-registration-app/src/patient-registration/field/field.scss new file mode 100644 index 00000000..594928b3 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.scss @@ -0,0 +1,127 @@ +@use '@carbon/styles/scss/spacing'; +@use '@carbon/styles/scss/type'; +@import '~@openmrs/esm-styleguide/src/vars'; + +.productiveHeading02 { + @include type.type-style('heading-compact-02'); + margin-bottom: 1rem; +} + +.productiveHeading02Light { + @include type.type-style('heading-compact-02'); + margin-bottom: 1rem; + color: #525252; +} + +.label01 { + @include type.type-style('label-01'); +} + +.grid { + display: grid; + grid-template-columns: 1fr 1fr; + column-gap: spacing.$spacing-05; +} + +.halfWidthInDesktopView { + width: calc(50% - spacing.$spacing-05); +} + +.patientPhoto { + display: flex; + justify-content: center; +} + +.nameField { + grid-row: 1; + grid-column: 1; +} + +.nameField > :global(.cds--content-switcher) { + display: grid; + grid-template-columns: 1fr 1fr; + width: max-content; + justify-content: flex-start; +} + +.contentSwitcher { + margin-bottom: 1rem; +} + +.dobField > :global(.cds--content-switcher) { + display: grid; + grid-template-columns: 1fr 1fr; + width: max-content; + justify-content: flex-start; +} + +.photoExtension { + margin-bottom: 1rem; + grid-row: 1; + grid-column: 2; + justify-self: center; +} + +.sexField, +.dobField { + margin-bottom: spacing.$spacing-05; +} + +.dobContentSwitcherLabel { + margin-bottom: spacing.$spacing-03; +} + +.identifierLabelText { + display: flex; + align-items: center; +} + +.setIDNumberButton { + margin-bottom: spacing.$spacing-05; +} + +.setIDNumberButton > svg { + margin-left: spacing.$spacing-03; +} + +.customField { + margin-bottom: spacing.$spacing-05; +} + +.attributeField { + margin-bottom: spacing.$spacing-05; +} + +:global(.omrs-breakpoint-lt-desktop) { + .grid { + grid-template-columns: 1fr; + grid-template-rows: auto auto; + } + .nameField { + grid-row: 2; + grid-column: 1; + } + .photoExtension { + grid-column: 1; + grid-row: 1; + justify-self: start; + } + .radioButton label { + height: spacing.$spacing-09 !important; + } + .halfWidthInDesktopView { + width: 100%; + } +} + +.radioFieldError { + color: #da1e28; + display: block; + font-weight: 400; + max-height: 12.5rem; + overflow: visible; + font-size: 0.75rem; + letter-spacing: 0.32px; + line-height: 1.34; + margin: 0.25rem 0 0; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/field.test.tsx new file mode 100644 index 00000000..f4cda088 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.test.tsx @@ -0,0 +1,294 @@ +import React from 'react'; +import { Form, Formik } from 'formik'; +import { render, screen } from '@testing-library/react'; +import { useConfig } from '@openmrs/esm-framework'; +import { Field } from './field.component'; +import type { AddressTemplate, FormValues } from '../patient-registration.types'; +import { type Resources, ResourcesContext } from '../../offline.resources'; +import { PatientRegistrationContext } from '../patient-registration-context'; + +jest.mock('@openmrs/esm-framework', () => ({ + ...jest.requireActual('@openmrs/esm-framework'), + useConfig: jest.fn(), +})); + +const predefinedAddressTemplate = { + uuid: 'test-address-template-uuid', + property: 'layout.address.format', + description: 'Test Address Template', + display: + 'Layout - Address Format = \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n address1\n address2\n cityVillage stateProvince country postalCode\n \n ', + value: + '\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n address1 address2\r\n cityVillage stateProvince postalCode\r\n country\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n ', +}; + +const mockedIdentifierTypes = [ + { + fieldName: 'openMrsId', + format: '', + identifierSources: [ + { + uuid: '8549f706-7e85-4c1d-9424-217d50a2988b', + name: 'Generator for OpenMRS ID', + description: 'Generator for OpenMRS ID', + baseCharacterSet: '0123456789ACDEFGHJKLMNPRTUVWXY', + prefix: '', + }, + ], + isPrimary: true, + name: 'OpenMRS ID', + required: true, + uniquenessBehavior: 'UNIQUE' as const, + uuid: '05a29f94-c0ed-11e2-94be-8c13b969e334', + }, + { + fieldName: 'idCard', + format: '', + identifierSources: [], + isPrimary: false, + name: 'ID Card', + required: false, + uniquenessBehavior: 'UNIQUE' as const, + uuid: 'b4143563-16cd-4439-b288-f83d61670fc8', + }, + { + fieldName: 'legacyId', + format: '', + identifierSources: [], + isPrimary: false, + name: 'Legacy ID', + required: false, + uniquenessBehavior: null, + uuid: '22348099-3873-459e-a32e-d93b17eda533', + }, + { + fieldName: 'oldIdentificationNumber', + format: '', + identifierSources: [], + isPrimary: false, + name: 'Old Identification Number', + required: false, + uniquenessBehavior: null, + uuid: '8d79403a-c2cc-11de-8d13-0010c6dffd0f', + }, + { + fieldName: 'openMrsIdentificationNumber', + format: '', + identifierSources: [], + isPrimary: false, + name: 'OpenMRS Identification Number', + required: false, + uniquenessBehavior: null, + uuid: '8d793bee-c2cc-11de-8d13-0010c6dffd0f', + }, +]; + +const mockResourcesContextValue: Resources = { + addressTemplate: predefinedAddressTemplate as unknown as AddressTemplate, + currentSession: { + authenticated: true, + sessionId: 'JSESSION', + currentProvider: { uuid: 'provider-uuid', identifier: 'PRO-123' }, + }, + relationshipTypes: [], + identifierTypes: [...mockedIdentifierTypes], +}; + +const initialContextValues = { + currentPhoto: 'data:image/png;base64,1234567890', + identifierTypes: [], + inEditMode: false, + initialFormValues: {} as FormValues, + isOffline: false, + setCapturePhotoProps: jest.fn(), + setFieldValue: jest.fn(), + setInitialFormValues: jest.fn(), + validationSchema: null, + values: {} as FormValues, +}; + +describe('Field', () => { + let ContextWrapper; + + beforeEach(() => { + ContextWrapper = ({ children }) => ( + + +
+ + {children} + +
+
+
+ ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render NameField component when name prop is "name"', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + name: { + displayMiddleName: true, + unidentifiedPatient: true, + defaultUnknownGivenName: 'UNKNOWN', + defaultUnknownFamilyName: 'UNKNOWN', + }, + }, + })); + + render(, { wrapper: ContextWrapper }); + + expect(screen.getByText('Full Name')).toBeInTheDocument(); + }); + + it('should render GenderField component when name prop is "gender"', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + gender: [ + { + value: 'Male', + label: 'Male', + id: 'male', + }, + ], + }, + })); + + render(, { wrapper: ContextWrapper }); + + expect(screen.getByLabelText('Male')).toBeInTheDocument(); + }); + + it('should render DobField component when name prop is "dob"', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + dob: { + minAgeLimit: 0, + maxAgeLimit: 120, + }, + }, + })); + render(, { wrapper: ContextWrapper }); + expect(screen.getByText('Birth')).toBeInTheDocument(); + }); + + it('should render AddressComponent component when name prop is "address"', () => { + jest.mock('./address/address-hierarchy.resource', () => ({ + ...(jest.requireActual('../address-hierarchy.resource') as jest.Mock), + useOrderedAddressHierarchyLevels: jest.fn(), + })); + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldConfigurations: { + address: { + useAddressHierarchy: { + enabled: false, + useQuickSearch: false, + searchAddressByLevel: false, + }, + }, + }, + })); + + render(, { wrapper: ContextWrapper }); + + expect(screen.getByText('Address')).toBeInTheDocument(); + }); + + it('should render Identifiers component when name prop is "id"', () => { + (useConfig as jest.Mock).mockImplementation(() => ({ + defaultPatientIdentifierTypes: ['OpenMRS ID'], + })); + + const openmrsID = { + name: 'OpenMRS ID', + fieldName: 'openMrsId', + required: true, + uuid: '05a29f94-c0ed-11e2-94be-8c13b969e334', + format: null, + isPrimary: true, + identifierSources: [ + { + uuid: '691eed12-c0f1-11e2-94be-8c13b969e334', + name: 'Generator 1 for OpenMRS ID', + autoGenerationOption: { + manualEntryEnabled: false, + automaticGenerationEnabled: true, + }, + }, + { + uuid: '01af8526-cea4-4175-aa90-340acb411771', + name: 'Generator 2 for OpenMRS ID', + autoGenerationOption: { + manualEntryEnabled: true, + automaticGenerationEnabled: true, + }, + }, + ], + identifierUuid: 'openmrs-identifier-uuid', + identifierTypeUuid: 'openmrs-id-identifier-type-uuid', + initialValue: '12345', + identifierValue: '12345', + identifierName: 'OpenMRS ID', + preferred: true, + selectedSource: { + uuid: 'openmrs-id-selected-source-uuid', + name: 'Generator 1 for OpenMRS ID', + autoGenerationOption: { + manualEntryEnabled: false, + automaticGenerationEnabled: true, + }, + }, + autoGenerationSource: null, + }; + + const updatedContextValues = { + currentPhoto: 'data:image/png;base64,1234567890', + identifierTypes: [], + inEditMode: false, + initialFormValues: { identifiers: { openmrsID } } as unknown as FormValues, + isOffline: false, + setCapturePhotoProps: jest.fn(), + setFieldValue: jest.fn(), + setInitialFormValues: jest.fn(), + validationSchema: null, + values: { identifiers: { openmrsID } } as unknown as FormValues, + }; + + render( + + +
+ + + +
+
+
, + ); + expect(screen.getByText('Identifiers')).toBeInTheDocument(); + }); + + it('should return null and report an error for an invalid field name', () => { + const consoleError = jest.spyOn(console, 'error').mockImplementation(() => {}); + + (useConfig as jest.Mock).mockImplementation(() => ({ + fieldDefinitions: [{ id: 'weight' }], + })); + let error = null; + + try { + render(); + } catch (err) { + error = err; + } + + expect(error).toMatch(/Invalid field name 'invalidField'. Valid options are /); + expect(screen.queryByTestId('invalid-field')).not.toBeInTheDocument(); + + consoleError.mockRestore(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.component.tsx new file mode 100644 index 00000000..465839cb --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.component.tsx @@ -0,0 +1,49 @@ +import React, { useContext } from 'react'; +import { RadioButton, RadioButtonGroup } from '@carbon/react'; +import styles from '../field.scss'; +import { useTranslation } from 'react-i18next'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { useField } from 'formik'; +import { type RegistrationConfig } from '../../../config-schema'; +import { useConfig } from '@openmrs/esm-framework'; + +export const GenderField: React.FC = () => { + const { fieldConfigurations } = useConfig() as RegistrationConfig; + const { t } = useTranslation(); + const [field, meta] = useField('gender'); + const { setFieldValue } = useContext(PatientRegistrationContext); + const fieldConfigs = fieldConfigurations?.gender; + + const setGender = (gender: string) => { + setFieldValue('gender', gender); + }; + /** + * DO NOT REMOVE THIS COMMENT HERE, ADDS TRANSLATION FOR SEX OPTIONS + * t('male', 'Male') + * t('female', 'Female') + * t('other', 'Other') + * t('unknown', 'Unknown') + */ + + return ( +
+

{t('sexFieldLabelText', 'Sex')}

+
+

{t('genderLabelText', 'Sex')}

+ + {fieldConfigs.map((option) => ( + + ))} + + {meta.touched && meta.error && ( +
{t(meta.error, 'Gender is required')}
+ )} +
+
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.test.tsx new file mode 100644 index 00000000..6b239330 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/gender/gender-field.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { Formik, Form } from 'formik'; +import { render } from '@testing-library/react'; +import { GenderField } from './gender-field.component'; + +jest.mock('@openmrs/esm-framework', () => ({ + ...(jest.requireActual('@openmrs/esm-framework') as any), + useConfig: jest.fn(() => ({ + fieldConfigurations: { + gender: [ + { + value: 'male', + label: 'Male', + }, + ], + name: { + displayMiddleName: false, + unidentifiedPatient: false, + defaultUnknownGivenName: '', + defaultUnknownFamilyName: '', + }, + }, + })), +})); + +jest.mock('react', () => ({ + ...(jest.requireActual('react') as any), + useContext: jest.fn(() => ({ + setFieldValue: jest.fn(), + })), +})); + +jest.mock('formik', () => ({ + ...(jest.requireActual('formik') as any), + useField: jest.fn(() => [{}, {}]), +})); + +describe('GenderField', () => { + const renderComponent = () => { + return render( + +
+ + +
, + ); + }; + + it('has a label', () => { + expect(renderComponent().getAllByText('Sex')).toBeTruthy(); + }); + + it('checks an option', async () => { + const user = userEvent.setup(); + const component = renderComponent(); + expect(component.getByLabelText('Male').getAttribute('value')).toBe('male'); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx new file mode 100644 index 00000000..b7b890a0 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx @@ -0,0 +1,144 @@ +import React, { useCallback, useContext, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, SkeletonText } from '@carbon/react'; +import { ArrowRight } from '@carbon/react/icons'; +import { useLayoutType, useConfig, isDesktop, UserHasAccess } from '@openmrs/esm-framework'; +import IdentifierSelectionOverlay from './identifier-selection-overlay.component'; +import { IdentifierInput } from '../../input/custom-input/identifier/identifier-input.component'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { + type FormValues, + type IdentifierSource, + type PatientIdentifierType, + type PatientIdentifierValue, +} from '../../patient-registration.types'; +import { ResourcesContext } from '../../../offline.resources'; +import styles from '../field.scss'; + +export function setIdentifierSource( + identifierSource: IdentifierSource, + identifierValue: string, + initialValue: string, +): { + identifierValue: string; + autoGeneration: boolean; + selectedSource: IdentifierSource; +} { + const autoGeneration = identifierSource?.autoGenerationOption?.automaticGenerationEnabled; + return { + selectedSource: identifierSource, + autoGeneration, + identifierValue: autoGeneration + ? 'auto-generated' + : identifierValue !== 'auto-generated' + ? identifierValue + : initialValue, + }; +} + +export function initializeIdentifier(identifierType: PatientIdentifierType, identifierProps): PatientIdentifierValue { + return { + identifierTypeUuid: identifierType.uuid, + identifierName: identifierType.name, + preferred: identifierType.isPrimary, + initialValue: '', + required: identifierType.isPrimary || identifierType.required, + ...identifierProps, + ...setIdentifierSource( + identifierProps?.selectedSource ?? identifierType.identifierSources?.[0], + identifierProps?.identifierValue, + identifierProps?.initialValue ?? '', + ), + }; +} + +export function deleteIdentifierType(identifiers: FormValues['identifiers'], identifierFieldName) { + return Object.fromEntries(Object.entries(identifiers).filter(([fieldName]) => fieldName !== identifierFieldName)); +} + +export const Identifiers: React.FC = () => { + const { identifierTypes } = useContext(ResourcesContext); + const isLoading = !identifierTypes; + const { values, setFieldValue, initialFormValues, isOffline } = useContext(PatientRegistrationContext); + const { t } = useTranslation(); + const layout = useLayoutType(); + const [showIdentifierOverlay, setShowIdentifierOverlay] = useState(false); + const config = useConfig(); + const { defaultPatientIdentifierTypes } = config; + + useEffect(() => { + // Initialization + if (identifierTypes) { + const identifiers = {}; + identifierTypes + .filter( + (type) => + type.isPrimary || + type.required || + !!defaultPatientIdentifierTypes?.find( + (defaultIdentifierTypeUuid) => defaultIdentifierTypeUuid === type.uuid, + ), + ) + .filter((type) => !values.identifiers[type.fieldName]) + .forEach((type) => { + identifiers[type.fieldName] = initializeIdentifier( + type, + values.identifiers[type.uuid] ?? initialFormValues.identifiers[type.uuid] ?? {}, + ); + }); + /* + Identifier value should only be updated if there is any update in the + identifier values, otherwise, if the below 'if' clause is removed, it will + fall into an infinite run. + */ + if (Object.keys(identifiers).length) { + setFieldValue('identifiers', { + ...values.identifiers, + ...identifiers, + }); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [identifierTypes, setFieldValue, defaultPatientIdentifierTypes, values.identifiers, initializeIdentifier]); + + const closeIdentifierSelectionOverlay = useCallback( + () => setShowIdentifierOverlay(false), + [setShowIdentifierOverlay], + ); + + if (isLoading && !isOffline) { + return ( +
+
+

{t('idFieldLabelText', 'Identifiers')}

+
+ +
+ ); + } + + return ( +
+ +
+

{t('idFieldLabelText', 'Identifiers')}

+ +
+
+
+ {Object.entries(values.identifiers).map(([fieldName, identifier]) => ( + + ))} + {showIdentifierOverlay && ( + + )} +
+
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx new file mode 100644 index 00000000..b1b0e674 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { Identifiers } from './id-field.component'; +import { type Resources, ResourcesContext } from '../../../offline.resources'; +import { Form, Formik } from 'formik'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { openmrsID, mockedIdentifierTypes } from '__mocks__'; + +jest.mock('@openmrs/esm-framework', () => ({ + ...jest.requireActual('@openmrs/esm-framework'), + useConfig: jest.fn().mockImplementation(() => ({ + defaultPatientIdentifierTypes: ['OpenMRS ID'], + })), +})); + +describe('Identifiers', () => { + const mockResourcesContextValue = { + addressTemplate: {}, + currentSession: { + authenticated: true, + sessionId: 'JSESSION', + currentProvider: { uuid: 'provider-uuid', identifier: 'PRO-123' }, + }, + relationshipTypes: [], + identifierTypes: [...mockedIdentifierTypes], + } as Resources; + + it('should render loading skeleton when identifier types are loading', () => { + render( + + +
+ + + +
+
+
, + ); + expect(screen.getByTestId('loading-skeleton')).toBeInTheDocument(); + }); + + it('should render identifier inputs when identifier types are loaded', () => { + render( + + +
+ + + +
+
+
, + ); + + expect(screen.getByText('Identifiers')).toBeInTheDocument(); + const configureButton = screen.getByRole('button', { name: 'Configure' }); + expect(configureButton).toBeInTheDocument(); + expect(configureButton).toBeEnabled(); + }); + + it('should open identifier selection overlay when "Configure" button is clicked', async () => { + const user = userEvent.setup(); + + render( + + +
+ + + +
+
+
, + ); + + const configureButton = screen.getByRole('button', { name: 'Configure' }); + await user.click(configureButton); + + expect(screen.getByRole('button', { name: 'Close overlay' })).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection-overlay.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection-overlay.component.tsx new file mode 100644 index 00000000..b181c511 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection-overlay.component.tsx @@ -0,0 +1,198 @@ +import React, { useMemo, useCallback, useEffect, useState, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ButtonSet, Checkbox, Search, RadioButtonGroup, RadioButton } from '@carbon/react'; +import { isDesktop, useConfig, useLayoutType } from '@openmrs/esm-framework'; +import { type FormValues, type PatientIdentifierType, PatientIdentifierValue } from '../../patient-registration.types'; +import Overlay from '../../ui-components/overlay/overlay.component'; +import { ResourcesContext } from '../../../offline.resources'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import { + isUniqueIdentifierTypeForOffline, + shouldBlockPatientIdentifierInOfflineMode, +} from '../../input/custom-input/identifier/utils'; +import { initializeIdentifier, setIdentifierSource } from './id-field.component'; +import styles from './identifier-selection.scss'; + +interface PatientIdentifierOverlayProps { + setFieldValue: (string, PatientIdentifierValue) => void; + closeOverlay: () => void; +} + +const PatientIdentifierOverlay: React.FC = ({ closeOverlay, setFieldValue }) => { + const layout = useLayoutType(); + const { identifierTypes } = useContext(ResourcesContext); + const { isOffline, values, initialFormValues } = useContext(PatientRegistrationContext); + const [unsavedIdentifierTypes, setUnsavedIdentifierTypes] = useState(values.identifiers); + const [searchString, setSearchString] = useState(''); + const { t } = useTranslation(); + const { defaultPatientIdentifierTypes } = useConfig(); + const defaultPatientIdentifierTypesMap = useMemo(() => { + const map = {}; + defaultPatientIdentifierTypes?.forEach((typeUuid) => { + map[typeUuid] = true; + }); + return map; + }, [defaultPatientIdentifierTypes]); + + useEffect(() => { + setUnsavedIdentifierTypes(values.identifiers); + }, [values.identifiers]); + + const handleSearch = useCallback((event) => setSearchString(event?.target?.value ?? ''), []); + + const filteredIdentifiers = useMemo( + () => identifierTypes?.filter((identifier) => identifier?.name?.toLowerCase().includes(searchString.toLowerCase())), + [searchString, identifierTypes], + ); + + const handleCheckingIdentifier = useCallback( + (identifierType: PatientIdentifierType, checked: boolean) => + setUnsavedIdentifierTypes((unsavedIdentifierTypes) => { + if (checked) { + return { + ...unsavedIdentifierTypes, + [identifierType.fieldName]: initializeIdentifier( + identifierType, + values.identifiers[identifierType.fieldName] ?? + initialFormValues.identifiers[identifierType.fieldName] ?? + {}, + ), + }; + } + if (unsavedIdentifierTypes[identifierType.fieldName]) { + return Object.fromEntries( + Object.entries(unsavedIdentifierTypes).filter(([fieldName]) => fieldName !== identifierType.fieldName), + ); + } + return unsavedIdentifierTypes; + }), + [initialFormValues.identifiers, values.identifiers], + ); + + const handleSelectingIdentifierSource = (identifierType: PatientIdentifierType, sourceUuid) => + setUnsavedIdentifierTypes((unsavedIdentifierTypes) => ({ + ...unsavedIdentifierTypes, + [identifierType.fieldName]: { + ...unsavedIdentifierTypes[identifierType.fieldName], + ...setIdentifierSource( + identifierType.identifierSources.find((source) => source.uuid === sourceUuid), + unsavedIdentifierTypes[identifierType.fieldName].identifierValue, + unsavedIdentifierTypes[identifierType.fieldName].initialValue, + ), + }, + })); + + const identifierTypeFields = useMemo( + () => + filteredIdentifiers.map((identifierType) => { + const patientIdentifier = unsavedIdentifierTypes[identifierType.fieldName]; + const isDisabled = + identifierType.isPrimary || + identifierType.required || + defaultPatientIdentifierTypesMap[identifierType.uuid] || + // De-selecting shouldn't be allowed if the identifier was selected earlier and is present in the form. + // If the user wants to de-select an identifier-type already present in the form, they'll need to delete the particular identifier from the form itself. + values.identifiers[identifierType.fieldName]; + const isDisabledOffline = isOffline && shouldBlockPatientIdentifierInOfflineMode(identifierType); + + return ( +
+ handleCheckingIdentifier(identifierType, checked)} + checked={!!patientIdentifier} + disabled={isDisabled || (isOffline && isDisabledOffline)} + /> + {patientIdentifier && + identifierType?.identifierSources?.length > 0 && + /* + This check are for the cases when there's an initialValue identifier is assigned + to the patient + The corresponding flow is like: + 1. If there's no change to the actual initial identifier, then the source remains null, + hence the list of the identifier sources shouldn't be displayed. + 2. If user wants to edit the patient identifier's value, hence there will be an initialValue, + along with a source assigned to itself(only if the identifierType has sources, else there's nothing to worry about), which by + default is the first identifierSource + */ + (!patientIdentifier.initialValue || patientIdentifier?.selectedSource) && ( +
+ handleSelectingIdentifierSource(identifierType, sourceUuid)} + orientation="vertical"> + {identifierType?.identifierSources.map((source) => ( + + ))} + +
+ )} +
+ ); + }), + [ + filteredIdentifiers, + unsavedIdentifierTypes, + defaultPatientIdentifierTypesMap, + values.identifiers, + isOffline, + handleCheckingIdentifier, + t, + ], + ); + + const handleConfiguringIdentifiers = useCallback(() => { + setFieldValue('identifiers', unsavedIdentifierTypes); + closeOverlay(); + }, [unsavedIdentifierTypes, setFieldValue, closeOverlay]); + + return ( + + + + + }> +
+

+ {t('IDInstructions', "Select the identifiers you'd like to add for this patient:")} +

+ {identifierTypes.length > 7 && ( +
+ +
+ )} +
{identifierTypeFields}
+
+
+ ); +}; + +export default PatientIdentifierOverlay; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection.scss b/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection.scss new file mode 100644 index 00000000..6ed85973 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/identifier-selection.scss @@ -0,0 +1,37 @@ +@use '@carbon/styles/scss/spacing'; +@import '../../patient-registration.scss'; + +.button { + height: 4rem; + display: flex; + align-content: flex-start; + align-items: baseline; + min-width: 50%; +} + +.tablet { + padding: 1.5rem 1rem; + background-color: $ui-02; +} + +.desktop { + padding: 0rem; +} + +.radioGroup { + background-color: $ui-01; + padding: spacing.$spacing-05; +} + +.radioButton { + margin: 0 !important; + label { + height: spacing.$spacing-07; + } +} + +:global(.omrs-breakpoint-lt-desktop) { + .radioButton label { + height: spacing.$spacing-09 !important; + } +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/name/name-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/name/name-field.component.tsx new file mode 100644 index 00000000..d2a9ea0f --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/name/name-field.component.tsx @@ -0,0 +1,142 @@ +import React, { useCallback, useContext } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ContentSwitcher, Switch } from '@carbon/react'; +import { useField } from 'formik'; +import { ExtensionSlot, useConfig } from '@openmrs/esm-framework'; +import { Input } from '../../input/basic-input/input/input.component'; +import { PatientRegistrationContext } from '../../patient-registration-context'; +import styles from '../field.scss'; +import { type RegistrationConfig } from '../../../config-schema'; + +export const unidentifiedPatientAttributeTypeUuid = '8b56eac7-5c76-4b9c-8c6f-1deab8d3fc47'; +const containsNoNumbers = /^([^0-9]*)$/; + +function checkNumber(value: string) { + if (!containsNoNumbers.test(value)) { + return 'numberInNameDubious'; + } + + return undefined; +} + +export const NameField = () => { + const { t } = useTranslation(); + const { setCapturePhotoProps, currentPhoto, setFieldValue } = useContext(PatientRegistrationContext); + const { + fieldConfigurations: { + name: { + displayCapturePhoto, + allowUnidentifiedPatients, + defaultUnknownGivenName, + defaultUnknownFamilyName, + displayMiddleName, + displayReverseFieldOrder, + }, + }, + } = useConfig(); + + const [{ value: isPatientUnknownValue }, , { setValue: setUnknownPatient }] = useField( + `attributes.${unidentifiedPatientAttributeTypeUuid}`, + ); + + const isPatientUnknown = isPatientUnknownValue === 'true'; + + const onCapturePhoto = useCallback( + (dataUri: string, photoDateTime: string) => { + if (setCapturePhotoProps) { + setCapturePhotoProps({ + imageData: dataUri, + dateTime: photoDateTime, + }); + } + }, + [setCapturePhotoProps], + ); + + const toggleNameKnown = (e) => { + if (e.name === 'known') { + setFieldValue('givenName', ''); + setFieldValue('familyName', ''); + setUnknownPatient('false'); + } else { + setFieldValue('givenName', defaultUnknownGivenName); + setFieldValue('familyName', defaultUnknownFamilyName); + setUnknownPatient('true'); + } + }; + + const firstNameField = ( + + ); + + const middleNameField = displayMiddleName && ( + + ); + + const familyNameField = ( + + ); + + return ( +
+

{t('fullNameLabelText', 'Full Name')}

+
+ {displayCapturePhoto && ( + + )} + +
+ {(allowUnidentifiedPatients || isPatientUnknown) && ( + <> +
+ {t('patientNameKnown', "Patient's Name is Known?")} +
+ + + + + + )} + {!isPatientUnknown && + (!displayReverseFieldOrder ? ( + <> + {firstNameField} + {middleNameField} + {familyNameField} + + ) : ( + <> + {familyNameField} + {middleNameField} + {firstNameField} + + ))} +
+
+
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx new file mode 100644 index 00000000..0ba125d5 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx @@ -0,0 +1,204 @@ +import React, { useMemo } from 'react'; +import classNames from 'classnames'; +import { Field } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { InlineNotification, Layer, Select, SelectItem } from '@carbon/react'; +import { useConfig } from '@openmrs/esm-framework'; +import { type ConceptResponse } from '../../patient-registration.types'; +import { type FieldDefinition, type RegistrationConfig } from '../../../config-schema'; +import { Input } from '../../input/basic-input/input/input.component'; +import { useConcept, useConceptAnswers } from '../field.resource'; +import styles from './../field.scss'; + +export interface ObsFieldProps { + fieldDefinition: FieldDefinition; +} + +export function ObsField({ fieldDefinition }: ObsFieldProps) { + const { t } = useTranslation(); + const { data: concept, isLoading } = useConcept(fieldDefinition.uuid); + + const config = useConfig(); + + if (!config.registrationObs.encounterTypeUuid) { + console.error( + 'The registration form has been configured to have obs fields, ' + + 'but no registration encounter type has been configured. Obs fields ' + + 'will not be displayed.', + ); + return null; + } + + if (isLoading) { + return null; + } + + switch (concept.datatype.display) { + case 'Text': + return ( + + ); + case 'Numeric': + return ( + + ); + case 'Coded': + return ( + + ); + default: + return ( + + {t( + 'obsFieldUnknownDatatype', + `Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'`, + { fieldDefinitionId: fieldDefinition.id, datatypeName: concept.datatype.display }, + )} + + ); + } +} + +interface TextObsFieldProps { + concept: ConceptResponse; + validationRegex: string; + label: string; + required?: boolean; +} + +function TextObsField({ concept, validationRegex, label, required }: TextObsFieldProps) { + const { t } = useTranslation(); + + const validateInput = (value: string) => { + if (!value || !validationRegex || validationRegex === '' || typeof validationRegex !== 'string' || value === '') { + return; + } + const regex = new RegExp(validationRegex); + if (regex.test(value)) { + return; + } else { + return t('invalidInput', 'Invalid Input'); + } + }; + + const fieldName = `obs.${concept.uuid}`; + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +} + +interface NumericObsFieldProps { + concept: ConceptResponse; + label: string; + required?: boolean; +} + +function NumericObsField({ concept, label, required }: NumericObsFieldProps) { + const fieldName = `obs.${concept.uuid}`; + + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +} + +interface CodedObsFieldProps { + concept: ConceptResponse; + answerConceptSetUuid?: string; + label?: string; + required?: boolean; + customConceptAnswers: Array<{ uuid: string; label?: string }>; +} + +function CodedObsField({ concept, answerConceptSetUuid, label, required, customConceptAnswers }: CodedObsFieldProps) { + const { t } = useTranslation(); + const fieldName = `obs.${concept.uuid}`; + + const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers( + customConceptAnswers.length ? '' : answerConceptSetUuid ?? concept.uuid, + ); + + const answers = useMemo( + () => + customConceptAnswers.length + ? customConceptAnswers + : isLoadingConceptAnswers + ? [] + : conceptAnswers.map((answer) => ({ ...answer, label: answer.display })), + [customConceptAnswers, conceptAnswers, isLoadingConceptAnswers], + ); + + return ( +
+ {!isLoadingConceptAnswers ? ( + + {({ field, form: { touched, errors }, meta }) => { + return ( + + + + ); + }} + + ) : null} +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx new file mode 100644 index 00000000..661a35e6 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx @@ -0,0 +1,205 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useConfig } from '@openmrs/esm-framework'; +import { type FieldDefinition } from '../../../config-schema'; +import { useConcept, useConceptAnswers } from '../field.resource'; +import { ObsField } from './obs-field.component'; + +const mockUseConfig = useConfig as jest.Mock; + +jest.mock('../field.resource'); // Mock the useConceptAnswers hook + +const mockedUseConcept = useConcept as jest.Mock; +const mockedUseConceptAnswers = useConceptAnswers as jest.Mock; + +const useConceptMockImpl = (uuid: string) => { + let data; + if (uuid == 'weight-uuid') { + data = { + uuid: 'weight-uuid', + display: 'Weight (kg)', + datatype: { display: 'Numeric', uuid: 'num' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'chief-complaint-uuid') { + data = { + uuid: 'chief-complaint-uuid', + display: 'Chief Complaint', + datatype: { display: 'Text', uuid: 'txt' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'nationality-uuid') { + data = { + uuid: 'nationality-uuid', + display: 'Nationality', + datatype: { display: 'Coded', uuid: 'cdd' }, + answers: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + setMembers: [], + }; + } else { + throw Error(`Programming error, you probably didn't mean to do this: unknown concept uuid '${uuid}'`); + } + return { + data: data ?? null, + isLoading: !data, + }; +}; + +const useConceptAnswersMockImpl = (uuid: string) => { + if (uuid == 'nationality-uuid') { + return { + data: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + isLoading: false, + }; + } else if (uuid == 'other-countries-uuid') { + return { + data: [ + { display: 'Kenya', uuid: 'ke' }, + { display: 'Uganda', uuid: 'ug' }, + ], + isLoading: false, + }; + } else if (uuid == '') { + return { + data: [], + isLoading: false, + }; + } else { + throw Error(`Programming error, you probably didn't mean to do this: unknown concept answer set uuid '${uuid}'`); + } +}; + +type FieldProps = { + children: ({ field, form: { touched, errors } }) => React.ReactNode; +}; + +jest.mock('formik', () => ({ + ...(jest.requireActual('formik') as object), + Field: jest.fn(({ children }: FieldProps) => <>{children({ field: {}, form: { touched: {}, errors: {} } })}), + useField: jest.fn(() => [{ value: null }, {}]), +})); + +const textFieldDef: FieldDefinition = { + id: 'chief-complaint', + type: 'obs', + label: '', + placeholder: '', + showHeading: false, + uuid: 'chief-complaint-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, + customConceptAnswers: [], +}; + +const numberFieldDef: FieldDefinition = { + id: 'weight', + type: 'obs', + label: '', + placeholder: '', + showHeading: false, + uuid: 'weight-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, + customConceptAnswers: [], +}; + +const codedFieldDef: FieldDefinition = { + id: 'nationality', + type: 'obs', + label: '', + placeholder: '', + showHeading: false, + uuid: 'nationality-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, + customConceptAnswers: [], +}; + +describe('ObsField', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ registrationObs: { encounterTypeUuid: 'reg-enc-uuid' } }); + mockedUseConcept.mockImplementation(useConceptMockImpl); + mockedUseConceptAnswers.mockImplementation(useConceptAnswersMockImpl); + }); + + it("logs an error and doesn't render if no registration encounter type is provided", () => { + mockUseConfig.mockReturnValue({ registrationObs: { encounterTypeUuid: null } }); + console.error = jest.fn(); + render(); + expect(console.error).toHaveBeenCalledWith( + expect.stringMatching(/no registration encounter type has been configured/i), + ); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('renders a text box for text concept', () => { + render(); + // I don't know why the labels aren't in the DOM, but they aren't + // expect(screen.getByLabelText("Chief Complaint")).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders a number box for number concept', () => { + render(); + // expect(screen.getByLabelText("Weight (kg)")).toBeInTheDocument(); + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + }); + + it('renders a select for a coded concept', () => { + render(); + // expect(screen.getByLabelText("Nationality")).toBeInTheDocument(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + expect(select).toHaveDisplayValue('Select an option'); + }); + + it('select uses answerConcept for answers when it is provided', async () => { + const user = userEvent.setup(); + + render(); + // expect(screen.getByLabelText("Nationality")).toBeInTheDocument(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + await user.selectOptions(select, 'Kenya'); + }); + + it('select uses customConceptAnswers for answers when provided', async () => { + const user = userEvent.setup(); + + render( + , + ); + // expect(screen.getByLabelText("Nationality")).toBeInTheDocument(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + await user.selectOptions(select, 'Mozambique'); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-attributes.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-attributes.component.tsx new file mode 100644 index 00000000..c66025de --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-attributes.component.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Layer, Select, SelectItem } from '@carbon/react'; +import { useConfig } from '@openmrs/esm-framework'; +import { Input } from '../../input/basic-input/input/input.component'; +import { type CodedPersonAttributeConfig } from '../../patient-registration.types'; +import { useConceptAnswers } from '../field.resource'; +import { usePersonAttributeType } from './person-attributes.resource'; +import styles from './../field.scss'; + +export interface CodedAttributesFieldProps {} + +export const CodedAttributesField: React.FC = () => { + const { codedPersonAttributes } = useConfig(); + + return codedPersonAttributes?.length ? ( +
+ {codedPersonAttributes.map((personAttributeType: CodedPersonAttributeConfig, ind) => ( + + ))} +
+ ) : null; +}; + +interface PersonAttributeFieldProps { + personAttributeTypeUuid: string; + conceptUuid: string; +} + +const PersonAttributeField: React.FC = ({ personAttributeTypeUuid, conceptUuid }) => { + const { data: personAttributeType, isLoading } = usePersonAttributeType(personAttributeTypeUuid); + const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers(conceptUuid); + + return !isLoading ? ( +
+ {!isLoadingConceptAnswers && conceptAnswers?.length ? ( + + + + ) : ( + + )} +
+ ) : null; +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.component.tsx new file mode 100644 index 00000000..60e13632 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.component.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useTranslation } from 'react-i18next'; +import { Field } from 'formik'; +import { Layer, Select, SelectItem } from '@carbon/react'; +import { type PersonAttributeTypeResponse } from '../../patient-registration.types'; +import { useConceptAnswers } from '../field.resource'; +import styles from './../field.scss'; +import { reportError } from '@openmrs/esm-framework'; + +export interface CodedPersonAttributeFieldProps { + id: string; + personAttributeType: PersonAttributeTypeResponse; + answerConceptSetUuid: string; + label?: string; + customConceptAnswers: Array<{ uuid: string; label?: string }>; +} + +export function CodedPersonAttributeField({ + id, + personAttributeType, + answerConceptSetUuid, + label, + customConceptAnswers, +}: CodedPersonAttributeFieldProps) { + const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers( + customConceptAnswers.length ? '' : answerConceptSetUuid, + ); + const { t } = useTranslation(); + const fieldName = `attributes.${personAttributeType.uuid}`; + const [error, setError] = useState(false); + + useEffect(() => { + if (!answerConceptSetUuid && !customConceptAnswers.length) { + reportError( + t( + 'codedPersonAttributeNoAnswerSet', + `The person attribute field '{{codedPersonAttributeFieldId}}' is of type 'coded' but has been defined without an answer concept set UUID. The 'answerConceptSetUuid' key is required.`, + { codedPersonAttributeFieldId: id }, + ), + ); + setError(true); + } + }, [answerConceptSetUuid, customConceptAnswers]); + + useEffect(() => { + if (!isLoadingConceptAnswers && !customConceptAnswers.length) { + if (!conceptAnswers) { + reportError( + t( + 'codedPersonAttributeAnswerSetInvalid', + `The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.`, + { codedPersonAttributeFieldId: id, answerConceptSetUuid }, + ), + ); + setError(true); + } + if (conceptAnswers?.length == 0) { + reportError( + t( + 'codedPersonAttributeAnswerSetEmpty', + `The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.`, + { + codedPersonAttributeFieldId: id, + answerConceptSetUuid, + }, + ), + ); + setError(true); + } + } + }, [isLoadingConceptAnswers, conceptAnswers, customConceptAnswers]); + + const answers = useMemo(() => { + if (customConceptAnswers.length) { + return customConceptAnswers; + } + return isLoadingConceptAnswers || !conceptAnswers + ? [] + : conceptAnswers + .map((answer) => ({ ...answer, label: answer.display })) + .sort((a, b) => a.label.localeCompare(b.label)); + }, [customConceptAnswers, conceptAnswers, isLoadingConceptAnswers]); + + if (error) { + return null; + } + + return ( +
+ {!isLoadingConceptAnswers ? ( + + + {({ field, form: { touched, errors }, meta }) => { + return ( + <> + + + ); + }} + + + ) : null} +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.test.tsx new file mode 100644 index 00000000..389d0026 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/coded-person-attribute-field.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { useConceptAnswers } from '../field.resource'; +import { CodedPersonAttributeField } from './coded-person-attribute-field.component'; +import { Form, Formik } from 'formik'; + +jest.mock('formik', () => ({ + ...jest.requireActual('formik'), +})); + +jest.mock('../field.resource'); // Mock the useConceptAnswers hook + +const mockedUseConceptAnswers = useConceptAnswers as jest.Mock; + +describe('CodedPersonAttributeField', () => { + const conceptAnswers = [ + { uuid: '1', display: 'Option 1' }, + { uuid: '2', display: 'Option 2' }, + ]; + const personAttributeType = { + format: 'org.openmrs.Concept', + display: 'Referred by', + uuid: '4dd56a75-14ab-4148-8700-1f4f704dc5b0', + name: '', + description: '', + }; + const answerConceptSetUuid = '6682d17f-0777-45e4-a39b-93f77eb3531c'; + + beforeEach(() => { + jest.clearAllMocks(); + mockedUseConceptAnswers.mockReturnValue({ + data: conceptAnswers, + isLoading: false, + }); + }); + + it('shows error if there is no concept answer set provided', () => { + expect(() => { + render( + {}}> +
+ + +
, + ); + }).toThrow(expect.stringMatching(/has been defined without an answer concept set UUID/i)); + }); + + it('shows error if the concept answer set does not have any concept answers', () => { + mockedUseConceptAnswers.mockReturnValue({ + data: [], + isLoading: false, + }); + expect(() => { + render( + {}}> +
+ + +
, + ); + }).toThrow(expect.stringMatching(/does not have any concept answers/i)); + }); + + it('renders the conceptAnswers as select options', () => { + render( + {}}> +
+ + +
, + ); + + expect(screen.getByLabelText(/Referred by/i)).toBeInTheDocument(); + expect(screen.getByText(/Option 1/i)).toBeInTheDocument(); + expect(screen.getByText(/Option 2/i)).toBeInTheDocument(); + }); + + it('renders customConceptAnswers as select options when they are provided', () => { + render( + {}}> +
+ + +
, + ); + + expect(screen.getByLabelText(/Referred by/i)).toBeInTheDocument(); + expect(screen.getByText(/Special Option A/i)).toBeInTheDocument(); + expect(screen.getByText(/Special Option B/i)).toBeInTheDocument(); + expect(screen.queryByText(/Option 1/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/Option 2/i)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.component.tsx new file mode 100644 index 00000000..58350bf9 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.component.tsx @@ -0,0 +1,88 @@ +import React, { useMemo } from 'react'; +import { InlineNotification, TextInputSkeleton, SkeletonText } from '@carbon/react'; +import { type FieldDefinition } from '../../../config-schema'; +import { CodedPersonAttributeField } from './coded-person-attribute-field.component'; +import { usePersonAttributeType } from './person-attributes.resource'; +import { TextPersonAttributeField } from './text-person-attribute-field.component'; +import { useTranslation } from 'react-i18next'; +import styles from '../field.scss'; + +export interface PersonAttributeFieldProps { + fieldDefinition: FieldDefinition; +} + +export function PersonAttributeField({ fieldDefinition }: PersonAttributeFieldProps) { + const { data: personAttributeType, isLoading, error } = usePersonAttributeType(fieldDefinition.uuid); + const { t } = useTranslation(); + + const personAttributeField = useMemo(() => { + if (!personAttributeType) { + return null; + } + switch (personAttributeType.format) { + case 'java.lang.String': + return ( + + ); + case 'org.openmrs.Concept': + return ( + + ); + default: + return ( + + {t( + 'unknownPatientAttributeType', + 'Patient attribute type has unknown format {{personAttributeTypeFormat}}', + { + personAttributeTypeFormat: personAttributeType.format, + }, + )} + + ); + } + }, [personAttributeType, fieldDefinition, t]); + + if (isLoading) { + return ( +
+ {fieldDefinition.showHeading &&

{fieldDefinition?.label}

} + +
+ ); + } + + if (error) { + return ( +
+ {fieldDefinition.showHeading &&

{fieldDefinition?.label}

} + + {t('unableToFetch', 'Unable to fetch person attribute type - {{personattributetype}}', { + personattributetype: fieldDefinition?.label ?? fieldDefinition?.id, + })} + +
+ ); + } + + return ( +
+ {fieldDefinition.showHeading && ( +

{fieldDefinition?.label ?? personAttributeType?.display}

+ )} + {personAttributeField} +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.test.tsx new file mode 100644 index 00000000..14cd16fa --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attribute-field.test.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { usePersonAttributeType } from './person-attributes.resource'; +import { PersonAttributeField } from './person-attribute-field.component'; +import { useConceptAnswers } from '../field.resource'; +import { Form, Formik } from 'formik'; +import { type FieldDefinition } from '../../../config-schema'; + +jest.mock('./person-attributes.resource'); // Mock the usePersonAttributeType hook +jest.mock('../field.resource'); // Mock the useConceptAnswers hook + +jest.mock('formik', () => ({ + ...jest.requireActual('formik'), +})); + +const mockedUsePersonAttributeType = usePersonAttributeType as jest.Mock; +const mockedUseConceptAnswers = useConceptAnswers as jest.Mock; + +let fieldDefinition: FieldDefinition; + +describe('PersonAttributeField', () => { + let mockPersonAttributeType = { + format: 'java.lang.String', + display: 'Referred by', + uuid: '4dd56a75-14ab-4148-8700-1f4f704dc5b0', + }; + + beforeEach(() => { + fieldDefinition = { + id: 'referredby', + name: 'Referred by', + type: 'person attribute', + uuid: '4dd56a75-14ab-4148-8700-1f4f704dc5b0', + answerConceptSetUuid: '6682d17f-0777-45e4-a39b-93f77eb3531c', + validation: { + matches: '', + required: true, + }, + showHeading: true, + }; + mockedUsePersonAttributeType.mockReturnValue({ + data: mockPersonAttributeType, + isLoading: false, + error: null, + uuid: '14d4f066-15f5-102d-96e4-000c29c2a5d7d', + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders the text input field for String format', () => { + render( + {}}> +
+ + +
, + ); + + const input = screen.getByLabelText(/Referred by/i) as HTMLInputElement; + expect(screen.getByRole('heading')).toBeInTheDocument(); + expect(input).toBeInTheDocument(); + expect(input.type).toBe('text'); + }); + + it('should not show heading if showHeading is false', () => { + fieldDefinition = { + ...fieldDefinition, + showHeading: false, + }; + + render( + {}}> +
+ + +
, + ); + expect(screen.queryByRole('heading')).not.toBeInTheDocument(); + }); + + it('renders the coded attribute field for Concept format', () => { + mockedUsePersonAttributeType.mockReturnValue({ + data: { ...mockPersonAttributeType, format: 'org.openmrs.Concept' }, + isLoading: false, + error: null, + }); + + fieldDefinition = { + id: 'referredby', + ...fieldDefinition, + label: 'Referred by', + }; + + mockedUseConceptAnswers.mockReturnValueOnce({ + data: [ + { uuid: '1', display: 'Option 1' }, + { uuid: '2', display: 'Option 2' }, + ], + isLoading: false, + }); + + render( + {}}> +
+ + +
, + ); + + const input = screen.getByLabelText(/Referred by/i) as HTMLInputElement; + expect(input).toBeInTheDocument(); + expect(input.type).toBe('select-one'); + expect(screen.getByText('Option 1')).toBeInTheDocument(); + expect(screen.getByText('Option 2')).toBeInTheDocument(); + }); + + it('renders an error notification if attribute type has unknown format', () => { + mockedUsePersonAttributeType.mockReturnValue({ + data: { ...mockPersonAttributeType, format: 'unknown' }, + isLoading: false, + error: null, + }); + + render( + {}}> +
+ + +
, + ); + + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText(/Patient attribute type has unknown format/i)).toBeInTheDocument(); + }); + it('renders an error notification if unable to fetch attribute type', () => { + mockedUsePersonAttributeType.mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to fetch attribute type'), + }); + + fieldDefinition = { + uuid: 'attribute-uuid', + label: 'Attribute', + showHeading: false, + }; + + render( + {}}> +
+ + +
, + ); + + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText(/Unable to fetch person attribute type/i)).toBeInTheDocument(); + }); + + it('renders a skeleton if attribute type is loading', () => { + mockedUsePersonAttributeType.mockReturnValue({ + data: null, + isLoading: true, + error: null, + }); + + fieldDefinition = { + uuid: 'attribute-uuid', + label: 'Attribute', + showHeading: true, + }; + + render( + {}}> +
+ + +
, + ); + const input = screen.findByLabelText(/Reffered by/i); + expect(screen.getByText(/Attribute/i)).toBeInTheDocument(); + expect(input).not.toBeNull(); // checks that the input is not rendered when the attribute type is loading + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attributes.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attributes.resource.ts new file mode 100644 index 00000000..fb9f180f --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/person-attributes.resource.ts @@ -0,0 +1,20 @@ +import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; +import { type PersonAttributeTypeResponse } from '../../patient-registration.types'; + +export function usePersonAttributeType(personAttributeTypeUuid: string): { + data: PersonAttributeTypeResponse; + isLoading: boolean; + error: any; +} { + const { data, error, isLoading } = useSWRImmutable>( + `/ws/rest/v1/personattributetype/${personAttributeTypeUuid}`, + openmrsFetch, + ); + + return { + data: data?.data, + isLoading, + error, + }; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.component.tsx new file mode 100644 index 00000000..16b9c6f8 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.component.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import classNames from 'classnames'; +import { Field } from 'formik'; +import { useTranslation } from 'react-i18next'; +import { Input } from '../../input/basic-input/input/input.component'; +import { type PersonAttributeTypeResponse } from '../../patient-registration.types'; +import styles from './../field.scss'; + +export interface TextPersonAttributeFieldProps { + id: string; + personAttributeType: PersonAttributeTypeResponse; + validationRegex?: string; + label?: string; + required?: boolean; +} + +export function TextPersonAttributeField({ + id, + personAttributeType, + validationRegex, + label, + required, +}: TextPersonAttributeFieldProps) { + const { t } = useTranslation(); + + const validateInput = (value: string) => { + if (!value || !validationRegex || validationRegex === '' || typeof validationRegex !== 'string' || value === '') { + return; + } + const regex = new RegExp(validationRegex); + if (regex.test(value)) { + return; + } else { + return t('invalidInput', 'Invalid Input'); + } + }; + + const fieldName = `attributes.${personAttributeType.uuid}`; + + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.test.tsx new file mode 100644 index 00000000..2f65f0a6 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/text-person-attribute-field.test.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Form, Formik } from 'formik'; +import { TextPersonAttributeField } from './text-person-attribute-field.component'; + +describe('TextPersonAttributeField', () => { + const mockPersonAttributeType = { + format: 'java.lang.String', + display: 'Referred by', + uuid: '4dd56a75-14ab-4148-8700-1f4f704dc5b0', + }; + + it('renders the input field with a label', () => { + render( + {}}> +
+ + +
, + ); + + expect(screen.getByRole('textbox', { name: /custom label \(optional\)/i })).toBeInTheDocument(); + }); + + it('renders the input field with the default label if label prop is not provided', () => { + render( + {}}> +
+ + +
, + ); + + expect(screen.getByRole('textbox', { name: /referred by \(optional\)/i })).toBeInTheDocument(); + }); + + it('validates the input with the provided validationRegex', async () => { + const user = userEvent.setup(); + const validationRegex = '^[A-Z]+$'; // Accepts only uppercase letters + + render( + {}}> +
+ + +
, + ); + + const textbox = screen.getByRole('textbox', { name: /referred by \(optional\)/i }); + expect(textbox).toBeInTheDocument(); + + // Valid input: "ABC" + await user.type(textbox, 'ABC'); + await user.tab(); + + expect(screen.queryByText(/invalid input/i)).not.toBeInTheDocument(); + await user.clear(textbox); + + // // Invalid input: "abc" (contains lowercase letters) + await user.type(textbox, 'abc'); + await user.tab(); + expect(screen.getByText(/invalid input/i)).toBeInTheDocument(); + }); + + it('renders the input field as required when required prop is true', () => { + render( + {}}> +
+ + +
, + ); + const textbox = screen.getByRole('textbox', { name: /referred by/i }); + + // Required attribute should be truthy on the input element + expect(textbox).toBeInTheDocument(); + expect(textbox).toBeRequired(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/phone/phone-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/phone/phone-field.component.tsx new file mode 100644 index 00000000..0aa99508 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/phone/phone-field.component.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { PersonAttributeField } from '../person-attributes/person-attribute-field.component'; +import { useConfig } from '@openmrs/esm-framework'; +import { type RegistrationConfig } from '../../../config-schema'; + +export function PhoneField() { + const config = useConfig(); + + const fieldDefinition = { + id: 'phone', + type: 'person attribute', + uuid: config.fieldConfigurations.phone.personAttributeUuid, + showHeading: false, + }; + return ; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/form-manager.test.ts b/packages/esm-patient-registration-app/src/patient-registration/form-manager.test.ts new file mode 100644 index 00000000..56b4e528 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/form-manager.test.ts @@ -0,0 +1,67 @@ +import { FormManager } from './form-manager'; +import { type FormValues } from './patient-registration.types'; + +jest.mock('./patient-registration.resource'); + +const formValues: FormValues = { + patientUuid: '', + givenName: '', + middleName: '', + familyName: '', + additionalGivenName: '', + additionalMiddleName: '', + additionalFamilyName: '', + addNameInLocalLanguage: false, + gender: '', + birthdate: '', + yearsEstimated: 1000, + monthsEstimated: 11, + birthdateEstimated: false, + telephoneNumber: '', + isDead: false, + deathDate: 'string', + deathCause: 'string', + relationships: [], + address: { + address1: '', + address2: '', + cityVillage: '', + stateProvince: 'New York', + country: 'string', + postalCode: 'string', + }, + identifiers: { + foo: { + identifierUuid: 'aUuid', + identifierName: 'Foo', + required: false, + initialValue: 'foo', + identifierValue: 'foo', + identifierTypeUuid: 'identifierType', + preferred: true, + autoGeneration: false, + selectedSource: { + uuid: 'some-uuid', + name: 'unique', + autoGenerationOption: { manualEntryEnabled: true, automaticGenerationEnabled: false }, + }, + }, + }, +}; + +describe('FormManager', () => { + describe('createIdentifiers', () => { + it('uses the uuid of a field name if it exists', async () => { + const result = await FormManager.savePatientIdentifiers(true, undefined, formValues.identifiers, {}, 'Nyc'); + expect(result).toEqual([ + { + uuid: 'aUuid', + identifier: 'foo', + identifierType: 'identifierType', + location: 'Nyc', + preferred: true, + }, + ]); + }); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/form-manager.ts b/packages/esm-patient-registration-app/src/patient-registration/form-manager.ts new file mode 100644 index 00000000..5810c927 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/form-manager.ts @@ -0,0 +1,414 @@ +import { type FetchResponse, openmrsFetch, queueSynchronizationItem, type Session } from '@openmrs/esm-framework'; +import { patientRegistration } from '../constants'; +import { + type FormValues, + type AttributeValue, + type PatientUuidMapType, + type Patient, + type CapturePhotoProps, + type PatientIdentifier, + type PatientRegistration, + type RelationshipValue, + type Encounter, +} from './patient-registration.types'; +import { + addPatientIdentifier, + deletePatientIdentifier, + deletePersonName, + deleteRelationship, + generateIdentifier, + savePatient, + savePatientPhoto, + saveRelationship, + updateRelationship, + updatePatientIdentifier, + saveEncounter, +} from './patient-registration.resource'; +import { type RegistrationConfig } from '../config-schema'; + +export type SavePatientForm = ( + isNewPatient: boolean, + values: FormValues, + patientUuidMap: PatientUuidMapType, + initialAddressFieldValues: Record, + capturePhotoProps: CapturePhotoProps, + currentLocation: string, + initialIdentifierValues: FormValues['identifiers'], + currentUser: Session, + config: RegistrationConfig, + savePatientTransactionManager: SavePatientTransactionManager, + abortController?: AbortController, +) => Promise; + +export class FormManager { + static savePatientFormOffline: SavePatientForm = async ( + isNewPatient, + values, + patientUuidMap, + initialAddressFieldValues, + capturePhotoProps, + currentLocation, + initialIdentifierValues, + currentUser, + config, + ) => { + const syncItem: PatientRegistration = { + fhirPatient: FormManager.mapPatientToFhirPatient( + FormManager.getPatientToCreate(isNewPatient, values, patientUuidMap, initialAddressFieldValues, []), + ), + _patientRegistrationData: { + isNewPatient, + formValues: values, + patientUuidMap, + initialAddressFieldValues, + capturePhotoProps, + currentLocation, + initialIdentifierValues, + currentUser, + config, + savePatientTransactionManager: new SavePatientTransactionManager(), + }, + }; + + await queueSynchronizationItem(patientRegistration, syncItem, { + id: values.patientUuid, + displayName: 'Patient registration', + patientUuid: syncItem.fhirPatient.id, + dependencies: [], + }); + + return null; + }; + + static savePatientFormOnline: SavePatientForm = async ( + isNewPatient, + values, + patientUuidMap, + initialAddressFieldValues, + capturePhotoProps, + currentLocation, + initialIdentifierValues, + currentUser, + config, + savePatientTransactionManager, + abortController, + ) => { + const patientIdentifiers: Array = await FormManager.savePatientIdentifiers( + isNewPatient, + values.patientUuid, + values.identifiers, + initialIdentifierValues, + currentLocation, + ); + + const createdPatient = FormManager.getPatientToCreate( + isNewPatient, + values, + patientUuidMap, + initialAddressFieldValues, + patientIdentifiers, + ); + + FormManager.getDeletedNames(values.patientUuid, patientUuidMap).forEach(async (name) => { + await deletePersonName(name.nameUuid, name.personUuid); + }); + + const savePatientResponse = await savePatient( + createdPatient, + isNewPatient && !savePatientTransactionManager.patientSaved ? undefined : values.patientUuid, + ); + + if (savePatientResponse.ok) { + savePatientTransactionManager.patientSaved = true; + await this.saveRelationships(values.relationships, savePatientResponse); + + await this.saveObservations(values.obs, savePatientResponse, currentLocation, currentUser, config); + + if (config.concepts.patientPhotoUuid && capturePhotoProps?.imageData) { + await savePatientPhoto( + savePatientResponse.data.uuid, + capturePhotoProps.imageData, + '/ws/rest/v1/obs', + capturePhotoProps.dateTime || new Date().toISOString(), + config.concepts.patientPhotoUuid, + ); + } + } + + return savePatientResponse.data.uuid; + }; + + static async saveRelationships(relationships: Array, savePatientResponse: FetchResponse) { + return Promise.all( + relationships + .filter((m) => m.relationshipType) + .filter((relationship) => !!relationship.action) + .map(({ relatedPersonUuid, relationshipType, uuid: relationshipUuid, action }) => { + const [type, direction] = relationshipType.split('/'); + const thisPatientUuid = savePatientResponse.data.uuid; + const isAToB = direction === 'aIsToB'; + const relationshipToSave = { + personA: isAToB ? relatedPersonUuid : thisPatientUuid, + personB: isAToB ? thisPatientUuid : relatedPersonUuid, + relationshipType: type, + }; + + switch (action) { + case 'ADD': + return saveRelationship(relationshipToSave); + case 'UPDATE': + return updateRelationship(relationshipUuid, relationshipToSave); + case 'DELETE': + return deleteRelationship(relationshipUuid); + } + }), + ); + } + + static async saveObservations( + obss: { [conceptUuid: string]: string }, + savePatientResponse: FetchResponse, + currentLocation: string, + currentUser: Session, + config: RegistrationConfig, + ) { + if (obss && Object.keys(obss).length > 0) { + if (!config.registrationObs.encounterTypeUuid) { + console.error( + 'The registration form has been configured to have obs fields, ' + + 'but no registration encounter type has been configured. Obs field values ' + + 'will not be saved.', + ); + } else { + const encounterToSave: Encounter = { + encounterDatetime: new Date(), + patient: savePatientResponse.data.uuid, + encounterType: config.registrationObs.encounterTypeUuid, + location: currentLocation, + encounterProviders: [ + { + provider: currentUser.currentProvider.uuid, + encounterRole: config.registrationObs.encounterProviderRoleUuid, + }, + ], + form: config.registrationObs.registrationFormUuid, + obs: Object.entries(obss) + .filter(([, value]) => value !== '') + .map(([conceptUuid, value]) => ({ concept: conceptUuid, value })), + }; + return saveEncounter(encounterToSave); + } + } + } + + static async savePatientIdentifiers( + isNewPatient: boolean, + patientUuid: string, + patientIdentifiers: FormValues['identifiers'], // values.identifiers + initialIdentifierValues: FormValues['identifiers'], // Initial identifiers assigned to the patient + location: string, + ): Promise> { + let identifierTypeRequests = Object.values(patientIdentifiers) + /* Since default identifier-types will be present on the form and are also in the not-required state, + therefore we might be running into situations when there's no value and no source associated, + hence filtering these fields out. + */ + .filter( + ({ identifierValue, autoGeneration, selectedSource }) => identifierValue || (autoGeneration && selectedSource), + ) + .map(async (patientIdentifier) => { + const { + identifierTypeUuid, + identifierValue, + identifierUuid, + selectedSource, + preferred, + autoGeneration, + initialValue, + } = patientIdentifier; + + const identifier = !autoGeneration + ? identifierValue + : await ( + await generateIdentifier(selectedSource.uuid) + ).data.identifier; + const identifierToCreate = { + uuid: identifierUuid, + identifier, + identifierType: identifierTypeUuid, + location, + preferred, + }; + + if (!isNewPatient) { + if (!initialValue) { + await addPatientIdentifier(patientUuid, identifierToCreate); + } else if (initialValue !== identifier) { + await updatePatientIdentifier(patientUuid, identifierUuid, identifierToCreate.identifier); + } + } + + return identifierToCreate; + }); + + /* + If there was initially an identifier assigned to the patient, + which is now not present in the patientIdentifiers(values.identifiers), + this means that the identifier is meant to be deleted, hence we need + to delete the respective identifiers. + */ + + if (patientUuid) { + Object.keys(initialIdentifierValues) + .filter((identifierFieldName) => !patientIdentifiers[identifierFieldName]) + .forEach(async (identifierFieldName) => { + await deletePatientIdentifier(patientUuid, initialIdentifierValues[identifierFieldName].identifierUuid); + }); + } + + return Promise.all(identifierTypeRequests); + } + + static getDeletedNames(patientUuid: string, patientUuidMap: PatientUuidMapType) { + if (patientUuidMap?.additionalNameUuid) { + return [ + { + nameUuid: patientUuidMap.additionalNameUuid, + personUuid: patientUuid, + }, + ]; + } + return []; + } + + static getPatientToCreate( + isNewPatient: boolean, + values: FormValues, + patientUuidMap: PatientUuidMapType, + initialAddressFieldValues: Record, + identifiers: Array, + ): Patient { + let birthdate; + if (values.birthdate instanceof Date) { + birthdate = [values.birthdate.getFullYear(), values.birthdate.getMonth() + 1, values.birthdate.getDate()].join( + '-', + ); + } else { + birthdate = values.birthdate; + } + + return { + uuid: values.patientUuid, + person: { + uuid: values.patientUuid, + names: FormManager.getNames(values, patientUuidMap), + gender: values.gender.charAt(0).toUpperCase(), + birthdate, + birthdateEstimated: values.birthdateEstimated, + attributes: FormManager.getPatientAttributes(isNewPatient, values, patientUuidMap), + addresses: [values.address], + ...FormManager.getPatientDeathInfo(values), + }, + identifiers, + }; + } + + static getNames(values: FormValues, patientUuidMap: PatientUuidMapType) { + const names = [ + { + uuid: patientUuidMap.preferredNameUuid, + preferred: true, + givenName: values.givenName, + middleName: values.middleName, + familyName: values.familyName, + }, + ]; + + if (values.addNameInLocalLanguage) { + names.push({ + uuid: patientUuidMap.additionalNameUuid, + preferred: false, + givenName: values.additionalGivenName, + middleName: values.additionalMiddleName, + familyName: values.additionalFamilyName, + }); + } + + return names; + } + + static getPatientAttributes(isNewPatient: boolean, values: FormValues, patientUuidMap: PatientUuidMapType) { + const attributes: Array = []; + if (values.attributes) { + Object.entries(values.attributes) + .filter(([, value]) => !!value) + .forEach(([key, value]) => { + attributes.push({ + attributeType: key, + value, + }); + }); + + if (!isNewPatient && values.patientUuid) { + Object.entries(values.attributes) + .filter(([, value]) => !value) + .forEach(async ([key]) => { + const attributeUuid = patientUuidMap[`attribute.${key}`]; + await openmrsFetch(`/ws/rest/v1/person/${values.patientUuid}/attribute/${attributeUuid}`, { + method: 'DELETE', + }).catch((err) => { + console.error(err); + }); + }); + } + } + + return attributes; + } + + static getPatientDeathInfo(values: FormValues) { + const { isDead, deathDate, deathCause } = values; + return { + dead: isDead, + deathDate: isDead ? deathDate : undefined, + causeOfDeath: isDead ? deathCause : undefined, + }; + } + + static mapPatientToFhirPatient(patient: Partial): fhir.Patient { + // Important: + // When changing this code, ideally assume that `patient` can be missing any attribute. + // The `fhir.Patient` provides us with the benefit that all properties are nullable and thus + // not required (technically, at least). -> Even if we cannot map some props here, we still + // provide a valid fhir.Patient object. The various patient chart modules should be able to handle + // such missing props correctly (and should be updated if they don't). + + // Mapping inspired by: + // https://github.com/openmrs/openmrs-module-fhir/blob/669b3c52220bb9abc622f815f4dc0d8523687a57/api/src/main/java/org/openmrs/module/fhir/api/util/FHIRPatientUtil.java#L36 + // https://github.com/openmrs/openmrs-esm-patient-management/blob/94e6f637fb37cf4984163c355c5981ea6b8ca38c/packages/esm-patient-search-app/src/patient-search-result/patient-search-result.component.tsx#L21 + // Update as required. + return { + id: patient.uuid, + gender: patient.person?.gender, + birthDate: patient.person?.birthdate, + deceasedBoolean: patient.person.dead, + deceasedDateTime: patient.person.deathDate, + name: patient.person?.names?.map((name) => ({ + given: [name.givenName, name.middleName].filter(Boolean), + family: name.familyName, + })), + address: patient.person?.addresses.map((address) => ({ + city: address.cityVillage, + country: address.country, + postalCode: address.postalCode, + state: address.stateProvince, + use: 'home', + })), + telecom: patient.person.attributes?.filter((attribute) => attribute.attributeType === 'Telephone Number'), + }; + } +} + +export class SavePatientTransactionManager { + patientSaved = false; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.component.tsx new file mode 100644 index 00000000..c2a31ab8 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.component.tsx @@ -0,0 +1,179 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Layer, TextInput } from '@carbon/react'; +import { useField } from 'formik'; + +// FIXME Temporarily imported here +export interface TextInputProps + extends Omit, 'defaultValue' | 'id' | 'size' | 'value'> { + /** + * Specify an optional className to be applied to the `` node + */ + className?: string; + + /** + * Optionally provide the default value of the `` + */ + defaultValue?: string | number; + + /** + * Specify whether the `` should be disabled + */ + disabled?: boolean; + + /** + * Specify whether to display the character counter + */ + enableCounter?: boolean; + + /** + * Provide text that is used alongside the control label for additional help + */ + helperText?: React.ReactNode; + + /** + * Specify whether you want the underlying label to be visually hidden + */ + hideLabel?: boolean; + + /** + * Specify a custom `id` for the `` + */ + id: string; + + /** + * `true` to use the inline version. + */ + inline?: boolean; + + /** + * Specify whether the control is currently invalid + */ + invalid?: boolean; + + /** + * Provide the text that is displayed when the control is in an invalid state + */ + invalidText?: React.ReactNode; + + /** + * Provide the text that will be read by a screen reader when visiting this + * control + */ + labelText: React.ReactNode; + + /** + * `true` to use the light version. For use on $ui-01 backgrounds only. + * Don't use this to make tile background color same as container background color. + * 'The `light` prop for `TextInput` has ' + + 'been deprecated in favor of the new `Layer` component. It will be removed in the next major release.' + */ + light?: boolean; + + /** + * Max character count allowed for the input. This is needed in order for enableCounter to display + */ + maxCount?: number; + + /** + * Optionally provide an `onChange` handler that is called whenever `` + * is updated + */ + onChange?: (event: React.ChangeEvent) => void; + + /** + * Optionally provide an `onClick` handler that is called whenever the + * `` is clicked + */ + onClick?: (event: React.MouseEvent) => void; + + /** + * Specify the placeholder attribute for the `` + */ + placeholder?: string; + + /** + * Whether the input should be read-only + */ + readOnly?: boolean; + + /** + * Specify the size of the Text Input. Currently supports the following: + */ + size?: 'sm' | 'md' | 'lg' | 'xl'; + + /** + * Specify the type of the `` + */ + type?: string; + + /** + * Specify the value of the `` + */ + value?: string | number | undefined; + + /** + * Specify whether the control is currently in warning state + */ + warn?: boolean; + + /** + * Provide the text that is displayed when the control is in warning state + */ + warnText?: React.ReactNode; +} + +interface InputProps extends TextInputProps { + checkWarning?(value: string): string; +} + +export const Input: React.FC = ({ checkWarning, ...props }) => { + const [field, meta] = useField(props.name); + const { t } = useTranslation(); + + /* + Do not remove these comments + t('givenNameRequired') + t('familyNameRequired') + t('genderUnspecified') + t('genderRequired') + t('birthdayRequired') + t('birthdayNotInTheFuture') + t('negativeYears') + t('negativeMonths') + t('deathdayNotInTheFuture') + t('invalidEmail') + t('numberInNameDubious') + t('yearsEstimateRequired') + */ + + const value = field.value || ''; + const invalidText = meta.error && t(meta.error); + const warnText = useMemo(() => { + if (!invalidText && typeof checkWarning === 'function') { + const warning = checkWarning(value); + return warning && t(warning); + } + + return undefined; + }, [checkWarning, invalidText, value, t]); + + const labelText = props.required ? props.labelText : `${props.labelText} (${t('optional', 'optional')})`; + + return ( +
+ + + +
+ ); +}; diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.test.tsx new file mode 100644 index 00000000..77949365 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/input/input.test.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Formik, Form } from 'formik'; +import { Input } from './input.component'; + +describe('text input', () => { + const setupInput = async () => { + render( + {}}> +
+ { + if (value.length > 5) { + return 'name should be of 5 char'; + } + }} + /> +
+
, + ); + return screen.getByLabelText('Text') as HTMLInputElement; + }; + + it('exists', async () => { + const input = await setupInput(); + expect(input.type).toEqual('text'); + }); + + it('can input valid data without warning', async () => { + const user = userEvent.setup(); + + const input = await setupInput(); + const userInput = 'text'; + + await user.type(input, userInput); + await user.tab(); + + expect(input.value).toEqual(userInput); + expect(screen.queryByText('name should be of 5 char')).not.toBeInTheDocument(); + }); + + it('should show a warning when the invalid input is entered', async () => { + const user = userEvent.setup(); + + const input = await setupInput(); + const userInput = 'Hello World'; + + await userEvent.clear(input); + + await user.type(input, userInput); + await user.tab(); + + expect(screen.getByText('name should be of 5 char')).toBeInTheDocument(); + }); + + it('should show the correct label text if the field is not required', () => { + render( + {}}> +
+ +
+
, + ); + expect(screen.getByLabelText('Text (optional)')).toBeInTheDocument(); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.component.tsx new file mode 100644 index 00000000..c120bbc8 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.component.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Layer, Select, SelectItem } from '@carbon/react'; +import { useField } from 'formik'; +import { useTranslation } from 'react-i18next'; + +interface SelectInputProps { + name: string; + options: Array; + label: string; + required?: boolean; +} + +export const SelectInput: React.FC = ({ name, options, label, required }) => { + const [field] = useField(name); + const { t } = useTranslation(); + const selectOptions = [ +