= ({ personAttributeTypeUuid, conceptUuid }) => {
+ const { data: personAttributeType, isLoading } = usePersonAttributeType(personAttributeTypeUuid);
+ const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers(conceptUuid);
+
+ return !isLoading ? (
+
+ {!isLoadingConceptAnswers && conceptAnswers?.length ? (
+
+
+ {conceptAnswers.map((answer) => (
+
+ ))}
+
+
+ ) : (
+
+ )}
+
+ ) : 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 (
+ <>
+
+
+ {answers.map((answer) => (
+
+ ))}
+
+ >
+ );
+ }}
+
+
+ ) : 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(
+ {}}>
+
+ ,
+ );
+ 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 = [
+ ,
+ ...options.map((currentOption, index) => ),
+ ];
+
+ const labelText = required ? label : `${label} (${t('optional', 'optional')})`;
+
+ return (
+
+
+
+ {selectOptions}
+
+
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.test.tsx
new file mode 100644
index 00000000..ba873599
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/basic-input/select/select-input.test.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { Formik, Form } from 'formik';
+import { SelectInput } from './select-input.component';
+
+describe('the select input', () => {
+ const setupSelect = async () => {
+ render(
+
+
+ ,
+ );
+ return screen.getByLabelText('Select') as HTMLInputElement;
+ };
+
+ it('exists', async () => {
+ const input = await setupSelect();
+ expect(input.type).toEqual('select-one');
+ });
+
+ it('can input data', async () => {
+ const user = userEvent.setup();
+ const input = await setupSelect();
+ const expected = 'A Option';
+
+ await user.selectOptions(input, expected);
+
+ await expect(input.value).toEqual(expected);
+ });
+
+ it('should show optional label if the input is not required', async () => {
+ render(
+
+
+ ,
+ );
+
+ await expect(screen.findByRole('combobox'));
+
+ const selectInput = screen.getByRole('combobox', { name: 'Select (optional)' }) as HTMLSelectElement;
+ expect(selectInput.labels).toHaveLength(1);
+ expect(selectInput.labels[0]).toHaveTextContent('Select (optional)');
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/combo-input.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/combo-input.component.tsx
new file mode 100644
index 00000000..291b4314
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/combo-input.component.tsx
@@ -0,0 +1,128 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import classNames from 'classnames';
+import { TextInput, Layer } from '@carbon/react';
+import SelectionTick from './selection-tick.component';
+import styles from '../input.scss';
+
+interface ComboInputProps {
+ entries: Array;
+ name: string;
+ fieldProps: {
+ value: string;
+ labelText: string;
+ [x: string]: any;
+ };
+ handleInputChange: (newValue: string) => void;
+ handleSelection: (newSelection) => void;
+}
+
+const ComboInput: React.FC = ({ entries, fieldProps, handleInputChange, handleSelection }) => {
+ const [highlightedEntry, setHighlightedEntry] = useState(-1);
+ const { value = '' } = fieldProps;
+ const [showEntries, setShowEntries] = useState(false);
+ const comboInputRef = useRef(null);
+
+ const handleFocus = useCallback(() => {
+ setShowEntries(true);
+ setHighlightedEntry(-1);
+ }, [setShowEntries, setHighlightedEntry]);
+
+ const filteredEntries = useMemo(() => {
+ if (!entries) {
+ return [];
+ }
+ if (!value) {
+ return entries;
+ }
+ return entries.filter((entry) => entry.toLowerCase().includes(value.toLowerCase()));
+ }, [entries, value]);
+
+ const handleOptionClick = useCallback(
+ (newSelection: string, e: KeyboardEvent = null) => {
+ e?.preventDefault();
+ handleSelection(newSelection);
+ setShowEntries(false);
+ },
+ [handleSelection, setShowEntries],
+ );
+
+ const handleKeyPress = useCallback(
+ (e: KeyboardEvent) => {
+ const totalResults = filteredEntries.length ?? 0;
+
+ if (e.key === 'Tab') {
+ setShowEntries(false);
+ setHighlightedEntry(-1);
+ }
+
+ if (e.key === 'ArrowUp') {
+ setHighlightedEntry((prev) => Math.max(-1, prev - 1));
+ } else if (e.key === 'ArrowDown') {
+ setHighlightedEntry((prev) => Math.min(totalResults - 1, prev + 1));
+ } else if (e.key === 'Enter' && highlightedEntry > -1) {
+ handleOptionClick(filteredEntries[highlightedEntry], e);
+ }
+ },
+ [highlightedEntry, handleOptionClick, filteredEntries, setHighlightedEntry, setShowEntries],
+ );
+
+ useEffect(() => {
+ const listener = (e) => {
+ if (!comboInputRef.current.contains(e.target as Node)) {
+ setShowEntries(false);
+ setHighlightedEntry(-1);
+ }
+ };
+ window.addEventListener('click', listener);
+ return () => {
+ window.removeEventListener('click', listener);
+ };
+ });
+
+ return (
+
+
+ {
+ setHighlightedEntry(-1);
+ handleInputChange(e.target.value);
+ }}
+ onFocus={handleFocus}
+ autoComplete={'off'}
+ onKeyDown={handleKeyPress}
+ />
+
+
+ {showEntries && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default ComboInput;
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/selection-tick.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/selection-tick.component.tsx
new file mode 100644
index 00000000..fefc0b7f
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/combo-input/selection-tick.component.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+
+function SelectionTick() {
+ return (
+
+
+
+ );
+}
+
+export default SelectionTick;
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.component.tsx
new file mode 100644
index 00000000..148aaea2
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.component.tsx
@@ -0,0 +1,187 @@
+import React, { type HTMLAttributes, useEffect, useRef, useState } from 'react';
+import { Layer, Search, type SearchProps } from '@carbon/react';
+import classNames from 'classnames';
+import styles from './autosuggest.scss';
+
+// FIXME Temporarily included types from Carbon
+type InputPropsBase = Omit, 'onChange'>;
+
+interface SearchProps extends InputPropsBase {
+ /**
+ * Specify an optional value for the `autocomplete` property on the underlying
+ * ` `, defaults to "off"
+ */
+ autoComplete?: string;
+
+ /**
+ * Specify an optional className to be applied to the container node
+ */
+ className?: string;
+
+ /**
+ * Specify a label to be read by screen readers on the "close" button
+ */
+ closeButtonLabelText?: string;
+
+ /**
+ * Optionally provide the default value of the ` `
+ */
+ defaultValue?: string | number;
+
+ /**
+ * Specify whether the ` ` should be disabled
+ */
+ disabled?: boolean;
+
+ /**
+ * Specify whether or not ExpandableSearch should render expanded or not
+ */
+ isExpanded?: boolean;
+
+ /**
+ * Specify a custom `id` for the input
+ */
+ id?: string;
+
+ /**
+ * Provide the label text for the Search icon
+ */
+ labelText: React.ReactNode;
+
+ /**
+ * Optional callback called when the search value changes.
+ */
+ onChange?(e: { target: HTMLInputElement; type: 'change' }): void;
+
+ /**
+ * Optional callback called when the search value is cleared.
+ */
+ onClear?(): void;
+
+ /**
+ * Optional callback called when the magnifier icon is clicked in ExpandableSearch.
+ */
+ onExpand?(e: React.MouseEvent | React.KeyboardEvent): void;
+
+ /**
+ * Provide an optional placeholder text for the Search.
+ * Note: if the label and placeholder differ,
+ * VoiceOver on Mac will read both
+ */
+ placeholder?: string;
+
+ /**
+ * Rendered icon for the Search.
+ * Can be a React component class
+ */
+ renderIcon?: React.ComponentType | React.FunctionComponent;
+
+ /**
+ * Specify the role for the underlying ` `, defaults to `searchbox`
+ */
+ role?: string;
+
+ /**
+ * Specify the size of the Search
+ */
+ size?: 'sm' | 'md' | 'lg';
+
+ /**
+ * Optional prop to specify the type of the ` `
+ */
+ type?: string;
+
+ /**
+ * Specify the value of the ` `
+ */
+ value?: string | number;
+}
+
+interface AutosuggestProps extends SearchProps {
+ getDisplayValue: Function;
+ getFieldValue: Function;
+ getSearchResults: (query: string) => Promise;
+ onSuggestionSelected: (field: string, value: string) => void;
+ invalid?: boolean | undefined;
+ invalidText?: string | undefined;
+}
+
+export const Autosuggest: React.FC = ({
+ getDisplayValue,
+ getFieldValue,
+ getSearchResults,
+ onSuggestionSelected,
+ invalid,
+ invalidText,
+ ...searchProps
+}) => {
+ const [suggestions, setSuggestions] = useState([]);
+ const searchBox = useRef(null);
+ const wrapper = useRef(null);
+ const { id: name, labelText } = searchProps;
+
+ useEffect(() => {
+ document.addEventListener('mousedown', handleClickOutsideComponent);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutsideComponent);
+ };
+ }, [wrapper]);
+
+ const handleClickOutsideComponent = (e) => {
+ if (wrapper.current && !wrapper.current.contains(e.target)) {
+ setSuggestions([]);
+ }
+ };
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const query = e.target.value;
+ onSuggestionSelected(name, undefined);
+
+ if (query) {
+ getSearchResults(query).then((suggestions) => {
+ setSuggestions(suggestions);
+ });
+ } else {
+ setSuggestions([]);
+ }
+ };
+
+ const handleClear = (e: React.ChangeEvent) => {
+ onSuggestionSelected(name, undefined);
+ };
+
+ const handleClick = (index: number) => {
+ const display = getDisplayValue(suggestions[index]);
+ const value = getFieldValue(suggestions[index]);
+ searchBox.current.value = display;
+ onSuggestionSelected(name, value);
+ setSuggestions([]);
+ };
+
+ return (
+
+
{labelText}
+
+
+
+ {suggestions.length > 0 && (
+
+ {suggestions.map((suggestion, index) => (
+ handleClick(index)}>
+ {getDisplayValue(suggestion)}
+
+ ))}
+
+ )}
+ {invalid ?
{invalidText} : <>>}
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.scss b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.scss
new file mode 100644
index 00000000..9441c3e4
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.scss
@@ -0,0 +1,62 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '~@openmrs/esm-styleguide/src/vars';
+
+.label01 {
+ @include type.type-style('label-01');
+}
+
+.suggestions {
+ position: relative;
+ border-top-width: 0;
+ list-style: none;
+ margin-top: 0;
+ max-height: 143px;
+ overflow-y: auto;
+ padding-left: 0;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ background-color: #fff;
+ margin-bottom: 20px;
+ z-index: 99;
+}
+
+.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;
+}
+
+.invalid input {
+ outline: 2px solid var(--cds-support-error, #da1e28);
+ outline-offset: -2px;
+}
+
+.invalidMsg {
+ color: var(--cds-text-error, #da1e28);
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.test.tsx
new file mode 100644
index 00000000..2bb5f9d2
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/autosuggest/autosuggest.test.tsx
@@ -0,0 +1,132 @@
+import React from 'react';
+import { Autosuggest } from './autosuggest.component';
+import { BrowserRouter } from 'react-router-dom';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+const mockPersons = [
+ {
+ uuid: 'randomuuid1',
+ display: 'John Doe',
+ },
+ {
+ uuid: 'randomuuid2',
+ display: 'John Smith',
+ },
+ {
+ uuid: 'randomuuid3',
+ display: 'James Smith',
+ },
+ {
+ uuid: 'randomuuid4',
+ display: 'Spider Man',
+ },
+];
+
+const mockedGetSearchResults = async (query: string) => {
+ return mockPersons.filter((person) => {
+ return person.display.toUpperCase().includes(query.toUpperCase());
+ });
+};
+
+const mockedHandleSuggestionSelected = jest.fn((field, value) => [field, value]);
+
+describe('Autosuggest', () => {
+ afterEach(() => mockedHandleSuggestionSelected.mockClear());
+
+ it('renders a search box', () => {
+ renderAutosuggest();
+
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ expect(screen.queryByRole('list')).toBeNull();
+ });
+
+ it('renders matching search results in a list when the user types a query', async () => {
+ const user = userEvent.setup();
+
+ renderAutosuggest();
+
+ const searchbox = screen.getByRole('searchbox');
+ await user.type(searchbox, 'john');
+
+ const list = screen.getByRole('list');
+
+ expect(list).toBeInTheDocument();
+ expect(list.children).toHaveLength(2);
+ expect(screen.getAllByRole('listitem')[0]).toHaveTextContent('John Doe');
+ expect(screen.getAllByRole('listitem')[1]).toHaveTextContent('John Smith');
+ });
+
+ it('clears the list of suggestions when a suggestion is selected', async () => {
+ const user = userEvent.setup();
+
+ renderAutosuggest();
+
+ let list = screen.queryByRole('list');
+ expect(list).toBeNull();
+
+ const searchbox = screen.getByRole('searchbox');
+ await user.type(searchbox, 'john');
+
+ list = screen.getByRole('list');
+ expect(list).toBeInTheDocument();
+
+ const listitems = screen.getAllByRole('listitem');
+ await user.click(listitems[0]);
+
+ expect(mockedHandleSuggestionSelected).toHaveBeenLastCalledWith('person', 'randomuuid1');
+
+ list = screen.queryByRole('list');
+ expect(list).toBeNull();
+ });
+
+ it('changes suggestions when a search input is changed', async () => {
+ const user = userEvent.setup();
+
+ renderAutosuggest();
+
+ let list = screen.queryByRole('list');
+ expect(list).toBeNull();
+
+ const searchbox = screen.getByRole('searchbox');
+ await user.type(searchbox, 'john');
+
+ const suggestion = await screen.findByText('John Doe');
+ expect(suggestion).toBeInTheDocument();
+
+ await user.clear(searchbox);
+
+ list = screen.queryByRole('list');
+ expect(list).toBeNull();
+ });
+
+ it('hides the list of suggestions when the user clicks outside of the component', async () => {
+ const user = userEvent.setup();
+
+ renderAutosuggest();
+
+ const input = screen.getByRole('searchbox');
+
+ await user.type(input, 'john');
+ await screen.findByText('John Doe');
+ await user.click(document.body);
+
+ expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
+ });
+});
+
+function renderAutosuggest() {
+ render(
+
+ item.display}
+ getFieldValue={(item) => item.uuid}
+ id="person"
+ labelText=""
+ onSuggestionSelected={mockedHandleSuggestionSelected}
+ placeholder="Find Person"
+ />
+ ,
+ );
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.component.tsx
new file mode 100644
index 00000000..04918ccb
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.component.tsx
@@ -0,0 +1,156 @@
+import React, { useState, useCallback, useContext, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useField } from 'formik';
+import { Button } from '@carbon/react';
+import { TrashCan, Edit, Reset } from '@carbon/react/icons';
+import { ResourcesContext } from '../../../../offline.resources';
+import { showModal, useConfig, UserHasAccess } from '@openmrs/esm-framework';
+import { shouldBlockPatientIdentifierInOfflineMode } from './utils';
+import { deleteIdentifierType, setIdentifierSource } from '../../../field/id/id-field.component';
+import { type PatientIdentifierValue } from '../../../patient-registration.types';
+import { PatientRegistrationContext } from '../../../patient-registration-context';
+import { Input } from '../../basic-input/input/input.component';
+import styles from '../../input.scss';
+
+interface IdentifierInputProps {
+ patientIdentifier: PatientIdentifierValue;
+ fieldName: string;
+}
+
+export const IdentifierInput: React.FC = ({ patientIdentifier, fieldName }) => {
+ const { t } = useTranslation();
+ const { defaultPatientIdentifierTypes } = useConfig();
+ const { identifierTypes } = useContext(ResourcesContext);
+ const { isOffline, values, setFieldValue } = useContext(PatientRegistrationContext);
+ const identifierType = useMemo(
+ () => identifierTypes.find((identifierType) => identifierType.uuid === patientIdentifier.identifierTypeUuid),
+ [patientIdentifier, identifierTypes],
+ );
+ const { autoGeneration, initialValue, identifierValue, identifierName, required } = patientIdentifier;
+ const [hideInputField, setHideInputField] = useState(autoGeneration || initialValue === identifierValue);
+ const name = `identifiers.${fieldName}.identifierValue`;
+ const [identifierField, identifierFieldMeta] = useField(name);
+
+ const disabled = isOffline && shouldBlockPatientIdentifierInOfflineMode(identifierType);
+
+ const defaultPatientIdentifierTypesMap = useMemo(() => {
+ const map = {};
+ defaultPatientIdentifierTypes?.forEach((typeUuid) => {
+ map[typeUuid] = true;
+ });
+ return map;
+ }, [defaultPatientIdentifierTypes]);
+
+ const handleReset = useCallback(() => {
+ setHideInputField(true);
+ setFieldValue(`identifiers.${fieldName}`, {
+ ...patientIdentifier,
+ identifierValue: initialValue,
+ selectedSource: null,
+ autoGeneration: false,
+ } as PatientIdentifierValue);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialValue, setHideInputField]);
+
+ const handleEdit = () => {
+ setHideInputField(false);
+ setFieldValue(`identifiers.${fieldName}`, {
+ ...patientIdentifier,
+ ...setIdentifierSource(identifierType?.identifierSources?.[0], initialValue, initialValue),
+ });
+ };
+
+ const handleDelete = () => {
+ /*
+ If there is an initialValue to the identifier, a confirmation modal seeking
+ confirmation to delete the identifier should be shown, else in the other case,
+ we can directly delete the identifier.
+ */
+
+ if (initialValue) {
+ const confirmDeleteIdentifierModal = showModal('delete-identifier-confirmation-modal', {
+ deleteIdentifier: (deleteIdentifier) => {
+ if (deleteIdentifier) {
+ setFieldValue('identifiers', deleteIdentifierType(values.identifiers, fieldName));
+ }
+ confirmDeleteIdentifierModal();
+ },
+ identifierName,
+ initialValue,
+ });
+ } else {
+ setFieldValue('identifiers', deleteIdentifierType(values.identifiers, fieldName));
+ }
+ };
+
+ return (
+
+ {!autoGeneration && !hideInputField ? (
+
+ ) : (
+
+
{identifierName}
+
+ {autoGeneration ? t('autoGeneratedPlaceholderText', 'Auto-generated') : identifierValue}
+
+
+ {/* This is added for any error descriptions */}
+ {!!(identifierFieldMeta.touched && identifierFieldMeta.error) && (
+
{identifierFieldMeta.error && t(identifierFieldMeta.error)}
+ )}
+
+ )}
+
+ {!patientIdentifier.required && patientIdentifier.initialValue && hideInputField && (
+
+
+
+
+
+ )}
+ {initialValue && initialValue !== identifierValue && (
+
+
+
+
+
+ )}
+ {!patientIdentifier.required && !defaultPatientIdentifierTypesMap[patientIdentifier.identifierTypeUuid] && (
+
+
+
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.test.tsx
new file mode 100644
index 00000000..d29f5585
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/identifier-input.test.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Formik, Form } from 'formik';
+import { IdentifierInput } from './identifier-input.component';
+import { initialFormValues } from '../../../patient-registration.component';
+import { type PatientIdentifierType } from '../../../patient-registration-types';
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ };
+});
+
+describe.skip('identifier input', () => {
+ 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,
+ },
+ },
+ ],
+ autoGenerationSource: null,
+ };
+ const setupIdentifierInput = async (identifierType: PatientIdentifierType) => {
+ initialFormValues['source-for-' + identifierType.fieldName] = identifierType.identifierSources[0].name;
+ render(
+
+
+ ,
+ );
+ const identifierInput = screen.getByLabelText(identifierType.fieldName) as HTMLInputElement;
+ let identifierSourceSelectInput = screen.getByLabelText('source-for-' + identifierType.fieldName);
+ return {
+ identifierInput,
+ identifierSourceSelectInput,
+ };
+ };
+
+ it('exists', async () => {
+ const { identifierInput, identifierSourceSelectInput } = await setupIdentifierInput(openmrsID);
+ expect(identifierInput.type).toBe('text');
+ expect(identifierSourceSelectInput.type).toBe('select-one');
+ });
+
+ it('has correct props for identifier source select input', async () => {
+ const { identifierSourceSelectInput } = await setupIdentifierInput(openmrsID);
+ expect(identifierSourceSelectInput.childElementCount).toBe(3);
+ expect(identifierSourceSelectInput.value).toBe('Generator 1 for OpenMRS ID');
+ });
+
+ it('has correct props for identifier input', async () => {
+ const { identifierInput } = await setupIdentifierInput(openmrsID);
+ expect(identifierInput.placeholder).toBe('Auto-generated');
+ expect(identifierInput.disabled).toBe(true);
+ });
+
+ it('text input should not be disabled if manual entry is enabled', async () => {
+ // setup
+ openmrsID.identifierSources[0].autoGenerationOption.manualEntryEnabled = true;
+ // replay
+ const { identifierInput } = await setupIdentifierInput(openmrsID);
+ expect(identifierInput.placeholder).toBe('Auto-generated');
+ expect(identifierInput.disabled).toBe(false);
+ });
+
+ it('should not render select widget if auto-entry is false', async () => {
+ // setup
+ openmrsID.identifierSources = [
+ {
+ uuid: '691eed12-c0f1-11e2-94be-8c13b969e334',
+ name: 'Generator 1 for OpenMRS ID',
+ autoGenerationOption: {
+ manualEntryEnabled: true,
+ automaticGenerationEnabled: false,
+ },
+ },
+ ];
+ // replay
+ const { identifierInput, identifierSourceSelectInput } = await setupIdentifierInput(openmrsID);
+ expect(identifierInput.placeholder).toBe('Enter identifier');
+ expect(identifierInput.disabled).toBe(false);
+ expect(identifierSourceSelectInput).toBe(undefined);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.test.ts b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.test.ts
new file mode 100644
index 00000000..5f3bd0c0
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.test.ts
@@ -0,0 +1,81 @@
+import { isUniqueIdentifierTypeForOffline, shouldBlockPatientIdentifierInOfflineMode } from './utils';
+
+interface IdentifierTypeOptions {
+ uniquenessBehavior?: 'UNIQUE' | 'LOCATION' | 'NON_UNIQUE';
+ manualEntryEnabled?: boolean;
+ automaticGenerationEnabled?: boolean;
+}
+
+function createIdentifierType(options: IdentifierTypeOptions) {
+ return {
+ uniquenessBehavior: options.uniquenessBehavior,
+ identifierSources: [
+ {
+ uuid: 'identifier-source-uuid',
+ name: 'Identifier Source Name',
+ autoGenerationOption: {
+ manualEntryEnabled: options.manualEntryEnabled,
+ automaticGenerationEnabled: options.automaticGenerationEnabled,
+ },
+ },
+ ],
+ name: 'Identifier Type Name',
+ required: true,
+ uuid: 'identifier-type-uuid',
+ fieldName: 'identifierFieldName',
+ format: 'identifierFormat',
+ isPrimary: true,
+ };
+}
+
+describe('shouldBlockPatientIdentifierInOfflineMode function', () => {
+ it('should return false if identifierType is not unique', () => {
+ const identifierType = createIdentifierType({ uniquenessBehavior: null });
+
+ const result = shouldBlockPatientIdentifierInOfflineMode(identifierType);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false if identifierType is unique and no manual entry is enabled', () => {
+ const identifierType = createIdentifierType({ uniquenessBehavior: null });
+
+ const result = shouldBlockPatientIdentifierInOfflineMode(identifierType);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true if identifierType is unique and manual entry is enabled', () => {
+ const identifierType = createIdentifierType({ manualEntryEnabled: true, uniquenessBehavior: 'UNIQUE' });
+
+ const result = shouldBlockPatientIdentifierInOfflineMode(identifierType);
+
+ expect(result).toBe(true);
+ });
+});
+
+describe('isUniqueIdentifierTypeForOffline function', () => {
+ it('should return true if uniquenessBehavior is UNIQUE', () => {
+ const identifierType = createIdentifierType({ uniquenessBehavior: 'UNIQUE' });
+
+ const result = isUniqueIdentifierTypeForOffline(identifierType);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true if uniquenessBehavior is LOCATION', () => {
+ const identifierType = createIdentifierType({ uniquenessBehavior: 'LOCATION' });
+
+ const result = isUniqueIdentifierTypeForOffline(identifierType);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false for other uniqueness behaviors', () => {
+ const identifierType = createIdentifierType({ uniquenessBehavior: null });
+
+ const result = isUniqueIdentifierTypeForOffline(identifierType);
+
+ expect(result).toBe(false);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.ts b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.ts
new file mode 100644
index 00000000..a0a0436c
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/custom-input/identifier/utils.ts
@@ -0,0 +1,19 @@
+import { type FetchedPatientIdentifierType, type PatientIdentifierType } from '../../../patient-registration.types';
+
+export function shouldBlockPatientIdentifierInOfflineMode(identifierType: PatientIdentifierType) {
+ // Patient Identifiers which are unique and can be manually entered are prohibited while offline because
+ // of the chance of generating conflicts when syncing later.
+ return (
+ isUniqueIdentifierTypeForOffline(identifierType) &&
+ !identifierType.identifierSources.some(
+ (source) =>
+ !source.autoGenerationOption?.manualEntryEnabled && source.autoGenerationOption?.automaticGenerationEnabled,
+ )
+ );
+}
+
+export function isUniqueIdentifierTypeForOffline(identifierType: FetchedPatientIdentifierType) {
+ // In offline mode we consider each uniqueness behavior which could cause conflicts during syncing as 'unique'.
+ // Syncing conflicts can appear for the following behaviors:
+ return identifierType.uniquenessBehavior === 'UNIQUE' || identifierType.uniquenessBehavior === 'LOCATION';
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.component.tsx
new file mode 100644
index 00000000..79f76218
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.component.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import classNames from 'classnames';
+import { v4 } from 'uuid';
+import { type FormValues } from '../../patient-registration.types';
+import styles from './../input.scss';
+
+interface DummyDataInputProps {
+ setValues(values: FormValues, shouldValidate?: boolean): void;
+}
+
+export const dummyFormValues: FormValues = {
+ patientUuid: v4(),
+ givenName: 'John',
+ middleName: '',
+ familyName: 'Smith',
+ additionalGivenName: 'Joey',
+ additionalMiddleName: '',
+ additionalFamilyName: 'Smitty',
+ addNameInLocalLanguage: true,
+ gender: 'Male',
+ birthdate: new Date(2020, 1, 1) as any,
+ yearsEstimated: 1,
+ monthsEstimated: 2,
+ birthdateEstimated: true,
+ telephoneNumber: '0800001066',
+ isDead: false,
+ deathDate: '',
+ deathCause: '',
+ relationships: [],
+ address: {
+ address1: 'Bom Jesus Street',
+ address2: '',
+ cityVillage: 'Recife',
+ stateProvince: 'Pernambuco',
+ country: 'Brazil',
+ postalCode: '50030-310',
+ },
+ identifiers: {},
+};
+
+export const DummyDataInput: React.FC = ({ setValues }) => {
+ return (
+
+ setValues(dummyFormValues)}
+ type="button"
+ aria-label="Dummy Data Input">
+ Input Dummy Data
+
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.test.tsx
new file mode 100644
index 00000000..2cf995b2
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/dummy-data/dummy-data-input.test.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DummyDataInput, dummyFormValues } from './dummy-data-input.component';
+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,
+ validator: jest.fn(),
+ };
+});
+
+describe('dummy data input', () => {
+ let formValues: FormValues = initialFormValues;
+
+ const setupInput = async () => {
+ render(
+ {
+ formValues = values;
+ }}
+ />,
+ );
+ return screen.getByLabelText('Dummy Data Input') as HTMLButtonElement;
+ };
+
+ it('exists', async () => {
+ const input = await setupInput();
+ expect(input.type).toEqual('button');
+ });
+
+ it('can input data on button click', async () => {
+ const user = userEvent.setup();
+ const input = await setupInput();
+
+ await user.click(input);
+ expect(formValues).toEqual(dummyFormValues);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/input/input.scss b/packages/esm-patient-registration-app/src/patient-registration/input/input.scss
new file mode 100644
index 00000000..10a7e2e5
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/input/input.scss
@@ -0,0 +1,118 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '../../patient-registration/patient-registration.scss';
+
+.fieldRow {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ flex-wrap: wrap;
+}
+
+.subFieldRow {
+ margin-bottom: 5px;
+}
+
+.label {
+ @include type.type-style('label-01');
+ color: $text-02;
+}
+
+.textID,
+.comboInput {
+ margin-bottom: spacing.$spacing-05;
+}
+
+.requiredField {
+ color: $danger;
+}
+
+.input {
+ margin-right: 5px;
+}
+
+.checkboxField {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ width: 400px;
+ margin-left: 10px;
+}
+
+.textInput {
+ width: 250px !important;
+ height: 40px !important;
+}
+
+.numberInput {
+ width: 200px !important;
+ height: 40px !important;
+}
+
+.checkboxInput {
+ height: 1.5rem !important;
+ width: 1.5rem !important;
+ margin-top: 8px;
+}
+
+.selectInput {
+ width: 150px !important;
+ height: 40px !important;
+}
+
+.dateInput {
+ width: 200px !important;
+ height: 40px !important;
+ font-size: large !important;
+}
+
+.telInput {
+ width: 250px !important;
+ height: 40px !important;
+}
+
+.errorInput {
+ border: 2px solid $danger !important;
+ padding-left: 0.875rem !important;
+}
+
+.errorMessage {
+ color: $danger;
+ align-self: center;
+}
+
+.dummyData {
+ cursor: pointer;
+ margin-left: 5px;
+}
+
+.IDInput {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: end;
+}
+
+.trashCan {
+ color: $danger;
+}
+
+:global(.omrs-breakpoint-lt-desktop) {
+ .IDInput {
+ max-width: unset;
+ width: 100%;
+ }
+}
+
+.dangerLabel01 {
+ @include type.type-style('label-01');
+ color: $danger;
+}
+
+.comboInputEntries {
+ position: relative;
+}
+
+.comboInputItemOption {
+ margin: 0;
+ padding-left: 1rem;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-context.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-context.ts
new file mode 100644
index 00000000..5b76ba79
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-context.ts
@@ -0,0 +1,24 @@
+import { useConfig } from '@openmrs/esm-framework';
+import { createContext, type SetStateAction } from 'react';
+import { type RegistrationConfig } from '../config-schema';
+import { type FormValues, type CapturePhotoProps } from './patient-registration.types';
+
+export interface PatientRegistrationContextProps {
+ identifierTypes: Array;
+ values: FormValues;
+ validationSchema: any;
+ inEditMode: boolean;
+ setFieldValue(field: string, value: any, shouldValidate?: boolean): void;
+ setCapturePhotoProps(value: SetStateAction): void;
+ currentPhoto: string;
+ isOffline: boolean;
+ initialFormValues: FormValues;
+ setInitialFormValues?: React.Dispatch>;
+}
+
+export const PatientRegistrationContext = createContext(undefined);
+
+export function useFieldConfig(field: string) {
+ const { fieldConfigurations } = useConfig() as RegistrationConfig;
+ return fieldConfigurations[field];
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts
new file mode 100644
index 00000000..76c82962
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts
@@ -0,0 +1,287 @@
+import {
+ type FetchResponse,
+ type OpenmrsResource,
+ getSynchronizationItems,
+ openmrsFetch,
+ useConfig,
+ usePatient,
+} from '@openmrs/esm-framework';
+import camelCase from 'lodash-es/camelCase';
+import { type Dispatch, useEffect, useMemo, useState } from 'react';
+import useSWR from 'swr';
+import { v4 } from 'uuid';
+import { type RegistrationConfig } from '../config-schema';
+import { patientRegistration } from '../constants';
+import {
+ type FormValues,
+ type PatientRegistration,
+ type PatientUuidMapType,
+ type PersonAttributeResponse,
+ type PatientIdentifierResponse,
+ type Encounter,
+} from './patient-registration.types';
+import {
+ getAddressFieldValuesFromFhirPatient,
+ getFormValuesFromFhirPatient,
+ getPatientUuidMapFromFhirPatient,
+ getPhonePersonAttributeValueFromFhirPatient,
+ latestFirstEncounter,
+} from './patient-registration-utils';
+import { useInitialPatientRelationships } from './section/patient-relationships/relationships.resource';
+import dayjs from 'dayjs';
+
+export function useInitialFormValues(patientUuid: string): [FormValues, Dispatch] {
+ const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(patientUuid);
+ const { data: attributes, isLoading: isLoadingAttributes } = useInitialPersonAttributes(patientUuid);
+ const { data: identifiers, isLoading: isLoadingIdentifiers } = useInitialPatientIdentifiers(patientUuid);
+ const { data: relationships, isLoading: isLoadingRelationships } = useInitialPatientRelationships(patientUuid);
+ const { data: encounters } = useInitialEncounters(patientUuid, patientToEdit);
+
+ const [initialFormValues, setInitialFormValues] = useState({
+ patientUuid: v4(),
+ givenName: '',
+ middleName: '',
+ familyName: '',
+ additionalGivenName: '',
+ additionalMiddleName: '',
+ additionalFamilyName: '',
+ addNameInLocalLanguage: false,
+ gender: '',
+ birthdate: null,
+ yearsEstimated: 0,
+ monthsEstimated: 0,
+ birthdateEstimated: false,
+ telephoneNumber: '',
+ isDead: false,
+ deathDate: '',
+ deathCause: '',
+ relationships: [],
+ identifiers: {},
+ address: {},
+ });
+
+ useEffect(() => {
+ (async () => {
+ if (patientToEdit) {
+ const birthdateEstimated = !/^\d{4}-\d{2}-\d{2}$/.test(patientToEdit.birthDate);
+ const [years = 0, months = 0] = patientToEdit.birthDate.split('-').map((val) => parseInt(val));
+ // Please refer: https://github.com/openmrs/openmrs-esm-patient-management/pull/697#issuecomment-1562706118
+ const estimatedMonthsAvailable = patientToEdit.birthDate.split('-').length > 1;
+ const yearsEstimated = birthdateEstimated ? Math.floor(dayjs().diff(patientToEdit.birthDate, 'month') / 12) : 0;
+ const monthsEstimated =
+ birthdateEstimated && estimatedMonthsAvailable ? dayjs().diff(patientToEdit.birthDate, 'month') % 12 : 0;
+
+ setInitialFormValues({
+ ...initialFormValues,
+ ...getFormValuesFromFhirPatient(patientToEdit),
+ address: getAddressFieldValuesFromFhirPatient(patientToEdit),
+ ...getPhonePersonAttributeValueFromFhirPatient(patientToEdit),
+ birthdateEstimated: !/^\d{4}-\d{2}-\d{2}$/.test(patientToEdit.birthDate),
+ yearsEstimated,
+ monthsEstimated,
+ });
+ } else if (!isLoadingPatientToEdit && patientUuid) {
+ const registration = await getPatientRegistration(patientUuid);
+
+ if (!registration._patientRegistrationData.formValues) {
+ console.error(
+ `Found a queued offline patient registration for patient ${patientUuid}, but without form values. Not using these values.`,
+ );
+ return;
+ }
+
+ setInitialFormValues(registration._patientRegistrationData.formValues);
+ }
+ })();
+ }, [isLoadingPatientToEdit, patientToEdit, patientUuid]);
+
+ // Set initial patient relationships
+ useEffect(() => {
+ if (!isLoadingRelationships && relationships) {
+ setInitialFormValues((initialFormValues) => ({
+ ...initialFormValues,
+ relationships,
+ }));
+ }
+ }, [isLoadingRelationships, relationships, setInitialFormValues]);
+
+ // Set Initial patient identifiers
+ useEffect(() => {
+ if (!isLoadingIdentifiers && identifiers) {
+ setInitialFormValues((initialFormValues) => ({
+ ...initialFormValues,
+ identifiers,
+ }));
+ }
+ }, [isLoadingIdentifiers, identifiers, setInitialFormValues]);
+
+ // Set Initial person attributes
+ useEffect(() => {
+ if (!isLoadingAttributes && attributes) {
+ let personAttributes = {};
+ attributes.forEach((attribute) => {
+ personAttributes[attribute.attributeType.uuid] =
+ attribute.attributeType.format === 'org.openmrs.Concept' && typeof attribute.value === 'object'
+ ? attribute.value?.uuid
+ : attribute.value;
+ });
+
+ setInitialFormValues((initialFormValues) => ({
+ ...initialFormValues,
+ attributes: personAttributes,
+ }));
+ }
+ }, [attributes, setInitialFormValues, isLoadingAttributes]);
+
+ // Set Initial registration encounters
+ useEffect(() => {
+ if (patientToEdit && encounters) {
+ setInitialFormValues((initialFormValues) => ({
+ ...initialFormValues,
+ obs: encounters as Record,
+ }));
+ }
+ }, [encounters, patientToEdit]);
+
+ return [initialFormValues, setInitialFormValues];
+}
+
+export function useInitialAddressFieldValues(patientUuid: string, fallback = {}): [object, Dispatch] {
+ const { isLoading, patient } = usePatient(patientUuid);
+ const [initialAddressFieldValues, setInitialAddressFieldValues] = useState(fallback);
+
+ useEffect(() => {
+ (async () => {
+ if (patient) {
+ setInitialAddressFieldValues({
+ ...initialAddressFieldValues,
+ address: getAddressFieldValuesFromFhirPatient(patient),
+ });
+ } else if (!isLoading && patientUuid) {
+ const registration = await getPatientRegistration(patientUuid);
+ setInitialAddressFieldValues(registration?._patientRegistrationData.initialAddressFieldValues ?? fallback);
+ }
+ })();
+ }, [isLoading, patient, patientUuid]);
+
+ return [initialAddressFieldValues, setInitialAddressFieldValues];
+}
+
+export function usePatientUuidMap(
+ patientUuid: string,
+ fallback: PatientUuidMapType = {},
+): [PatientUuidMapType, Dispatch] {
+ const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(patientUuid);
+ const { data: attributes } = useInitialPersonAttributes(patientUuid);
+ const [patientUuidMap, setPatientUuidMap] = useState(fallback);
+
+ useEffect(() => {
+ if (patientToEdit) {
+ setPatientUuidMap({ ...patientUuidMap, ...getPatientUuidMapFromFhirPatient(patientToEdit) });
+ } else if (!isLoadingPatientToEdit && patientUuid) {
+ getPatientRegistration(patientUuid).then((registration) =>
+ setPatientUuidMap(registration?._patientRegistrationData.initialAddressFieldValues ?? fallback),
+ );
+ }
+ }, [isLoadingPatientToEdit, patientToEdit, patientUuid]);
+
+ useEffect(() => {
+ if (attributes) {
+ setPatientUuidMap((prevPatientUuidMap) => ({
+ ...prevPatientUuidMap,
+ ...getPatientAttributeUuidMapForPatient(attributes),
+ }));
+ }
+ }, [attributes]);
+
+ return [patientUuidMap, setPatientUuidMap];
+}
+
+async function getPatientRegistration(patientUuid: string) {
+ const items = await getSynchronizationItems(patientRegistration);
+ return items.find((item) => item._patientRegistrationData.formValues.patientUuid === patientUuid);
+}
+
+export function useInitialPatientIdentifiers(patientUuid: string): {
+ data: FormValues['identifiers'];
+ isLoading: boolean;
+} {
+ const shouldFetch = !!patientUuid;
+
+ const { data, error, isLoading } = useSWR }>, Error>(
+ shouldFetch
+ ? `/ws/rest/v1/patient/${patientUuid}/identifier?v=custom:(uuid,identifier,identifierType:(uuid,required,name),preferred)`
+ : null,
+ openmrsFetch,
+ );
+
+ const result: {
+ data: FormValues['identifiers'];
+ isLoading: boolean;
+ } = useMemo(() => {
+ const identifiers: FormValues['identifiers'] = {};
+
+ data?.data?.results?.forEach((patientIdentifier) => {
+ identifiers[camelCase(patientIdentifier.identifierType.name)] = {
+ identifierUuid: patientIdentifier.uuid,
+ preferred: patientIdentifier.preferred,
+ initialValue: patientIdentifier.identifier,
+ identifierValue: patientIdentifier.identifier,
+ identifierTypeUuid: patientIdentifier.identifierType.uuid,
+ identifierName: patientIdentifier.identifierType.name,
+ required: patientIdentifier.identifierType.required,
+ selectedSource: null,
+ autoGeneration: false,
+ };
+ });
+ return {
+ data: identifiers,
+ isLoading,
+ };
+ }, [data, error]);
+
+ return result;
+}
+
+function useInitialEncounters(patientUuid: string, patientToEdit: fhir.Patient) {
+ const { registrationObs } = useConfig() as RegistrationConfig;
+ const { data, error, isLoading } = useSWR }>>(
+ patientToEdit && registrationObs.encounterTypeUuid
+ ? `/ws/rest/v1/encounter?patient=${patientUuid}&v=custom:(encounterDatetime,obs:(concept:ref,value:ref))&encounterType=${registrationObs.encounterTypeUuid}`
+ : null,
+ openmrsFetch,
+ );
+ const obs = data?.data.results.sort(latestFirstEncounter)?.at(0)?.obs;
+ const encounters = obs
+ ?.map(({ concept, value }) => ({
+ [(concept as OpenmrsResource).uuid]: typeof value === 'object' ? value?.uuid : value,
+ }))
+ .reduce((accu, curr) => Object.assign(accu, curr), {});
+
+ return { data: encounters, isLoading, error };
+}
+
+function useInitialPersonAttributes(personUuid: string) {
+ const shouldFetch = !!personUuid;
+ const { data, error, isLoading } = useSWR }>, Error>(
+ shouldFetch
+ ? `/ws/rest/v1/person/${personUuid}/attribute?v=custom:(uuid,display,attributeType:(uuid,display,format),value)`
+ : null,
+ openmrsFetch,
+ );
+ const result = useMemo(() => {
+ return {
+ data: data?.data?.results,
+ isLoading,
+ };
+ }, [data, error]);
+ return result;
+}
+
+function getPatientAttributeUuidMapForPatient(attributes: Array) {
+ const attributeUuidMap = {};
+ attributes.forEach((attribute) => {
+ attributeUuidMap[`attribute.${attribute?.attributeType?.uuid}`] = attribute?.uuid;
+ });
+ return attributeUuidMap;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts
new file mode 100644
index 00000000..880eca48
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts
@@ -0,0 +1,216 @@
+import * as Yup from 'yup';
+import {
+ type AddressValidationSchemaType,
+ type FormValues,
+ type PatientIdentifier,
+ type PatientUuidMapType,
+ type PatientIdentifierValue,
+ type Encounter,
+} from './patient-registration.types';
+import { parseDate } from '@openmrs/esm-framework';
+import camelCase from 'lodash-es/camelCase';
+import capitalize from 'lodash-es/capitalize';
+
+export function parseAddressTemplateXml(addressTemplate: string) {
+ const templateXmlDoc = new DOMParser().parseFromString(addressTemplate, 'text/xml');
+ const nameMappings = templateXmlDoc.querySelector('nameMappings');
+ const properties = nameMappings.getElementsByTagName('entry');
+ const validationSchemaObjs = Array.prototype.map.call(properties, (property: Element) => {
+ const name = property.getElementsByTagName('string')[0].innerHTML;
+ const label = property.getElementsByTagName('string')[1].innerHTML;
+ const regex = findElementValueInXmlDoc(name, 'elementRegex', templateXmlDoc) || '.*';
+ const regexFormat = findElementValueInXmlDoc(name, 'elementRegexFormats', templateXmlDoc) || '';
+
+ return {
+ name,
+ label,
+ regex,
+ regexFormat,
+ };
+ });
+
+ const addressValidationSchema = Yup.object(
+ validationSchemaObjs.reduce((final, current) => {
+ final[current.name] = Yup.string().matches(current.regex, current.regexFormat);
+ return final;
+ }, {}),
+ );
+
+ const addressFieldValues = Array.prototype.map.call(properties, (property: Element) => {
+ const name = property.getElementsByTagName('string')[0].innerHTML;
+ return {
+ name,
+ defaultValue: '',
+ };
+ });
+ return {
+ addressFieldValues,
+ addressValidationSchema,
+ };
+}
+export function parseAddressTemplateXmlOld(addressTemplate: string) {
+ const templateXmlDoc = new DOMParser().parseFromString(addressTemplate, 'text/xml');
+ const nameMappings = templateXmlDoc.querySelector('nameMappings').querySelectorAll('property');
+ const validationSchemaObjs: AddressValidationSchemaType[] = Array.prototype.map.call(
+ nameMappings,
+ (nameMapping: Element) => {
+ const name = nameMapping.getAttribute('name');
+ const label = nameMapping.getAttribute('value');
+ const regex = findElementValueInXmlDoc(name, 'elementRegex', templateXmlDoc) || '.*';
+ const regexFormat = findElementValueInXmlDoc(name, 'elementRegexFormats', templateXmlDoc) || '';
+
+ return {
+ name,
+ label,
+ regex,
+ regexFormat,
+ };
+ },
+ );
+
+ const addressValidationSchema = Yup.object(
+ validationSchemaObjs.reduce((final, current) => {
+ final[current.name] = Yup.string().matches(current.regex, current.regexFormat);
+ return final;
+ }, {}),
+ );
+
+ const addressFieldValues: Array<{ name: string; defaultValue: string }> = Array.prototype.map.call(
+ nameMappings,
+ (nameMapping: Element) => {
+ const name = nameMapping.getAttribute('name');
+ const defaultValue = findElementValueInXmlDoc(name, 'elementDefaults', templateXmlDoc) ?? '';
+ return { name, defaultValue };
+ },
+ );
+
+ return {
+ addressFieldValues,
+ addressValidationSchema,
+ };
+}
+
+function findElementValueInXmlDoc(fieldName: string, elementSelector: string, doc: XMLDocument) {
+ return doc.querySelector(elementSelector)?.querySelector(`[name=${fieldName}]`)?.getAttribute('value') ?? null;
+}
+
+export function scrollIntoView(viewId: string) {
+ document.getElementById(viewId).scrollIntoView({
+ behavior: 'smooth',
+ block: 'center',
+ inline: 'center',
+ });
+}
+
+export function cancelRegistration() {
+ window.history.back();
+}
+
+export function getFormValuesFromFhirPatient(patient: fhir.Patient) {
+ const result = {} as FormValues;
+ const patientName = patient.name[0];
+ const additionalPatientName = patient.name[1];
+
+ result.patientUuid = patient.id;
+ result.givenName = patientName?.given[0];
+ result.middleName = patientName?.given[1];
+ result.familyName = patientName?.family;
+ result.addNameInLocalLanguage = !!additionalPatientName ? true : undefined;
+ result.additionalGivenName = additionalPatientName?.given[0];
+ result.additionalMiddleName = additionalPatientName?.given[1];
+ result.additionalFamilyName = additionalPatientName?.family;
+
+ result.gender = patient.gender;
+ result.birthdate = patient.birthDate ? parseDate(patient.birthDate) : undefined;
+ result.telephoneNumber = patient.telecom ? patient.telecom[0].value : '';
+
+ if (patient.deceasedBoolean || patient.deceasedDateTime) {
+ result.isDead = true;
+ result.deathDate = patient.deceasedDateTime ? patient.deceasedDateTime.split('T')[0] : '';
+ }
+
+ return {
+ ...result,
+ ...patient.identifier.map((identifier) => {
+ const key = camelCase(identifier.system || identifier.type.text);
+ return { [key]: identifier.value };
+ }),
+ };
+}
+
+export function getAddressFieldValuesFromFhirPatient(patient: fhir.Patient) {
+ const result = {};
+ const address = patient.address?.[0];
+
+ if (address) {
+ for (const key of Object.keys(address)) {
+ switch (key) {
+ case 'city':
+ result['cityVillage'] = address[key];
+ break;
+ case 'state':
+ result['stateProvince'] = address[key];
+ break;
+ case 'district':
+ result['countyDistrict'] = address[key];
+ break;
+ case 'extension':
+ address[key].forEach((ext) => {
+ ext.extension.forEach((extension) => {
+ result[extension.url.split('#')[1]] = extension.valueString;
+ });
+ });
+ break;
+ default:
+ if (key === 'country' || key === 'postalCode') {
+ result[key] = address[key];
+ }
+ }
+ }
+ }
+
+ return result;
+}
+
+export function getPatientUuidMapFromFhirPatient(patient: fhir.Patient): PatientUuidMapType {
+ const patientName = patient.name[0];
+ const additionalPatientName = patient.name[1];
+ const address = patient.address?.[0];
+
+ return {
+ preferredNameUuid: patientName?.id,
+ additionalNameUuid: additionalPatientName?.id,
+ preferredAddressUuid: address?.id,
+ ...patient.identifier.map((identifier) => {
+ const key = camelCase(identifier.system || identifier.type.text);
+ return { [key]: { uuid: identifier.id, value: identifier.value } };
+ }),
+ };
+}
+
+export function getPatientIdentifiersFromFhirPatient(patient: fhir.Patient): Array {
+ return patient.identifier.map((identifier) => {
+ return {
+ uuid: identifier.id,
+ identifier: identifier.value,
+ };
+ });
+}
+
+export function getPhonePersonAttributeValueFromFhirPatient(patient: fhir.Patient) {
+ const result = {};
+ if (patient.telecom) {
+ result['phone'] = patient.telecom[0].value;
+ }
+ return result;
+}
+
+export const filterUndefinedPatientIdenfier = (patientIdenfiers) =>
+ Object.fromEntries(
+ Object.entries(patientIdenfiers).filter(
+ ([key, value]) => value.identifierValue !== undefined,
+ ),
+ );
+
+export const latestFirstEncounter = (a: Encounter, b: Encounter) =>
+ new Date(b.encounterDatetime).getTime() - new Date(a.encounterDatetime).getTime();
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx
new file mode 100644
index 00000000..9df7cd41
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx
@@ -0,0 +1,240 @@
+import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
+import classNames from 'classnames';
+import { Button, Link, InlineLoading, Dropdown } from '@carbon/react';
+import { XAxis } from '@carbon/react/icons';
+import { useLocation, useParams } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import { Formik, Form, type FormikHelpers } from 'formik';
+import {
+ createErrorHandler,
+ showSnackbar,
+ useConfig,
+ interpolateUrl,
+ usePatient,
+ showModal,
+} from '@openmrs/esm-framework';
+import { getValidationSchema } from './validation/patient-registration-validation';
+import { type FormValues, type CapturePhotoProps } from './patient-registration.types';
+import { PatientRegistrationContext } from './patient-registration-context';
+import { type SavePatientForm, SavePatientTransactionManager } from './form-manager';
+import { fetchPatientRecordFromClientRegistry, usePatientPhoto, fetchPerson } from './patient-registration.resource';
+import { DummyDataInput } from './input/dummy-data/dummy-data-input.component';
+import { cancelRegistration, filterUndefinedPatientIdenfier, scrollIntoView } from './patient-registration-utils';
+import { useInitialAddressFieldValues, useInitialFormValues, usePatientUuidMap } from './patient-registration-hooks';
+import { ResourcesContext } from '../offline.resources';
+import { builtInSections, type RegistrationConfig, type SectionDefinition } from '../config-schema';
+import { SectionWrapper } from './section/section-wrapper.component';
+import BeforeSavePrompt from './before-save-prompt';
+import styles from './patient-registration.scss';
+import { TextInput } from '@carbon/react';
+import { ClientRegistry } from '../patient-verification/client-registry.component';
+
+let exportedInitialFormValuesForTesting = {} as FormValues;
+
+export interface PatientRegistrationProps {
+ savePatientForm: SavePatientForm;
+ isOffline: boolean;
+}
+
+export const PatientRegistration: React.FC = ({ savePatientForm, isOffline }) => {
+ const { currentSession, addressTemplate, identifierTypes } = useContext(ResourcesContext);
+ const { search } = useLocation();
+ const config = useConfig() as RegistrationConfig;
+ const [target, setTarget] = useState();
+ const { patientUuid: uuidOfPatientToEdit } = useParams();
+ const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(uuidOfPatientToEdit);
+ const { t } = useTranslation();
+ const [capturePhotoProps, setCapturePhotoProps] = useState(null);
+ const [initialFormValues, setInitialFormValues] = useInitialFormValues(uuidOfPatientToEdit);
+ const [initialAddressFieldValues] = useInitialAddressFieldValues(uuidOfPatientToEdit);
+ const [patientUuidMap] = usePatientUuidMap(uuidOfPatientToEdit);
+ const location = currentSession?.sessionLocation?.uuid;
+ const inEditMode = isLoadingPatientToEdit ? undefined : !!(uuidOfPatientToEdit && patientToEdit);
+ const showDummyData = useMemo(() => localStorage.getItem('openmrs:devtools') === 'true' && !inEditMode, [inEditMode]);
+ const { data: photo } = usePatientPhoto(patientToEdit?.id);
+ const savePatientTransactionManager = useRef(new SavePatientTransactionManager());
+ const fieldDefinition = config?.fieldDefinitions?.filter((def) => def.type === 'address');
+ const validationSchema = getValidationSchema(config);
+
+ useEffect(() => {
+ exportedInitialFormValuesForTesting = initialFormValues;
+ }, [initialFormValues]);
+
+ const sections: Array = useMemo(() => {
+ return config.sections
+ .map(
+ (sectionName) =>
+ config.sectionDefinitions.filter((s) => s.id == sectionName)[0] ??
+ builtInSections.filter((s) => s.id == sectionName)[0],
+ )
+ .filter((s) => s);
+ }, [config.sections, config.sectionDefinitions]);
+
+ const onFormSubmit = async (values: FormValues, helpers: FormikHelpers) => {
+ const abortController = new AbortController();
+ helpers.setSubmitting(true);
+
+ const updatedFormValues = { ...values, identifiers: filterUndefinedPatientIdenfier(values.identifiers) };
+ try {
+ await savePatientForm(
+ !inEditMode,
+ updatedFormValues,
+ patientUuidMap,
+ initialAddressFieldValues,
+ capturePhotoProps,
+ location,
+ initialFormValues['identifiers'],
+ currentSession,
+ config,
+ savePatientTransactionManager.current,
+ abortController,
+ );
+
+ showSnackbar({
+ subtitle: inEditMode
+ ? t('updatePatientSuccessSnackbarSubtitle', "The patient's information has been successfully updated")
+ : t(
+ 'registerPatientSuccessSnackbarSubtitle',
+ 'The patient can now be found by searching for them using their name or ID number',
+ ),
+ title: inEditMode
+ ? t('updatePatientSuccessSnackbarTitle', 'Patient Details Updated')
+ : t('registerPatientSuccessSnackbarTitle', 'New Patient Created'),
+ kind: 'success',
+ isLowContrast: true,
+ });
+
+ const afterUrl = new URLSearchParams(search).get('afterUrl');
+ const redirectUrl = interpolateUrl(afterUrl || config.links.submitButton, { patientUuid: values.patientUuid });
+
+ setTarget(redirectUrl);
+ } catch (error) {
+ if (error.responseBody?.error?.globalErrors) {
+ error.responseBody.error.globalErrors.forEach((error) => {
+ showSnackbar({
+ title: inEditMode
+ ? t('updatePatientErrorSnackbarTitle', 'Patient Details Update Failed')
+ : t('registrationErrorSnackbarTitle', 'Patient Registration Failed'),
+ subtitle: error.message,
+ kind: 'error',
+ });
+ });
+ } else if (error.responseBody?.error?.message) {
+ showSnackbar({
+ title: inEditMode
+ ? t('updatePatientErrorSnackbarTitle', 'Patient Details Update Failed')
+ : t('registrationErrorSnackbarTitle', 'Patient Registration Failed'),
+ subtitle: error.responseBody.error.message,
+ kind: 'error',
+ });
+ } else {
+ createErrorHandler()(error);
+ }
+
+ helpers.setSubmitting(false);
+ }
+ };
+
+ const displayErrors = (errors) => {
+ if (errors && typeof errors === 'object' && !!Object.keys(errors).length) {
+ Object.keys(errors).forEach((error) => {
+ showSnackbar({
+ subtitle: t(`${error}LabelText`, error),
+ title: t('incompleteForm', 'The following field has errors:'),
+ kind: 'warning',
+ isLowContrast: true,
+ timeoutInMs: 5000,
+ });
+ });
+ }
+ };
+
+ return (
+
+ {(props) => (
+
+ )}
+
+ );
+};
+
+/**
+ * @internal
+ * Just exported for testing
+ */
+export { exportedInitialFormValuesForTesting as initialFormValues };
+function dispose() {
+ throw new Error('Function not implemented.');
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.test.tsx
new file mode 100644
index 00000000..bacd67cf
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.test.tsx
@@ -0,0 +1,26 @@
+import { openmrsFetch } from '@openmrs/esm-framework';
+import { savePatient } from './patient-registration.resource';
+
+const mockOpenmrsFetch = openmrsFetch as jest.Mock;
+
+jest.mock('@openmrs/esm-framework', () => ({
+ openmrsFetch: jest.fn(),
+}));
+
+describe('savePatient', () => {
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('appends patient uuid in url if provided', () => {
+ mockOpenmrsFetch.mockImplementationOnce((url) => url);
+ savePatient(null, '1234');
+ expect(mockOpenmrsFetch.mock.calls[0][0]).toEqual('/ws/rest/v1/patient/1234');
+ });
+
+ it('does not append patient uuid in url', () => {
+ mockOpenmrsFetch.mockImplementationOnce(() => {});
+ savePatient(null);
+ expect(mockOpenmrsFetch.mock.calls[0][0]).toEqual('/ws/rest/v1/patient/');
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.ts
new file mode 100644
index 00000000..e55fad63
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.resource.ts
@@ -0,0 +1,250 @@
+import useSWR from 'swr';
+import { openmrsFetch, useConfig } from '@openmrs/esm-framework';
+import { type Patient, type Relationship, type PatientIdentifier, type Encounter } from './patient-registration.types';
+
+export const uuidIdentifier = '05a29f94-c0ed-11e2-94be-8c13b969e334';
+export const uuidTelephoneNumber = '14d4f066-15f5-102d-96e4-000c29c2a5d7';
+
+function dataURItoFile(dataURI: string) {
+ const byteString = atob(dataURI.split(',')[1]);
+ const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
+ // write the bytes of the string to a typed array
+ const buffer = new Uint8Array(byteString.length);
+
+ for (let i = 0; i < byteString.length; i++) {
+ buffer[i] = byteString.charCodeAt(i);
+ }
+
+ const blob = new Blob([buffer], { type: mimeString });
+ return new File([blob], 'patient-photo.png');
+}
+
+export function savePatient(patient: Patient | null, updatePatientUuid?: string) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/patient/${updatePatientUuid || ''}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: patient,
+ signal: abortController.signal,
+ });
+}
+
+export function saveEncounter(encounter: Encounter) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/encounter`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: encounter,
+ signal: abortController.signal,
+ });
+}
+
+export function generateIdentifier(source: string) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/idgen/identifiersource/${source}/identifier`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: {},
+ signal: abortController.signal,
+ });
+}
+
+export function deletePersonName(nameUuid: string, personUuid: string) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/person/${personUuid}/name/${nameUuid}`, {
+ method: 'DELETE',
+ signal: abortController.signal,
+ });
+}
+
+export function saveRelationship(relationship: Relationship) {
+ const abortController = new AbortController();
+
+ return openmrsFetch('/ws/rest/v1/relationship', {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: relationship,
+ signal: abortController.signal,
+ });
+}
+
+export function updateRelationship(relationshipUuid, relationship: { relationshipType: string }) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/relationship/${relationshipUuid}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'POST',
+ body: { relationshipType: relationship.relationshipType },
+ signal: abortController.signal,
+ });
+}
+
+export function deleteRelationship(relationshipUuid) {
+ const abortController = new AbortController();
+
+ return openmrsFetch(`/ws/rest/v1/relationship/${relationshipUuid}`, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ method: 'DELETE',
+ signal: abortController.signal,
+ });
+}
+
+export async function savePatientPhoto(
+ patientUuid: string,
+ content: string,
+ url: string,
+ date: string,
+ conceptUuid: string,
+) {
+ const abortController = new AbortController();
+
+ const formData = new FormData();
+ formData.append('patient', patientUuid);
+ formData.append('file', dataURItoFile(content));
+ formData.append(
+ 'json',
+ JSON.stringify({
+ person: patientUuid,
+ concept: conceptUuid,
+ groupMembers: [],
+ obsDatetime: date,
+ }),
+ );
+
+ return openmrsFetch(url, {
+ method: 'POST',
+ signal: abortController.signal,
+ body: formData,
+ });
+}
+
+interface ObsFetchResponse {
+ results: Array;
+}
+
+interface PhotoObs {
+ display: string;
+ obsDatetime: string;
+ uuid: string;
+ value: {
+ display: string;
+ links: {
+ rel: string;
+ uri: string;
+ };
+ };
+}
+
+interface UsePatientPhotoResult {
+ data: { dateTime: string; imageSrc: string } | null;
+ isError: Error;
+ isLoading: boolean;
+}
+
+export function usePatientPhoto(patientUuid: string): UsePatientPhotoResult {
+ const {
+ concepts: { patientPhotoUuid },
+ } = useConfig();
+ const url = `/ws/rest/v1/obs?patient=${patientUuid}&concept=${patientPhotoUuid}&v=full`;
+
+ const { data, error, isLoading } = useSWR<{ data: ObsFetchResponse }, Error>(patientUuid ? url : null, openmrsFetch);
+
+ const item = data?.data?.results[0];
+
+ return {
+ data: item
+ ? {
+ dateTime: item?.obsDatetime,
+ imageSrc: item?.value?.links?.uri,
+ }
+ : null,
+ isError: error,
+ isLoading,
+ };
+}
+
+export async function fetchPerson(query: string, abortController: AbortController) {
+ const [patientsRes, personsRes] = await Promise.all([
+ openmrsFetch(`/ws/rest/v1/patient?q=${query}`, {
+ signal: abortController.signal,
+ }),
+ openmrsFetch(`/ws/rest/v1/person?q=${query}`, {
+ signal: abortController.signal,
+ }),
+ ]);
+
+ const results = [...patientsRes.data.results];
+
+ personsRes.data.results.forEach((person) => {
+ if (!results.some((patient) => patient.uuid === person.uuid)) {
+ results.push(person);
+ }
+ });
+
+ return results;
+}
+
+export async function addPatientIdentifier(patientUuid: string, patientIdentifier: PatientIdentifier) {
+ const abortController = new AbortController();
+ return openmrsFetch(`/ws/rest/v1/patient/${patientUuid}/identifier/`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal: abortController.signal,
+ body: patientIdentifier,
+ });
+}
+
+export async function updatePatientIdentifier(patientUuid: string, identifierUuid: string, identifier: string) {
+ const abortController = new AbortController();
+ return openmrsFetch(`/ws/rest/v1/patient/${patientUuid}/identifier/${identifierUuid}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal: abortController.signal,
+ body: { identifier },
+ });
+}
+
+export async function deletePatientIdentifier(patientUuid: string, patientIdentifierUuid: string) {
+ const abortController = new AbortController();
+ return openmrsFetch(`/ws/rest/v1/patient/${patientUuid}/identifier/${patientIdentifierUuid}?purge`, {
+ method: 'DELETE',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ signal: abortController.signal,
+ });
+}
+export const fetchPatientRecordFromClientRegistry = (
+ patientIdentifier: string,
+ identifierType: string,
+ country: string,
+) => {
+ const url = `
+ https://ngx.ampath.or.ke/registry/api/uno?uno=${patientIdentifier}&idType=${identifierType}&countryCode=${country}`;
+
+ return fetch(url)
+ .then((response) => {
+ return response.json();
+ })
+ .then((data) => data);
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.scss b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.scss
new file mode 100644
index 00000000..3c1181fa
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.scss
@@ -0,0 +1,122 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '~@openmrs/esm-styleguide/src/vars';
+
+.title {
+ color: var(--omrs-color-brand-teal);
+}
+
+.submit {
+ width: 250px;
+}
+
+.submit:hover {
+ cursor: pointer;
+}
+
+.cancelButton {
+ width: 11.688rem;
+}
+
+.submitButton {
+ margin-bottom: spacing.$spacing-05;
+ width: 11.688rem;
+ display: block;
+}
+
+.infoGrid {
+ width: 100%;
+ padding-left: spacing.$spacing-07;
+ margin-bottom: 40vh;
+ margin-top: spacing.$spacing-05;
+ max-width: 50rem;
+}
+
+.label01 {
+ @include type.type-style('label-01');
+ margin-top: spacing.$spacing-05;
+ margin-bottom: spacing.$spacing-05;
+ color: $ui-04;
+}
+
+.productiveHeading02 {
+ @include type.type-style('heading-compact-02');
+ color: $ui-04;
+ cursor: pointer;
+}
+
+.space05 {
+ margin: spacing.$spacing-05 0 spacing.$spacing-05 0;
+}
+
+.formContainer {
+ display: flex;
+ width: 100%;
+}
+
+.stickyColumn {
+ position: sticky;
+ margin-top: spacing.$spacing-05;
+ // 3rem for the nav height and 1rem for top margin
+ top: 4rem;
+}
+
+.touchTarget a:active {
+ color: $color-gray-100;
+}
+
+.linkName {
+ color: $color-gray-70;
+ line-height: 1.38;
+ font-size: spacing.$spacing-05;
+ font-weight: 600;
+
+ &:active {
+ text-decoration: none;
+ color: $color-gray-100;
+ }
+
+ &:hover {
+ text-decoration: none;
+ color: $color-gray-100;
+ cursor: pointer;
+ }
+}
+
+.main {
+ background-color: white;
+}
+
+:global(.omrs-breakpoint-lt-desktop) {
+ .infoGrid {
+ max-width: unset;
+ }
+}
+
+.spinner {
+ &:global(.cds--inline-loading) {
+ min-height: 1rem;
+ }
+}
+
+// Overriding styles for RTL support
+html[dir='rtl'] {
+ .linkName {
+ & > svg {
+ transform: scale(-1, 1);
+ }
+ }
+
+ .infoGrid {
+ padding-left: unset;
+ padding-right: spacing.$spacing-07;
+ }
+}
+.patientVerification {
+ & > * {
+ margin-top: spacing.$spacing-03;
+ }
+ & > :last-child {
+ margin-top: spacing.$spacing-03;
+ }
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx
new file mode 100644
index 00000000..1c1b11e7
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx
@@ -0,0 +1,471 @@
+import React from 'react';
+import { BrowserRouter as Router, useParams } from 'react-router-dom';
+import { render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { showSnackbar, useConfig, usePatient } from '@openmrs/esm-framework';
+import type { AddressTemplate, Encounter } from './patient-registration.types';
+import { type RegistrationConfig } from '../config-schema';
+import { FormManager } from './form-manager';
+import { ResourcesContext } from '../offline.resources';
+import { PatientRegistration } from './patient-registration.component';
+import { saveEncounter, savePatient } from './patient-registration.resource';
+import { mockedAddressTemplate } from '__mocks__';
+import { mockPatient } from 'tools';
+
+const mockedUseConfig = useConfig as jest.Mock;
+const mockedUsePatient = usePatient as jest.Mock;
+const mockedSaveEncounter = saveEncounter as jest.Mock;
+const mockedSavePatient = savePatient as jest.Mock;
+const mockedShowSnackbar = showSnackbar as jest.Mock;
+
+jest.mock('./field/field.resource', () => ({
+ useConcept: jest.fn().mockImplementation((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: [],
+ };
+ }
+ return {
+ data: data ?? null,
+ isLoading: !data,
+ };
+ }),
+ useConceptAnswers: jest.fn().mockImplementation((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,
+ };
+ }
+ }),
+}));
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ };
+});
+
+jest.mock('react-router-dom', () => ({
+ ...(jest.requireActual('react-router-dom') as any),
+ useLocation: () => ({
+ pathname: 'openmrs/spa/patient-registration',
+ }),
+ useHistory: () => [],
+ useParams: jest.fn().mockReturnValue({ patientUuid: undefined }),
+}));
+
+jest.mock('./patient-registration.resource', () => {
+ const originalModule = jest.requireActual('./patient-registration.resource');
+
+ return {
+ ...originalModule,
+ saveEncounter: jest.fn(),
+ savePatient: jest.fn(),
+ };
+});
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ getLocale: jest.fn().mockReturnValue('en'),
+ };
+});
+
+const mockResourcesContextValue = {
+ addressTemplate: mockedAddressTemplate as AddressTemplate,
+ currentSession: {
+ authenticated: true,
+ sessionId: 'JSESSION',
+ currentProvider: { uuid: 'provider-uuid', identifier: 'PRO-123' },
+ },
+ relationshipTypes: [],
+ identifierTypes: [],
+};
+
+let mockOpenmrsConfig: RegistrationConfig = {
+ sections: ['demographics', 'contact'],
+ sectionDefinitions: [
+ { id: 'demographics', name: 'Demographics', fields: ['name', 'gender', 'dob'] },
+ { id: 'contact', name: 'Contact Info', fields: ['address'] },
+ { id: 'relationships', name: 'Relationships', fields: ['relationship'] },
+ ],
+ fieldDefinitions: [],
+ fieldConfigurations: {
+ dateOfBirth: {
+ allowEstimatedDateOfBirth: true,
+ useEstimatedDateOfBirth: {
+ enabled: true,
+ dayOfMonth: new Date().getDay(),
+ month: new Date().getMonth(),
+ },
+ },
+ name: {
+ displayMiddleName: true,
+ allowUnidentifiedPatients: true,
+ defaultUnknownGivenName: 'UNKNOWN',
+ defaultUnknownFamilyName: 'UNKNOWN',
+ displayReverseFieldOrder: false,
+ displayCapturePhoto: true,
+ },
+ gender: [
+ {
+ value: 'male',
+ label: 'Male',
+ },
+ ],
+ address: {
+ useAddressHierarchy: {
+ enabled: true,
+ useQuickSearch: true,
+ searchAddressByLevel: true,
+ },
+ },
+ },
+ concepts: {
+ patientPhotoUuid: '736e8771-e501-4615-bfa7-570c03f4bef5',
+ },
+ links: {
+ submitButton: '#',
+ },
+ defaultPatientIdentifierTypes: [],
+ registrationObs: {
+ encounterTypeUuid: null,
+ encounterProviderRoleUuid: 'asdf',
+ registrationFormUuid: null,
+ },
+};
+
+const path = `/patient/:patientUuid/edit`;
+
+const configWithObs = JSON.parse(JSON.stringify(mockOpenmrsConfig));
+configWithObs.fieldDefinitions = [
+ {
+ id: 'weight',
+ type: 'obs',
+ label: null,
+ uuid: 'weight-uuid',
+ placeholder: '',
+ validation: { required: false, matches: null },
+ answerConceptSetUuid: null,
+ customConceptAnswers: [],
+ },
+ {
+ id: 'chief complaint',
+ type: 'obs',
+ label: null,
+ uuid: 'chief-complaint-uuid',
+ placeholder: '',
+ validation: { required: false, matches: null },
+ answerConceptSetUuid: null,
+ customConceptAnswers: [],
+ },
+ {
+ id: 'nationality',
+ type: 'obs',
+ label: null,
+ uuid: 'nationality-uuid',
+ placeholder: '',
+ validation: { required: false, matches: null },
+ answerConceptSetUuid: null,
+ customConceptAnswers: [],
+ },
+];
+
+configWithObs.sectionDefinitions?.push({
+ id: 'custom',
+ name: 'Custom',
+ fields: ['weight', 'chief complaint', 'nationality'],
+});
+configWithObs.sections.push('custom');
+configWithObs.registrationObs.encounterTypeUuid = 'reg-enc-uuid';
+
+const fillRequiredFields = async () => {
+ const user = userEvent.setup();
+
+ const demographicsSection = await screen.findByLabelText('Demographics Section');
+ const givenNameInput = within(demographicsSection).getByLabelText(/first/i) as HTMLInputElement;
+ const familyNameInput = within(demographicsSection).getByLabelText(/family/i) as HTMLInputElement;
+ const dateOfBirthInput = within(demographicsSection).getByLabelText(/date of birth/i) as HTMLInputElement;
+ const genderInput = within(demographicsSection).getByLabelText(/Male/) as HTMLSelectElement;
+
+ await user.type(givenNameInput, 'Paul');
+ await user.type(familyNameInput, 'Gaihre');
+ await user.clear(dateOfBirthInput);
+ await user.type(dateOfBirthInput, '02/08/1993');
+ user.click(genderInput);
+};
+
+function Wrapper({ children }) {
+ return (
+
+ {children}
+
+ );
+}
+
+describe('Registering a new patient', () => {
+ beforeEach(() => {
+ mockedUseConfig.mockReturnValue(mockOpenmrsConfig);
+ mockedSavePatient.mockReturnValue({ data: { uuid: 'new-pt-uuid' }, ok: true });
+ mockedSaveEncounter.mockClear();
+ mockedShowSnackbar.mockClear();
+ jest.clearAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ render( , { wrapper: Wrapper });
+ });
+
+ it('has the expected sections', async () => {
+ render( , { wrapper: Wrapper });
+
+ expect(screen.getByLabelText(/Demographics Section/)).not.toBeNull();
+ expect(screen.getByLabelText(/Contact Info Section/)).not.toBeNull();
+ });
+
+ it('saves the patient without extra info', async () => {
+ const user = userEvent.setup();
+
+ render( , {
+ wrapper: Wrapper,
+ });
+
+ await fillRequiredFields();
+ await user.click(await screen.findByText('Register Patient'));
+ expect(mockedSavePatient).toHaveBeenCalledWith(
+ expect.objectContaining({
+ identifiers: [], //TODO when the identifer story is finished: { identifier: '', identifierType: '05a29f94-c0ed-11e2-94be-8c13b969e334', location: '' },
+ person: {
+ addresses: expect.arrayContaining([expect.any(Object)]),
+ attributes: [],
+ birthdate: '1993-8-2',
+ birthdateEstimated: false,
+ gender: expect.stringMatching(/^M$/),
+ names: [{ givenName: 'Paul', middleName: '', familyName: 'Gaihre', preferred: true, uuid: undefined }],
+ dead: false,
+ uuid: expect.anything(),
+ },
+ uuid: expect.anything(),
+ }),
+ undefined,
+ );
+ });
+
+ it('should not save the patient if validation fails', async () => {
+ const user = userEvent.setup();
+
+ const mockedSavePatientForm = jest.fn();
+ render( , { wrapper: Wrapper });
+
+ const givenNameInput = (await screen.findByLabelText('First Name')) as HTMLInputElement;
+
+ await user.type(givenNameInput, '5');
+ await user.click(screen.getByText('Register Patient'));
+
+ expect(mockedSavePatientForm).not.toHaveBeenCalled();
+ });
+
+ it('renders and saves registration obs', async () => {
+ const user = userEvent.setup();
+
+ mockedSaveEncounter.mockResolvedValue({});
+ mockedUseConfig.mockReturnValue(configWithObs);
+
+ render( , {
+ wrapper: Wrapper,
+ });
+
+ await fillRequiredFields();
+ const customSection = screen.getByLabelText('Custom Section');
+ const weight = within(customSection).getByLabelText('Weight (kg) (optional)');
+ await user.type(weight, '50');
+ const complaint = within(customSection).getByLabelText('Chief Complaint (optional)');
+ await user.type(complaint, 'sad');
+ const nationality = within(customSection).getByLabelText('Nationality');
+ await user.selectOptions(nationality, 'USA');
+
+ await user.click(screen.getByText('Register Patient'));
+
+ expect(mockedSavePatient).toHaveBeenCalled();
+
+ expect(mockedSaveEncounter).toHaveBeenCalledWith(
+ expect.objectContaining>({
+ encounterType: 'reg-enc-uuid',
+ patient: 'new-pt-uuid',
+ obs: [
+ { concept: 'weight-uuid', value: 50 },
+ { concept: 'chief-complaint-uuid', value: 'sad' },
+ { concept: 'nationality-uuid', value: 'usa' },
+ ],
+ }),
+ );
+ });
+
+ it('retries saving registration obs after a failed attempt', async () => {
+ const user = userEvent.setup();
+
+ mockedUseConfig.mockReturnValue(configWithObs);
+
+ render( , {
+ wrapper: Wrapper,
+ });
+
+ await fillRequiredFields();
+ const customSection = screen.getByLabelText('Custom Section');
+ const weight = within(customSection).getByLabelText('Weight (kg) (optional)');
+ await user.type(weight, '-999');
+
+ mockedSaveEncounter.mockRejectedValue({ status: 400, responseBody: { error: { message: 'an error message' } } });
+
+ const registerPatientButton = screen.getByText('Register Patient');
+
+ await user.click(registerPatientButton);
+
+ expect(mockedSavePatient).toHaveBeenCalledTimes(1);
+ expect(mockedSaveEncounter).toHaveBeenCalledTimes(1);
+
+ expect(mockedShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ subtitle: 'an error message' })),
+ mockedSaveEncounter.mockResolvedValue({});
+
+ await user.click(registerPatientButton);
+ expect(mockedSavePatient).toHaveBeenCalledTimes(2);
+ expect(mockedSaveEncounter).toHaveBeenCalledTimes(2);
+
+ expect(mockedShowSnackbar).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }));
+ });
+});
+
+describe('Updating an existing patient record', () => {
+ beforeEach(() => {
+ mockedUseConfig.mockReturnValue(mockOpenmrsConfig);
+ mockedSavePatient.mockReturnValue({ data: { uuid: 'new-pt-uuid' }, ok: true });
+ mockedSaveEncounter.mockClear();
+ mockedShowSnackbar.mockClear();
+ jest.clearAllMocks();
+ });
+
+ it('edits patient demographics', async () => {
+ const user = userEvent.setup();
+
+ mockedSavePatient.mockResolvedValue({});
+
+ const mockedUseParams = useParams as jest.Mock;
+
+ mockedUseParams.mockReturnValue({ patientUuid: mockPatient.id });
+
+ mockedUsePatient.mockReturnValue({
+ isLoading: false,
+ patient: mockPatient,
+ patientUuid: mockPatient.id,
+ error: null,
+ });
+
+ render( , { wrapper: Wrapper });
+
+ const givenNameInput: HTMLInputElement = screen.getByLabelText(/First Name/);
+ const familyNameInput: HTMLInputElement = screen.getByLabelText(/Family Name/);
+ const middleNameInput: HTMLInputElement = screen.getByLabelText(/Middle Name/);
+ const dateOfBirthInput: HTMLInputElement = screen.getByLabelText('Date of Birth');
+ const genderInput: HTMLInputElement = screen.getByLabelText(/Male/);
+
+ // assert initial values
+ expect(givenNameInput.value).toBe('John');
+ expect(familyNameInput.value).toBe('Wilson');
+ expect(middleNameInput.value).toBeFalsy();
+ expect(dateOfBirthInput.value).toBe('4/4/1972');
+ expect(genderInput.value).toBe('male');
+
+ // do some edits
+ await user.clear(givenNameInput);
+ await user.clear(middleNameInput);
+ await user.clear(familyNameInput);
+ await user.type(givenNameInput, 'Eric');
+ await user.type(middleNameInput, 'Johnson');
+ await user.type(familyNameInput, 'Smith');
+ await user.click(screen.getByText('Update Patient'));
+
+ expect(mockedSavePatient).toHaveBeenCalledWith(
+ false,
+ {
+ '0': {
+ oldIdentificationNumber: '100732HE',
+ },
+ '1': {
+ openMrsId: '100GEJ',
+ },
+ addNameInLocalLanguage: undefined,
+ additionalFamilyName: '',
+ additionalGivenName: '',
+ additionalMiddleName: '',
+ address: {},
+ birthdate: new Date('1972-04-04T00:00:00.000Z'),
+ birthdateEstimated: false,
+ deathCause: '',
+ deathDate: '',
+ familyName: 'Smith',
+ gender: expect.stringMatching(/male/i),
+ givenName: 'Eric',
+ identifiers: {},
+ isDead: false,
+ middleName: 'Johnson',
+ monthsEstimated: 0,
+ patientUuid: '8673ee4f-e2ab-4077-ba55-4980f408773e',
+ relationships: [],
+ telephoneNumber: '',
+ unidentifiedPatient: undefined,
+ yearsEstimated: 0,
+ },
+ expect.anything(),
+ expect.anything(),
+ null,
+ undefined,
+ expect.anything(),
+ expect.anything(),
+ expect.anything(),
+ { patientSaved: false },
+ expect.anything(),
+ );
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.types.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.types.ts
new file mode 100644
index 00000000..eeb4149c
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.types.ts
@@ -0,0 +1,318 @@
+import { type OpenmrsResource, type Session } from '@openmrs/esm-framework';
+import { type RegistrationConfig } from '../config-schema';
+import { type SavePatientTransactionManager } from './form-manager';
+
+interface NameValue {
+ uuid: string;
+ preferred: boolean;
+ givenName: string;
+ middleName: string;
+ familyName: string;
+}
+
+export interface AttributeValue {
+ attributeType: string;
+ value: string;
+}
+
+/**
+ * Patient Identifier data as it is fetched and composed from the APIs.
+ */
+export interface FetchedPatientIdentifierType {
+ name: string;
+ required: boolean;
+ uuid: string;
+ fieldName: string;
+ format: string;
+ isPrimary: boolean;
+ /** See: https://github.com/openmrs/openmrs-core/blob/e3fb1ac0a052aeff0f957a150731757dd319693b/api/src/main/java/org/openmrs/PatientIdentifierType.java#L41 */
+ uniquenessBehavior: undefined | null | 'UNIQUE' | 'NON_UNIQUE' | 'LOCATION';
+}
+
+export interface PatientIdentifierValue {
+ identifierUuid?: string;
+ identifierTypeUuid: string;
+ initialValue: string;
+ identifierValue: string;
+ identifierName: string;
+ selectedSource: IdentifierSource;
+ autoGeneration?: boolean;
+ preferred: boolean;
+ required: boolean;
+}
+
+/**
+ * Extends the `FetchedPatientIdentifierType` with aggregated data.
+ */
+export interface PatientIdentifierType extends FetchedPatientIdentifierType {
+ identifierSources: Array;
+ autoGenerationSource?: IdentifierSource;
+ checked?: boolean;
+ source?: IdentifierSource;
+}
+
+export interface IdentifierSource {
+ uuid: string;
+ name: string;
+ autoGenerationOption?: IdentifierSourceAutoGenerationOption;
+}
+
+export interface IdentifierSourceAutoGenerationOption {
+ manualEntryEnabled: boolean;
+ automaticGenerationEnabled: boolean;
+}
+
+export interface PatientIdentifier {
+ uuid?: string;
+ identifier: string;
+ identifierType?: string;
+ location?: string;
+ preferred?: boolean;
+}
+
+export interface PatientRegistration {
+ id?: number;
+ /**
+ * The preliminary patient in the FHIR format.
+ */
+ fhirPatient: fhir.Patient;
+ /**
+ * Internal data collected by patient-registration. Required for later syncing and editing.
+ * Not supposed to be used outside of this module.
+ */
+ _patientRegistrationData: {
+ isNewPatient: boolean;
+ formValues: FormValues;
+ patientUuidMap: PatientUuidMapType;
+ initialAddressFieldValues: Record;
+ capturePhotoProps: CapturePhotoProps;
+ currentLocation: string;
+ initialIdentifierValues: FormValues['identifiers'];
+ currentUser: Session;
+ config: RegistrationConfig;
+ savePatientTransactionManager: SavePatientTransactionManager;
+ };
+}
+
+export type Relationship = {
+ relationshipType: string;
+ personA: string;
+ personB: string;
+};
+
+export type Patient = {
+ uuid: string;
+ identifiers: Array;
+ person: {
+ uuid: string;
+ names: Array;
+ gender: string;
+ birthdate: string;
+ birthdateEstimated: boolean;
+ attributes: Array;
+ addresses: Array>;
+ dead: boolean;
+ deathDate?: string;
+ causeOfDeath?: string;
+ };
+};
+
+export interface Encounter {
+ encounterDatetime: Date;
+ patient: string;
+ encounterType: string;
+ location: string;
+ encounterProviders: Array<{
+ provider: string;
+ encounterRole: string;
+ }>;
+ form: string;
+ obs: Array<{
+ concept: string | OpenmrsResource;
+ value: string | number | OpenmrsResource;
+ }>;
+}
+
+export interface RelationshipValue {
+ relatedPersonName?: string;
+ relatedPersonUuid: string;
+ relation?: string;
+ relationshipType: string;
+ /**
+ * Defines the action to be taken on the existing relationship
+ * @kind ADD -> adds a new relationship
+ * @kind UPDATE -> updates an existing relationship
+ * @kind DELETE -> deletes an existing relationship
+ * @kind undefined -> no operation on the existing relationship
+ */
+ action?: 'ADD' | 'UPDATE' | 'DELETE';
+ /**
+ * Value kept for restoring initial relationshipType value
+ */
+ initialrelationshipTypeValue?: string;
+ uuid?: string;
+}
+
+export interface FormValues {
+ patientUuid: string;
+ givenName: string;
+ middleName: string;
+ familyName: string;
+ additionalGivenName: string;
+ additionalMiddleName: string;
+ additionalFamilyName: string;
+ addNameInLocalLanguage: boolean;
+ gender: string;
+ birthdate: Date | string;
+ yearsEstimated: number;
+ monthsEstimated: number;
+ birthdateEstimated: boolean;
+ telephoneNumber: string;
+ isDead: boolean;
+ deathDate: string;
+ deathCause: string;
+ relationships: Array;
+ identifiers: {
+ [identifierFieldName: string]: PatientIdentifierValue;
+ };
+ attributes?: {
+ [attributeTypeUuid: string]: string;
+ };
+ obs?: {
+ [conceptUuid: string]: string;
+ };
+ address: {
+ [addressField: string]: string;
+ };
+}
+
+export interface PatientUuidMapType {
+ additionalNameUuid?: string;
+ preferredNameUuid?: string;
+ preferredAddressUuid?: string;
+}
+
+export interface CapturePhotoProps {
+ imageData: string;
+ dateTime: string;
+}
+
+export interface AddressValidationSchemaType {
+ name: string;
+ label: string;
+ regex: RegExp;
+ regexFormat: string;
+}
+
+export interface CodedPersonAttributeConfig {
+ personAttributeUuid: string;
+ conceptUuid: string;
+}
+
+export interface TextBasedPersonAttributeConfig {
+ personAttributeUuid: string;
+ validationRegex: string;
+}
+export interface PatientIdentifierResponse {
+ uuid: string;
+ identifier: string;
+ preferred: boolean;
+ identifierType: {
+ uuid: string;
+ required: boolean;
+ name: string;
+ };
+}
+export interface PersonAttributeTypeResponse {
+ uuid: string;
+ display: string;
+ name: string;
+ description: string;
+ format: string;
+}
+
+export interface PersonAttributeResponse {
+ display: string;
+ uuid: string;
+ value:
+ | string
+ | {
+ uuid: string;
+ display: string;
+ };
+ attributeType: {
+ display: string;
+ uuid: string;
+ format: 'org.openmrs.Concept' | string;
+ };
+}
+
+export interface ConceptResponse {
+ uuid: string;
+ display: string;
+ datatype: {
+ uuid: string;
+ display: string;
+ };
+ answers: Array;
+ setMembers: Array;
+}
+
+export interface ConceptAnswers {
+ display: string;
+ uuid: string;
+}
+
+export type AddressProperties =
+ | 'cityVillage'
+ | 'stateProvince'
+ | 'countyDistrict'
+ | 'postalCode'
+ | 'country'
+ | 'address1'
+ | 'address2'
+ | 'address3'
+ | 'address4'
+ | 'address5'
+ | 'address6'
+ | 'address7'
+ | 'address8'
+ | 'address9'
+ | 'address10'
+ | 'address11'
+ | 'address12'
+ | 'address13'
+ | 'address14'
+ | 'address15';
+
+export type ExtensibleAddressProperties = { [p in AddressProperties]?: string } | null;
+
+export interface AddressTemplate {
+ displayName: string | null;
+ codeName: string | null;
+ country: string | null;
+ lines: Array<
+ Array<{
+ isToken: 'IS_NOT_ADDR_TOKEN' | 'IS_ADDR_TOKEN';
+ displayText: string;
+ codeName?: AddressProperties;
+ displaySize?: string;
+ }>
+ > | null;
+ lineByLineFormat: Array | null;
+ nameMappings: ExtensibleAddressProperties;
+ sizeMappings: ExtensibleAddressProperties;
+ elementDefaults: ExtensibleAddressProperties;
+ elementRegex: ExtensibleAddressProperties;
+ elementRegexFormats: ExtensibleAddressProperties;
+ requiredElements: Array | null;
+}
+
+// https://rest.openmrs.org/#address-template
+export interface RestAddressTemplate {
+ uuid: string;
+ description: string;
+ property: string;
+ display: string;
+ value: string;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.component.tsx
new file mode 100644
index 00000000..6b33cf3b
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.component.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import classNames from 'classnames';
+import { useTranslation } from 'react-i18next';
+import { Input } from '../../input/basic-input/input/input.component';
+import { SelectInput } from '../../input/basic-input/select/select-input.component';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+import styles from './../section.scss';
+
+export const DeathInfoSection = () => {
+ const { values } = React.useContext(PatientRegistrationContext);
+ const { t } = useTranslation();
+
+ return (
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.test.tsx
new file mode 100644
index 00000000..9a2a06f6
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/death-info/death-info-section.test.tsx
@@ -0,0 +1,64 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Formik, Form } from 'formik';
+import { initialFormValues } from '../../patient-registration.component';
+import { DeathInfoSection } from './death-info-section.component';
+import { type FormValues } from '../../patient-registration.types';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ };
+});
+
+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: {
+ isDead: true,
+ } as FormValues,
+};
+
+describe('Death info section', () => {
+ const renderDeathInfoSection = (isDead) => {
+ initialContextValues.values.isDead = isDead;
+
+ return render(
+
+
+
+
+ ,
+ );
+ };
+
+ it('shows fields for recording death info when the patient is marked as dead', () => {
+ renderDeathInfoSection(true);
+
+ expect(screen.getByRole('region', { name: /death info section/i })).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /death info/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /is dead \(optional\)/i })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: /date of death \(optional\)/i })).toBeInTheDocument();
+ expect(screen.getByRole('combobox', { name: /cause of death \(optional\)/i })).toBeInTheDocument();
+ });
+
+ it('has the correct number of inputs if is dead is not checked', async () => {
+ renderDeathInfoSection(false);
+
+ expect(screen.queryByRole('textbox', { name: /date of death \(optional\)/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('combobox', { name: /cause of death \(optional\)/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.component.tsx
new file mode 100644
index 00000000..3ab1adb2
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.component.tsx
@@ -0,0 +1,30 @@
+import React, { useContext, useEffect } from 'react';
+import styles from './../section.scss';
+import { useField } from 'formik';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+import { Field } from '../../field/field.component';
+
+export interface DemographicsSectionProps {
+ fields: Array;
+}
+
+export const DemographicsSection: React.FC = ({ fields }) => {
+ const [field, meta] = useField('addNameInLocalLanguage');
+ const { setFieldValue } = useContext(PatientRegistrationContext);
+
+ useEffect(() => {
+ if (!field.value && meta.touched) {
+ setFieldValue('additionalGivenName', '');
+ setFieldValue('additionalMiddleName', '');
+ setFieldValue('additionalFamilyName', '');
+ }
+ }, [field.value, meta.touched]);
+
+ return (
+
+ {fields.map((field) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.test.tsx
new file mode 100644
index 00000000..e0e4615c
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/demographics/demographics-section.test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { Formik, Form } from 'formik';
+import { initialFormValues } from '../../patient-registration.component';
+import { DemographicsSection } from './demographics-section.component';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+import { type FormValues } from '../../patient-registration.types';
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ useConfig: jest.fn().mockImplementation(() => ({
+ fieldConfigurations: { dateOfBirth: { useEstimatedDateOfBirth: { enabled: true, dayOfMonth: 0, month: 0 } } },
+ })),
+ };
+});
+
+jest.mock('../../field/name/name-field.component', () => {
+ return {
+ NameField: () => (
+
+
+
+ ),
+ };
+});
+
+jest.mock('../../field/gender/gender-field.component', () => {
+ return {
+ GenderField: () => (
+
+
+
+ ),
+ };
+});
+
+jest.mock('../../field/id/id-field.component', () => {
+ return {
+ IdField: () => (
+
+
+
+ ),
+ };
+});
+
+describe('demographics section', () => {
+ const formValues: FormValues = initialFormValues;
+
+ const setupSection = async (birthdateEstimated?: boolean, addNameInLocalLanguage?: boolean) => {
+ render(
+
+
+ ,
+ );
+ const allInputs = screen.getAllByRole('textbox') as Array;
+ return allInputs.map((input) => input.name);
+ };
+
+ it('inputs corresponding to number of fields', async () => {
+ const inputNames = await setupSection();
+ expect(inputNames.length).toBe(3);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/generic-section.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/generic-section.component.tsx
new file mode 100644
index 00000000..8544a591
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/generic-section.component.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { type SectionDefinition } from '../../config-schema';
+import { Field } from '../field/field.component';
+
+export interface GenericSectionProps {
+ sectionDefinition: SectionDefinition;
+}
+
+export const GenericSection = ({ sectionDefinition }: GenericSectionProps) => {
+ return (
+
+ {sectionDefinition.fields.map((name) => (
+
+ ))}
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.component.tsx
new file mode 100644
index 00000000..72ca4504
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.component.tsx
@@ -0,0 +1,235 @@
+import React, { useCallback, useContext, useEffect, useState } from 'react';
+import {
+ Button,
+ Layer,
+ Select,
+ SelectItem,
+ InlineNotification,
+ NotificationActionButton,
+ SkeletonText,
+} from '@carbon/react';
+import { TrashCan } from '@carbon/react/icons';
+import { FieldArray } from 'formik';
+import { useTranslation } from 'react-i18next';
+import { Autosuggest } from '../../input/custom-input/autosuggest/autosuggest.component';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+import { ResourcesContext } from '../../../offline.resources';
+import { fetchPerson } from '../../patient-registration.resource';
+import { type RelationshipValue } from '../../patient-registration.types';
+import sectionStyles from '../section.scss';
+import styles from './relationships.scss';
+
+interface RelationshipType {
+ display: string;
+ uuid: string;
+ direction: string;
+}
+
+interface RelationshipViewProps {
+ relationship: RelationshipValue;
+ index: number;
+ displayRelationshipTypes: RelationshipType[];
+ remove: (index: number) => T;
+}
+
+const RelationshipView: React.FC = ({
+ relationship,
+ index,
+ displayRelationshipTypes,
+ remove,
+}) => {
+ const { t } = useTranslation();
+ const { setFieldValue } = React.useContext(PatientRegistrationContext);
+ const [isInvalid, setIsInvalid] = useState(false);
+ const newRelationship = !relationship.uuid;
+
+ const handleRelationshipTypeChange = useCallback(
+ (event) => {
+ const { target } = event;
+ const field = target.name;
+ const value = target.options[target.selectedIndex].value;
+ setFieldValue(field, value);
+ if (!relationship?.action) {
+ setFieldValue(`relationships[${index}].action`, 'UPDATE');
+ }
+ },
+ [index, relationship?.action, setFieldValue],
+ );
+
+ const handleSuggestionSelected = useCallback(
+ (field: string, selectedSuggestion: string) => {
+ setIsInvalid(!selectedSuggestion);
+ setFieldValue(field, selectedSuggestion);
+ },
+ [setFieldValue],
+ );
+
+ const searchPerson = async (query: string) => {
+ const abortController = new AbortController();
+ return await fetchPerson(query, abortController);
+ };
+
+ const deleteRelationship = useCallback(() => {
+ if (relationship.action === 'ADD') {
+ remove(index);
+ } else {
+ setFieldValue(`relationships[${index}].action`, 'DELETE');
+ }
+ }, [relationship, index, remove, setFieldValue]);
+
+ const restoreRelationship = useCallback(() => {
+ setFieldValue(`relationships[${index}]`, {
+ ...relationship,
+ action: undefined,
+ relationshipType: relationship.initialrelationshipTypeValue,
+ });
+ }, [index, setFieldValue, relationship]);
+
+ return relationship.action !== 'DELETE' ? (
+
+
+
+
{t('relationshipPlaceholder', 'Relationship')}
+
+
+
+
+
+ {newRelationship ? (
+
item.display}
+ getFieldValue={(item) => item.uuid}
+ />
+ ) : (
+ <>
+ {t('relativeFullNameLabelText', 'Full name')}
+ {relationship.relatedPersonName}
+ >
+ )}
+
+
+
+
+
+
+ {displayRelationshipTypes.map((relationshipType, index) => (
+
+ ))}
+
+
+
+
+ ) : (
+
+ {t('restoreRelationshipActionButton', 'Undo')}
+
+ }
+ />
+ );
+};
+
+export const RelationshipsSection = () => {
+ const { relationshipTypes } = useContext(ResourcesContext);
+ const [displayRelationshipTypes, setDisplayRelationshipTypes] = useState([]);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ if (relationshipTypes) {
+ const tmp: RelationshipType[] = [];
+ relationshipTypes.results.forEach((type) => {
+ const aIsToB = {
+ display: type.displayAIsToB ? type.displayAIsToB : type.displayBIsToA,
+ uuid: type.uuid,
+ direction: 'aIsToB',
+ };
+ const bIsToA = {
+ display: type.displayBIsToA ? type.displayBIsToA : type.displayAIsToB,
+ uuid: type.uuid,
+ direction: 'bIsToA',
+ };
+ aIsToB.display === bIsToA.display ? tmp.push(aIsToB) : tmp.push(aIsToB, bIsToA);
+ });
+ setDisplayRelationshipTypes(tmp);
+ }
+ }, [relationshipTypes]);
+
+ if (!relationshipTypes) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {({
+ push,
+ remove,
+ form: {
+ values: { relationships },
+ },
+ }) => (
+
+ {relationships && relationships.length > 0
+ ? relationships.map((relationship: RelationshipValue, index) => (
+
+
+
+ ))
+ : null}
+
+
+ push({
+ relatedPersonUuid: '',
+ action: 'ADD',
+ })
+ }>
+ {t('addRelationshipButtonText', 'Add Relationship')}
+
+
+
+ )}
+
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.test.tsx
new file mode 100644
index 00000000..d804e226
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships-section.test.tsx
@@ -0,0 +1,100 @@
+import React from 'react';
+import { Form, Formik } from 'formik';
+import { render, screen } from '@testing-library/react';
+import { PatientRegistrationContext } from '../../patient-registration-context';
+import { type Resources, ResourcesContext } from '../../../offline.resources';
+import { RelationshipsSection } from './relationships-section.component';
+
+jest.mock('../../patient-registration.resource', () => ({
+ fetchPerson: jest.fn().mockResolvedValue({
+ data: {
+ results: [
+ { uuid: '42ae5ce0-d64b-11ea-9064-5adc43bbdd24', display: 'Person 1' },
+ { uuid: '691eed12-c0f1-11e2-94be-8c13b969e334', display: 'Person 2' },
+ ],
+ },
+ }),
+}));
+
+let mockResourcesContextValue = {
+ addressTemplate: null,
+ currentSession: {
+ authenticated: true,
+ sessionId: 'JSESSION',
+ currentProvider: { uuid: '45ce6c2e-dd5a-11e6-9d9c-0242ac150002', identifier: 'PRO-123' },
+ },
+ identifierTypes: [],
+ relationshipTypes: null,
+} as Resources;
+
+describe('RelationshipsSection', () => {
+ it('renders a loader when relationshipTypes are not available', () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText(/loading relationships section/i)).toBeInTheDocument();
+ expect(screen.getByRole('progressbar')).toBeInTheDocument();
+ expect(screen.queryByText(/add relationship/i)).not.toBeInTheDocument();
+ });
+
+ it('renders relationships when relationshipTypes are available', () => {
+ const relationshipTypes = {
+ results: [
+ {
+ displayAIsToB: 'Mother',
+ aIsToB: 'Mother',
+ bIsToA: 'Child',
+ displayBIsToA: 'Child',
+ uuid: '42ae5ce0-d64b-11ea-9064-5adc43bbdd34',
+ },
+ {
+ displayAIsToB: 'Father',
+ aIsToB: 'Father',
+ bIsToA: 'Child',
+ displayBIsToA: 'Child',
+ uuid: '52ae5ce0-d64b-11ea-9064-5adc43bbdd24',
+ },
+ ],
+ };
+ mockResourcesContextValue = {
+ ...mockResourcesContextValue,
+ relationshipTypes: relationshipTypes,
+ };
+
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByLabelText(/relationships section/i)).toBeInTheDocument();
+ expect(screen.getByRole('heading', { name: /relationship/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /delete/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /add relationship/i })).toBeInTheDocument();
+ expect(screen.getByRole('searchbox', { name: /full name/i })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: /mother/i })).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: /father/i })).toBeInTheDocument();
+ expect(screen.getAllByRole('option', { name: /child/i }).length).toEqual(2);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.resource.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.resource.tsx
new file mode 100644
index 00000000..4813404a
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.resource.tsx
@@ -0,0 +1,78 @@
+import { useMemo } from 'react';
+import useSWR from 'swr';
+import { type FetchResponse, openmrsFetch } from '@openmrs/esm-framework';
+import { type RelationshipValue } from '../../patient-registration.types';
+import { personRelationshipRepresentation } from '../../../constants';
+
+export function useInitialPatientRelationships(patientUuid: string): {
+ data: Array;
+ isLoading: boolean;
+} {
+ const shouldFetch = !!patientUuid;
+ const { data, error, isLoading } = useSWR, Error>(
+ shouldFetch ? `/ws/rest/v1/relationship?v=${personRelationshipRepresentation}&person=${patientUuid}` : null,
+ openmrsFetch,
+ );
+
+ const result = useMemo(() => {
+ const relationships: Array | undefined = data?.data?.results.map((r) =>
+ r.personA.uuid === patientUuid
+ ? {
+ relatedPersonName: r.personB.display,
+ relatedPersonUuid: r.personB.uuid,
+ relation: r.relationshipType.bIsToA,
+ relationshipType: `${r.relationshipType.uuid}/bIsToA`,
+ /**
+ * Value kept for restoring initial value
+ */
+ initialrelationshipTypeValue: `${r.relationshipType.uuid}/bIsToA`,
+ uuid: r.uuid,
+ }
+ : {
+ relatedPersonName: r.personA.display,
+ relatedPersonUuid: r.personA.uuid,
+ relation: r.relationshipType.aIsToB,
+ relationshipType: `${r.relationshipType.uuid}/aIsToB`,
+ /**
+ * Value kept for restoring initial value
+ */
+ initialrelationshipTypeValue: `${r.relationshipType.uuid}/aIsToB`,
+ uuid: r.uuid,
+ },
+ );
+ return {
+ data: relationships,
+ error,
+ isLoading,
+ };
+ }, [patientUuid, data, error]);
+
+ return result;
+}
+
+export interface Relationship {
+ display: string;
+ uuid: string;
+ personA: {
+ age: number;
+ display: string;
+ birthdate: string;
+ uuid: string;
+ };
+ personB: {
+ age: number;
+ display: string;
+ birthdate: string;
+ uuid: string;
+ };
+ relationshipType: {
+ uuid: string;
+ display: string;
+ aIsToB: string;
+ bIsToA: string;
+ };
+}
+
+interface RelationshipsResponse {
+ results: Array;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.scss b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.scss
new file mode 100644
index 00000000..4e8f631a
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/patient-relationships/relationships.scss
@@ -0,0 +1,35 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '../../patient-registration.scss';
+
+.labelText {
+ @include type.type-style('label-01');
+}
+
+.bodyShort02 {
+ @include type.type-style('body-compact-02');
+}
+
+.searchBox {
+ margin-bottom: spacing.$spacing-05;
+}
+
+.relationshipHeader {
+ display: flex;
+ align-items: center;
+}
+
+.productiveHeading {
+ @include type.type-style('heading-compact-02');
+ color: $text-02;
+}
+
+.trashCan {
+ color: $danger !important;
+}
+
+:global(.omrs-breakpoint-lt-desktop) {
+ .relationshipHeader {
+ justify-content: space-between;
+ }
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/section-wrapper.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/section-wrapper.component.tsx
new file mode 100644
index 00000000..acc9e7ab
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/section-wrapper.component.tsx
@@ -0,0 +1,40 @@
+import React from 'react';
+import styles from '../patient-registration.scss';
+import { Tile } from '@carbon/react';
+import { useTranslation } from 'react-i18next';
+import { type SectionDefinition } from '../../config-schema';
+import { Section } from './section.component';
+
+export interface SectionWrapperProps {
+ sectionDefinition: SectionDefinition;
+ index: number;
+}
+
+export const SectionWrapper = ({ sectionDefinition, index }: SectionWrapperProps) => {
+ const { t } = useTranslation();
+
+ /*
+ * This comment exists to provide translation keys for the default section names.
+ *
+ * DO NOT REMOVE THESE UNLESS A DEFAULT SECTION IS REMOVED
+ * t('demographicsSection', 'Basic Info')
+ * t('contactSection', 'Contact Details')
+ * t('deathSection', 'Death Info')
+ * t('relationshipsSection', 'Relationships')
+ */
+ return (
+
+
+ {index + 1}. {t(`${sectionDefinition.id}Section`, sectionDefinition.name)}
+
+
+ {t('allFieldsRequiredText', 'All fields are required unless marked optional')}
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/section.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/section/section.component.tsx
new file mode 100644
index 00000000..1f3429c6
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/section.component.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import { type SectionDefinition } from '../../config-schema';
+import { GenericSection } from './generic-section.component';
+import { DeathInfoSection } from './death-info/death-info-section.component';
+import { DemographicsSection } from './demographics/demographics-section.component';
+import { RelationshipsSection } from './patient-relationships/relationships-section.component';
+
+export interface SectionProps {
+ sectionDefinition: SectionDefinition;
+}
+
+export function Section({ sectionDefinition }: SectionProps) {
+ switch (sectionDefinition.id) {
+ case 'demographics':
+ return ;
+ case 'death':
+ return ;
+ case 'relationships':
+ return ;
+ default: // includes 'contact'
+ return ;
+ }
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/section/section.scss b/packages/esm-patient-registration-app/src/patient-registration/section/section.scss
new file mode 100644
index 00000000..1ccd3aaa
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/section/section.scss
@@ -0,0 +1 @@
+@import '../patient-registration.scss';
diff --git a/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.component.tsx
new file mode 100644
index 00000000..bf00133f
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.component.tsx
@@ -0,0 +1,51 @@
+import React from 'react';
+import { useTranslation } from 'react-i18next';
+import { Button, Header } from '@carbon/react';
+import { ArrowLeft, Close } from '@carbon/react/icons';
+import { useLayoutType, isDesktop } from '@openmrs/esm-framework';
+import styles from './overlay.scss';
+
+interface OverlayProps {
+ close: () => void;
+ header: string;
+ buttonsGroup?: React.ReactElement;
+ children?: React.ReactNode;
+}
+
+const Overlay: React.FC = ({ close, children, header, buttonsGroup }) => {
+ const { t } = useTranslation();
+ const layout = useLayoutType();
+
+ return (
+
+ {isDesktop(layout) ? (
+
+ ) : (
+
+ )}
+
{children}
+
{buttonsGroup}
+
+ );
+};
+
+export default Overlay;
diff --git a/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.scss b/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.scss
new file mode 100644
index 00000000..44102a66
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/ui-components/overlay/overlay.scss
@@ -0,0 +1,63 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '../../patient-registration.scss';
+
+.desktopOverlay {
+ position: fixed;
+ top: spacing.$spacing-09;
+ right: 0;
+ height: calc(100vh - 3rem);
+ min-width: 27rem;
+ background-color: $ui-02;
+ border-left: 1px solid $text-03;
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: auto 1fr auto;
+ z-index: 999;
+}
+
+.tabletOverlay {
+ position: fixed;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ z-index: 9999;
+ background-color: $ui-02;
+ overflow: hidden;
+ padding-top: spacing.$spacing-09;
+ display: grid;
+ grid-template-rows: 1fr auto;
+}
+
+.tabletOverlayHeader {
+ button {
+ background-color: $brand-01 !important;
+ }
+ .headerContent {
+ color: $ui-02;
+ }
+}
+
+.desktopHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ background-color: $ui-03;
+ border-bottom: 1px solid $text-03;
+}
+
+.headerContent {
+ @include type.type-style('heading-compact-02');
+ padding: 0 spacing.$spacing-05;
+ color: $ui-05;
+}
+
+.closeButton {
+ background-color: $ui-02;
+}
+
+.overlayContent {
+ padding: spacing.$spacing-05;
+ overflow-y: auto;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.test.tsx
new file mode 100644
index 00000000..b86139ce
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.test.tsx
@@ -0,0 +1,157 @@
+import { defineConfigSchema, getConfig } from '@openmrs/esm-framework';
+import { getValidationSchema } from './patient-registration-validation';
+import { type RegistrationConfig, esmPatientRegistrationSchema } from '../../config-schema';
+describe('Patient Registration Validation', () => {
+ beforeAll(() => {
+ defineConfigSchema('@openmrs/esm-patient-registration-app', esmPatientRegistrationSchema);
+ });
+
+ const validFormValues = {
+ givenName: 'John',
+ familyName: 'Doe',
+ additionalGivenName: '',
+ additionalFamilyName: '',
+ gender: 'male',
+ birthdate: new Date('1990-01-01'),
+ birthdateEstimated: false,
+ deathDate: null,
+ email: 'john.doe@example.com',
+ identifiers: {
+ nationalId: {
+ required: true,
+ identifierValue: '123456789',
+ },
+ passportId: {
+ required: false,
+ identifierValue: '',
+ },
+ },
+ };
+
+ const validateFormValues = async (formValues) => {
+ const config = (await getConfig('@openmrs/esm-patient-registration-app')) as any as RegistrationConfig;
+ const validationSchema = getValidationSchema(config);
+ try {
+ await validationSchema.validate(formValues, { abortEarly: false });
+ } catch (err) {
+ return err;
+ }
+ };
+
+ it('should allow valid form values', async () => {
+ const validationError = await validateFormValues(validFormValues);
+ expect(validationError).toBeFalsy();
+ });
+
+ it('should require givenName', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ givenName: '',
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('givenNameRequired');
+ });
+
+ it('should require familyName', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ familyName: '',
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('familyNameRequired');
+ });
+
+ it('should require additionalGivenName when addNameInLocalLanguage is true', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ addNameInLocalLanguage: true,
+ additionalGivenName: '',
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('givenNameRequired');
+ });
+
+ it('should require additionalFamilyName when addNameInLocalLanguage is true', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ addNameInLocalLanguage: true,
+ additionalFamilyName: '',
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('familyNameRequired');
+ });
+
+ it('should require gender', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ gender: '',
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('genderUnspecified');
+ });
+
+ it('should allow female as a valid gender', async () => {
+ const validFormValuesWithOtherGender = {
+ ...validFormValues,
+ gender: 'female',
+ };
+ const validationError = await validateFormValues(validFormValuesWithOtherGender);
+ expect(validationError).toBeFalsy();
+ });
+
+ it('should allow other as a valid gender', async () => {
+ const validFormValuesWithOtherGender = {
+ ...validFormValues,
+ gender: 'other',
+ };
+ const validationError = await validateFormValues(validFormValuesWithOtherGender);
+ expect(validationError).toBeFalsy();
+ });
+
+ it('should allow unknown as a valid gender', async () => {
+ const validFormValuesWithOtherGender = {
+ ...validFormValues,
+ gender: 'unknown',
+ };
+ const validationError = await validateFormValues(validFormValuesWithOtherGender);
+ expect(validationError).toBeFalsy();
+ });
+
+ it('should throw error when date of birth is a future date', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ birthdate: new Date('2100-01-01'),
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('birthdayNotInTheFuture');
+ });
+
+ it('should require yearsEstimated when birthdateEstimated is true', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ birthdateEstimated: true,
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('yearsEstimateRequired');
+ });
+
+ it('should throw error when monthEstimated is negative', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ birthdateEstimated: true,
+ yearsEstimated: 0,
+ monthsEstimated: -1,
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('negativeMonths');
+ });
+
+ it('should throw error when deathDate is in future', async () => {
+ const invalidFormValues = {
+ ...validFormValues,
+ deathDate: new Date('2100-01-01'),
+ };
+ const validationError = await validateFormValues(invalidFormValues);
+ expect(validationError.errors).toContain('deathdayNotInTheFuture');
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.tsx b/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.tsx
new file mode 100644
index 00000000..adba6a85
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-registration/validation/patient-registration-validation.tsx
@@ -0,0 +1,60 @@
+import * as Yup from 'yup';
+import mapValues from 'lodash/mapValues';
+import { type FormValues } from '../patient-registration.types';
+import { type RegistrationConfig } from '../../config-schema';
+
+export function getValidationSchema(config: RegistrationConfig) {
+ return Yup.object({
+ givenName: Yup.string().required('givenNameRequired'),
+ familyName: Yup.string().required('familyNameRequired'),
+ additionalGivenName: Yup.string().when('addNameInLocalLanguage', {
+ is: true,
+ then: Yup.string().required('givenNameRequired'),
+ otherwise: Yup.string().notRequired(),
+ }),
+ additionalFamilyName: Yup.string().when('addNameInLocalLanguage', {
+ is: true,
+ then: Yup.string().required('familyNameRequired'),
+ otherwise: Yup.string().notRequired(),
+ }),
+ gender: Yup.string()
+ .oneOf(
+ config.fieldConfigurations.gender.map((g) => g.value),
+ 'genderUnspecified',
+ )
+ .required('genderRequired'),
+ birthdate: Yup.date().when('birthdateEstimated', {
+ is: false,
+ then: Yup.date().required('birthdayRequired').max(Date(), 'birthdayNotInTheFuture').nullable(),
+ otherwise: Yup.date().nullable(),
+ }),
+ yearsEstimated: Yup.number().when('birthdateEstimated', {
+ is: true,
+ then: Yup.number().required('yearsEstimateRequired').min(0, 'negativeYears'),
+ otherwise: Yup.number().nullable(),
+ }),
+ monthsEstimated: Yup.number().min(0, 'negativeMonths'),
+ deathDate: Yup.date().max(Date(), 'deathdayNotInTheFuture').nullable(),
+ email: Yup.string().optional().email('invalidEmail'),
+ identifiers: Yup.lazy((obj: FormValues['identifiers']) =>
+ Yup.object(
+ mapValues(obj, () =>
+ Yup.object({
+ required: Yup.bool(),
+ identifierValue: Yup.string().when('required', {
+ is: true,
+ then: Yup.string().required('identifierValueRequired'),
+ otherwise: Yup.string().notRequired(),
+ }),
+ }),
+ ),
+ ),
+ ),
+ relationships: Yup.array().of(
+ Yup.object().shape({
+ relatedPersonUuid: Yup.string().required(),
+ relationshipType: Yup.string().required(),
+ }),
+ ),
+ });
+}
diff --git a/packages/esm-patient-registration-app/src/patient-verification/client-registry-constants.ts b/packages/esm-patient-registration-app/src/patient-verification/client-registry-constants.ts
new file mode 100644
index 00000000..85dd162d
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/client-registry-constants.ts
@@ -0,0 +1,13 @@
+export const countries = [
+ { text: 'Kenya', id: 'KE' },
+ { text: 'Uganda', id: 'UG' },
+ { text: 'Tanzania', id: 'TZ' },
+];
+
+export const identifierTypes = [
+ { text: 'National ID number', id: '58a47054-1359-11df-a1f1-0026b9348838' },
+ { text: 'Passport number', id: 'ced014a1-068a-4a13-b6b3-17412f754af2' },
+ { text: 'Birth Certificate Entry Number', id: '7924e13b-131a-4da8-8efa-e294184a1b0d' },
+];
+
+export const defaultSelectedCountry = 'KE';
diff --git a/packages/esm-patient-registration-app/src/patient-verification/client-registry.component.tsx b/packages/esm-patient-registration-app/src/patient-verification/client-registry.component.tsx
new file mode 100644
index 00000000..7e2e024c
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/client-registry.component.tsx
@@ -0,0 +1,66 @@
+import React from 'react';
+import { type FormValues } from '../patient-registration/patient-registration.types';
+import {} from '@carbon/react';
+import { Dropdown, TextInput, Button, InlineLoading } from '@carbon/react';
+import styles from './client-registry.scss';
+import { useTranslation } from 'react-i18next';
+import { useClientRegistryForm } from './utils';
+import { countries, identifierTypes } from './client-registry-constants';
+
+type ClientRegistryProps = {
+ setInitialFormValues: (initialValues) => void;
+ initialFormValues: FormValues;
+};
+
+export const ClientRegistry: React.FC = ({ setInitialFormValues, initialFormValues }) => {
+ const { t } = useTranslation();
+ const { handleClientRegistryDataSubmit, handleOnChange, clientRegistryData } = useClientRegistryForm(
+ setInitialFormValues,
+ initialFormValues,
+ );
+ return (
+
+
+ {t('patientVerification', 'Patient Verification')}
+
+ handleOnChange(selectedItem?.id, 'country')}
+ initialSelectedItem={countries[0]}
+ itemToString={(item) => (item ? item.text : '')}
+ />
+ handleOnChange(selectedItem?.id, 'identifierType')}
+ itemToString={(item) => (item ? item.text : '')}
+ />
+ handleOnChange(e.target.value, 'patientIdentifier')}
+ id="patientIdentifier"
+ type="text"
+ labelText="Patient identifier"
+ placeholder="Enter patient identifier"
+ />
+ {clientRegistryData.isSubmitting ? (
+
+ ) : (
+ handleClientRegistryDataSubmit()}>
+ {t('searchRegistry', 'Search Registry')}
+
+ )}
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/patient-verification/client-registry.scss b/packages/esm-patient-registration-app/src/patient-verification/client-registry.scss
new file mode 100644
index 00000000..dc9f4b38
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/client-registry.scss
@@ -0,0 +1 @@
+@import '../patient-registration/patient-registration.scss';
diff --git a/packages/esm-patient-registration-app/src/patient-verification/utils.tsx b/packages/esm-patient-registration-app/src/patient-verification/utils.tsx
new file mode 100644
index 00000000..fbaf45e1
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/utils.tsx
@@ -0,0 +1,56 @@
+import { showModal } from '@openmrs/esm-framework';
+import { useState } from 'react';
+import { fetchPatientRecordFromClientRegistry } from '../patient-registration/patient-registration.resource';
+import { defaultSelectedCountry } from './client-registry-constants';
+
+export const handleSetFormValueFromClientRegistry = (clientData, setInitialFormValues, initialFormValues) => {
+ setInitialFormValues({
+ ...initialFormValues,
+ familyName: clientData.lastName,
+ givenName: clientData.firstName,
+ middleName: clientData.middleName,
+ gender: clientData.gender,
+ birthdate: clientData.dateOfBirth,
+ address: {
+ address2: clientData.subCounty,
+ },
+ });
+};
+
+export const useClientRegistryForm = (setInitialFormValues, initialFormValues) => {
+ const [clientRegistryData, setClientRegistryData] = useState<{
+ country: string;
+ identifierType: string;
+ patientIdentifier: string;
+ isSubmitting: boolean;
+ }>({ country: defaultSelectedCountry, identifierType: '', patientIdentifier: '', isSubmitting: false });
+
+ const handleOnChange = (data, key: 'country' | 'identifierType' | 'patientIdentifier') => {
+ setClientRegistryData({ ...clientRegistryData, [key]: data });
+ };
+
+ const handleClientRegistryDataSubmit = () => {
+ setClientRegistryData({ ...clientRegistryData, isSubmitting: true });
+ fetchPatientRecordFromClientRegistry(
+ clientRegistryData.patientIdentifier,
+ clientRegistryData.identifierType,
+ clientRegistryData.country,
+ ).then((res) => {
+ setClientRegistryData({ ...clientRegistryData, isSubmitting: false });
+ if (res.clientExists) {
+ const clientData = res.client;
+ const closeModal = showModal('client-registry-modal', {
+ clientData,
+ closeModal: () => {
+ closeModal();
+ handleSetFormValueFromClientRegistry(clientData, setInitialFormValues, initialFormValues);
+ },
+ });
+ } else {
+ const clientData = res.client;
+ }
+ });
+ };
+
+ return { handleClientRegistryDataSubmit, handleOnChange, clientRegistryData };
+};
diff --git a/packages/esm-patient-registration-app/src/patient-verification/verification-modal.scss b/packages/esm-patient-registration-app/src/patient-verification/verification-modal.scss
new file mode 100644
index 00000000..33765c81
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/verification-modal.scss
@@ -0,0 +1,20 @@
+@use '@carbon/type';
+@use '@carbon/colors';
+
+.cardContainer {
+ display: grid;
+ grid-template-columns: 0.5fr 1fr;
+ column-gap: 0.25rem;
+ margin: 0.5rem 0;
+}
+
+.label {
+ @include type.type-style('body-compact-02');
+ color: colors.$cool-gray-100;
+ font-weight: bold;
+}
+
+.value {
+ @include type.type-style('legal-01');
+ color: colors.$cool-gray-80;
+}
diff --git a/packages/esm-patient-registration-app/src/patient-verification/verification.component.tsx b/packages/esm-patient-registration-app/src/patient-verification/verification.component.tsx
new file mode 100644
index 00000000..6bf9ec7b
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/patient-verification/verification.component.tsx
@@ -0,0 +1,48 @@
+import React from 'react';
+import { ModalHeader, ModalBody, ModalFooter, Button } from '@carbon/react';
+import { useTranslation } from 'react-i18next';
+import styles from './verification-modal.scss';
+
+type VerificationModalProps = { clientData; closeModal };
+
+const VerificationModal: React.FC = ({ clientData, closeModal }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('clientRegistry', 'Client registry')}
+
+ id.identificationType).join(' ')}
+ />
+ id.identificationNumber).join(' ')}
+ />
+
+
+
+
+ {}}>
+ {t('contactSupport', 'Contact supportt')}
+
+
+ {t('continue', 'Continue')}
+
+
+
+ );
+};
+
+export default VerificationModal;
+
+export const Card: React.FC<{ label: string; value: string }> = ({ label, value }) => {
+ return (
+
+ {label}
+ {value}
+
+ );
+};
diff --git a/packages/esm-patient-registration-app/src/resource.ts b/packages/esm-patient-registration-app/src/resource.ts
new file mode 100644
index 00000000..c203cb90
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/resource.ts
@@ -0,0 +1,12 @@
+import { openmrsFetch } from '@openmrs/esm-framework';
+
+const AddressHierarchyBaseURL = '/module/addresshierarchy/ajax/getPossibleAddressHierarchyEntriesWithParents.form';
+
+export function performAdressHierarchyWithParentSearch(addressField, parentid, query) {
+ return openmrsFetch(
+ `${AddressHierarchyBaseURL}?addressField=${addressField}&limit=20&searchString=${query}&parentUuid=${parentid}`,
+ {
+ method: 'GET',
+ },
+ );
+}
diff --git a/packages/esm-patient-registration-app/src/root.component.tsx b/packages/esm-patient-registration-app/src/root.component.tsx
new file mode 100644
index 00000000..da6f1403
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/root.component.tsx
@@ -0,0 +1,63 @@
+import React, { useMemo } from 'react';
+import classNames from 'classnames';
+import useSWRImmutable from 'swr/immutable';
+import { BrowserRouter, Route, Routes } from 'react-router-dom';
+import { Grid, Row } from '@carbon/react';
+import { ExtensionSlot, useConnectivity, useSession } from '@openmrs/esm-framework';
+import {
+ ResourcesContext,
+ fetchAddressTemplate,
+ fetchAllRelationshipTypes,
+ fetchPatientIdentifierTypesWithSources,
+} from './offline.resources';
+import { FormManager } from './patient-registration/form-manager';
+import { PatientRegistration } from './patient-registration/patient-registration.component';
+import styles from './root.scss';
+
+export default function Root() {
+ const isOnline = useConnectivity();
+ const currentSession = useSession();
+ const { data: addressTemplate } = useSWRImmutable('patientRegistrationAddressTemplate', fetchAddressTemplate);
+ const { data: relationshipTypes } = useSWRImmutable(
+ 'patientRegistrationRelationshipTypes',
+ fetchAllRelationshipTypes,
+ );
+ const { data: identifierTypes } = useSWRImmutable(
+ 'patientRegistrationPatientIdentifiers',
+ fetchPatientIdentifierTypesWithSources,
+ );
+ const savePatientForm = useMemo(
+ () => (isOnline ? FormManager.savePatientFormOnline : FormManager.savePatientFormOffline),
+ [isOnline],
+ );
+
+ return (
+
+
+
+
+
+
+
+
+ }
+ />
+ }
+ />
+
+
+
+
+
+ );
+}
diff --git a/packages/esm-patient-registration-app/src/root.scss b/packages/esm-patient-registration-app/src/root.scss
new file mode 100644
index 00000000..76881b6e
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/root.scss
@@ -0,0 +1,7 @@
+.root {
+ background-color: white;
+}
+
+.grid {
+ grid-template-columns: repeat(1, minmax(0, 1fr));
+}
diff --git a/packages/esm-patient-registration-app/src/root.test.tsx b/packages/esm-patient-registration-app/src/root.test.tsx
new file mode 100644
index 00000000..789b0592
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/root.test.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+import { createRoot } from 'react-dom/client';
+import Root from './root.component';
+
+window['getOpenmrsSpaBase'] = jest.fn().mockImplementation(() => '/');
+
+jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+
+ return {
+ ...originalModule,
+ validator: jest.fn(),
+ };
+});
+
+describe('root component', () => {
+ it('renders without crashing', () => {
+ const div = document.createElement('div');
+ const root = createRoot(div);
+
+ root.render(
+ ,
+ );
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/routes.json b/packages/esm-patient-registration-app/src/routes.json
new file mode 100644
index 00000000..e483a908
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/routes.json
@@ -0,0 +1,66 @@
+{
+ "$schema": "https://json.openmrs.org/routes.schema.json",
+ "backendDependencies": {
+ "webservices.rest": "^2.24.0"
+ },
+ "pages": [
+ {
+ "component": "root",
+ "route": "patient-registration",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "editPatient",
+ "routeRegex": "patient\\/([a-zA-Z0-9\\-]+)\\/edit",
+ "online": true,
+ "offline": true
+ }
+ ],
+ "extensions": [
+ {
+ "component": "addPatientLink",
+ "name": "add-patient-action",
+ "slot": "top-nav-actions-slot",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "cancelPatientEditModal",
+ "name": "cancel-patient-edit-modal",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "patientPhoto",
+ "name": "patient-photo-widget",
+ "slot": "patient-photo-slot",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "editPatientDetailsButton",
+ "name": "edit-patient-details-button",
+ "slot": "patient-actions-slot",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "editPatientDetailsButton",
+ "name": "edit-patient-details-button",
+ "slot": "patient-search-actions-slot",
+ "online": true,
+ "offline": true
+ },
+ {
+ "component": "deleteIdentifierConfirmationModal",
+ "name": "delete-identifier-confirmation-modal",
+ "online": true,
+ "offline": true
+ },
+ {
+ "name": "client-registry-modal",
+ "component": "clientRegistryModal"
+ }
+ ]
+}
diff --git a/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.component.tsx b/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.component.tsx
new file mode 100644
index 00000000..e7d1b86b
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.component.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { Button } from '@carbon/react';
+import { useTranslation } from 'react-i18next';
+
+interface CancelPatientEditProps {
+ close(): void;
+ onConfirm(): void;
+}
+
+const CancelPatientEdit: React.FC = ({ close, onConfirm }) => {
+ const { t } = useTranslation();
+ return (
+ <>
+
+
{t('discardModalHeader', 'Confirm Discard Changes')}
+
+
+
+ {t(
+ 'discardModalBody',
+ "The changes you made to this patient's details have not been saved. Discard changes?",
+ )}
+
+
+
+
+ {t('cancel', 'Cancel')}
+
+
+ {t('discard', 'Discard')}
+
+
+ >
+ );
+};
+
+export default CancelPatientEdit;
diff --git a/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.test.tsx b/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.test.tsx
new file mode 100644
index 00000000..1e131515
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/cancel-patient-edit.test.tsx
@@ -0,0 +1,27 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { screen, render } from '@testing-library/react';
+import CancelPatientEdit from './cancel-patient-edit.component';
+
+describe('CancelPatientEdit component', () => {
+ const mockClose = jest.fn();
+ const mockOnConfirm = jest.fn();
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the modal and triggers close and onConfirm functions', async () => {
+ const user = userEvent.setup();
+
+ render( );
+
+ const cancelButton = screen.getByRole('button', { name: /Cancel/i });
+ await user.click(cancelButton);
+ expect(mockClose).toHaveBeenCalledTimes(1);
+
+ const discardButton = screen.getByRole('button', { name: /discard/i });
+ await user.click(discardButton);
+ expect(mockOnConfirm).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.test.tsx b/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.test.tsx
new file mode 100644
index 00000000..1b00d831
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.test.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import DeleteIdentifierConfirmationModal from './delete-identifier-confirmation-modal';
+
+describe('DeleteIdentifierConfirmationModal component', () => {
+ const mockDeleteIdentifier = jest.fn();
+ const mockIdentifierName = 'Identifier Name';
+ const mockIdentifierValue = 'Identifier Value';
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders the modal and triggers deleteIdentifier function', async () => {
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ await user.click(cancelButton);
+ expect(mockDeleteIdentifier).toHaveBeenCalledWith(false);
+
+ const removeButton = screen.getByRole('button', { name: /remove identifier/i });
+ await user.click(removeButton);
+ expect(mockDeleteIdentifier).toHaveBeenCalledWith(true);
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.tsx b/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.tsx
new file mode 100644
index 00000000..71b2f756
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/delete-identifier-confirmation-modal.tsx
@@ -0,0 +1,41 @@
+import React, { useCallback } from 'react';
+import styles from './delete-identifier-modal.scss';
+import { useTranslation } from 'react-i18next';
+import { Button } from '@carbon/react';
+
+interface DeleteIdentifierConfirmationModalProps {
+ deleteIdentifier: (x: boolean) => void;
+ identifierName: string;
+ identifierValue: string;
+}
+
+const DeleteIdentifierConfirmationModal: React.FC = ({
+ deleteIdentifier,
+ identifierName,
+ identifierValue,
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
{t('deleteIdentifierModalHeading', 'Remove identifier?')}
+
+ {identifierName}
+ {t('deleteIdentifierModalText', ' has a value of ')} {identifierValue}
+
+
+ {t('confirmIdentifierDeletionText', 'Are you sure you want to remove this identifier?')}
+
+
+ deleteIdentifier(false)}>
+ {t('cancel', 'Cancel')}
+
+ deleteIdentifier(true)}>
+ {t('removeIdentifierButton', 'Remove Identifier')}
+
+
+
+ );
+};
+
+export default DeleteIdentifierConfirmationModal;
diff --git a/packages/esm-patient-registration-app/src/widgets/delete-identifier-modal.scss b/packages/esm-patient-registration-app/src/widgets/delete-identifier-modal.scss
new file mode 100644
index 00000000..27555a62
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/delete-identifier-modal.scss
@@ -0,0 +1,34 @@
+@use '@carbon/styles/scss/spacing';
+@use '@carbon/styles/scss/type';
+@import '../patient-registration/patient-registration.scss';
+
+.productiveHeading {
+ @include type.type-style('heading-compact-02');
+}
+
+.modalContent {
+ width: 100%;
+ background-color: $ui-01;
+ padding: spacing.$spacing-05;
+}
+
+.modalSubtitle {
+ @include type.type-style('body-compact-01');
+}
+
+.modalBody {
+ @include type.type-style('body-compact-01');
+ margin: spacing.$spacing-05 0;
+}
+
+.buttonSet {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ margin-left: -(spacing.$spacing-05);
+ margin-right: -(spacing.$spacing-05);
+ margin-bottom: -(spacing.$spacing-05);
+}
+
+.buttonSet > button {
+ max-width: unset !important;
+}
diff --git a/packages/esm-patient-registration-app/src/widgets/display-photo.component.tsx b/packages/esm-patient-registration-app/src/widgets/display-photo.component.tsx
new file mode 100644
index 00000000..f8e2d97f
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/display-photo.component.tsx
@@ -0,0 +1,30 @@
+import React from 'react';
+import Avatar from 'react-avatar';
+import GeoPattern from 'geopattern';
+import { usePatientPhoto } from '../patient-registration/patient-registration.resource';
+
+interface DisplayPatientPhotoProps {
+ patientName: string;
+ patientUuid: string;
+ size?: string;
+}
+
+export default function DisplayPatientPhoto({ patientUuid, patientName, size }: DisplayPatientPhotoProps) {
+ const { data: photo } = usePatientPhoto(patientUuid);
+ const patternUrl: string = GeoPattern.generate(patientUuid).toDataUri();
+
+ return (
+
+ );
+}
diff --git a/packages/esm-patient-registration-app/src/widgets/display-photo.test.tsx b/packages/esm-patient-registration-app/src/widgets/display-photo.test.tsx
new file mode 100644
index 00000000..1afdfedd
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/display-photo.test.tsx
@@ -0,0 +1,37 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { mockPatient } from '__mocks__';
+import DisplayPatientPhoto from './display-photo.component';
+
+jest.mock('../patient-registration/patient-registration.resource', () => ({
+ usePatientPhoto: jest.fn().mockReturnValue({ data: { imageSrc: 'test-image-src' } }),
+}));
+
+jest.mock('geopattern', () => ({
+ generate: jest.fn().mockReturnValue({
+ toDataUri: jest.fn().mockReturnValue('https://example.com'),
+ }),
+}));
+
+const patientUuid = mockPatient.uuid;
+const patientName = mockPatient.name;
+
+describe('DisplayPatientPhoto Component', () => {
+ it('should render the component with the patient photo and size should not be small', () => {
+ render( );
+
+ const avatarImage = screen.getByTitle(`${patientName}`);
+
+ expect(avatarImage).toBeInTheDocument();
+ expect(avatarImage).toHaveAttribute('style', expect.stringContaining('width: 80px; height: 80px'));
+ });
+
+ it('should render the component with the patient photo and size should be small i.e. 48px', () => {
+ render( );
+
+ const avatarImage = screen.getByTitle(`${patientName}`);
+
+ expect(avatarImage).toBeInTheDocument();
+ expect(avatarImage).toHaveAttribute('style', expect.stringContaining('width: 48px; height: 48px'));
+ });
+});
diff --git a/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.component.tsx b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.component.tsx
new file mode 100644
index 00000000..c48341e3
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.component.tsx
@@ -0,0 +1,34 @@
+import React from 'react';
+import { navigate } from '@openmrs/esm-framework';
+import { useTranslation } from 'react-i18next';
+import styles from './edit-patient-details-button.scss';
+
+interface EditPatientDetailsButtonProps {
+ onTransition?: () => void;
+ patientUuid: string;
+}
+
+const EditPatientDetailsButton: React.FC = ({ patientUuid, onTransition }) => {
+ const { t } = useTranslation();
+ const handleClick = React.useCallback(() => {
+ navigate({ to: `\${openmrsSpaBase}/patient/${patientUuid}/edit` });
+ onTransition && onTransition();
+ }, [onTransition, patientUuid]);
+
+ return (
+
+
+
+ {t('editPatientDetails', 'Edit patient details')}
+
+
+
+ );
+};
+
+export default EditPatientDetailsButton;
diff --git a/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.scss b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.scss
new file mode 100644
index 00000000..d5960b4c
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.scss
@@ -0,0 +1,3 @@
+.link {
+ text-decoration: none;
+}
diff --git a/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.test.tsx b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.test.tsx
new file mode 100644
index 00000000..0b3e7542
--- /dev/null
+++ b/packages/esm-patient-registration-app/src/widgets/edit-patient-details-button.test.tsx
@@ -0,0 +1,41 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import { navigate } from '@openmrs/esm-framework';
+import { mockPatient } from '__mocks__';
+import EditPatientDetailsButton from './edit-patient-details-button.component';
+
+describe('EditPatientDetailsButton', () => {
+ const patientUuid = mockPatient.uuid;
+
+ it('should navigate to the edit page when clicked', async () => {
+ const user = userEvent.setup();
+ const mockNavigate = navigate as jest.Mock;
+
+ jest.mock('@openmrs/esm-framework', () => {
+ const originalModule = jest.requireActual('@openmrs/esm-framework');
+ return {
+ ...originalModule,
+ };
+ });
+
+ render( );
+
+ const button = screen.getByRole('menuitem');
+ await user.click(button);
+
+ expect(mockNavigate).toHaveBeenCalledWith({ to: expect.stringContaining(`/patient/${patientUuid}/edit`) });
+ });
+
+ it('should call the onTransition function when provided', async () => {
+ const user = userEvent.setup();
+
+ const onTransitionMock = jest.fn();
+ render( );
+
+ const button = screen.getByRole('menuitem');
+ await user.click(button);
+
+ expect(onTransitionMock).toHaveBeenCalled();
+ });
+});
diff --git a/packages/esm-patient-registration-app/translations/am.json b/packages/esm-patient-registration-app/translations/am.json
new file mode 100644
index 00000000..36a146f5
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/am.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "Add Relationship",
+ "addressHeader": "Address",
+ "allFieldsRequiredText": "All fields are required unless marked optional",
+ "autoGeneratedPlaceholderText": "Auto-generated",
+ "birthdayNotInTheFuture": "",
+ "birthdayRequired": "",
+ "birthFieldLabelText": "Birth",
+ "cancel": "Cancel",
+ "causeOfDeathInputLabel": "Cause of Death",
+ "closeOverlay": "Close overlay",
+ "codedPersonAttributeAnswerSetEmpty": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.",
+ "codedPersonAttributeAnswerSetInvalid": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.",
+ "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.",
+ "configure": "Configure",
+ "configureIdentifiers": "Configure identifiers",
+ "contactSection": "Contact Details",
+ "createNew": "Create New",
+ "dateOfBirthLabelText": "Date of Birth",
+ "deathDateInputLabel": "Date of Death",
+ "deathdayNotInTheFuture": "",
+ "deathSection": "Death Info",
+ "deleteIdentifierTooltip": "Delete",
+ "deleteRelationshipTooltipText": "Delete",
+ "demographicsSection": "Basic Info",
+ "discard": "Discard",
+ "discardModalBody": "The changes you made to this patient's details have not been saved. Discard changes?",
+ "discardModalHeader": "Confirm Discard Changes",
+ "dobToggleLabelText": "Date of Birth Known?",
+ "edit": "Edit",
+ "editIdentifierTooltip": "Edit",
+ "editPatientDetails": "Edit patient details",
+ "editPatientDetailsBreadcrumb": "Edit patient details",
+ "error": "Error",
+ "errorFetchingOrderedFields": "Error occured fetching ordered fields for address hierarchy",
+ "estimatedAgeInMonthsLabelText": "Estimated age in months",
+ "estimatedAgeInYearsLabelText": "Estimated age in years",
+ "familyNameLabelText": "Family Name",
+ "familyNameRequired": "",
+ "female": "Female",
+ "fullNameLabelText": "Full Name",
+ "genderLabelText": "Sex",
+ "genderRequired": "",
+ "genderUnspecified": "",
+ "givenNameLabelText": "First Name",
+ "givenNameRequired": "",
+ "identifierValueRequired": "Identifier value is required",
+ "idFieldLabelText": "Identifiers",
+ "IDInstructions": "Select the identifiers you'd like to add for this patient:",
+ "incompleteForm": "Incomplete form",
+ "invalidEmail": "",
+ "invalidInput": "Invalid Input",
+ "isDeadInputLabel": "Is Dead",
+ "jumpTo": "Jump to",
+ "male": "Male",
+ "middleNameLabelText": "Middle Name",
+ "negativeMonths": "",
+ "negativeYears": "",
+ "no": "No",
+ "numberInNameDubious": "",
+ "obsFieldUnknownDatatype": "Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'",
+ "optional": "optional",
+ "other": "Other",
+ "patient": "Patient",
+ "patientNameKnown": "Patient's Name is Known?",
+ "patientRegistrationBreadcrumb": "Patient Registration",
+ "registerPatient": "Register Patient",
+ "registerPatientSuccessSnackbarSubtitle": "The patient can now be found by searching for them using their name or ID number",
+ "registerPatientSuccessSnackbarTitle": "New Patient Created",
+ "registrationErrorSnackbarTitle": "Patient Registration Failed",
+ "relationship": "Relationship",
+ "relationshipPersonMustExist": "Related person must be an existing person",
+ "relationshipPlaceholder": "Relationship",
+ "relationshipRemovedText": "Relationship removed",
+ "relationshipsSection": "Relationships",
+ "relationshipToPatient": "Relationship to patient",
+ "relativeFullNameLabelText": "Related person",
+ "relativeNamePlaceholder": "Firstname Familyname",
+ "resetIdentifierTooltip": "Reset",
+ "restoreRelationshipActionButton": "Undo",
+ "searchAddress": "Search address",
+ "searchIdentifierPlaceholder": "Search identifier",
+ "selectAnOption": "Select an option",
+ "sexFieldLabelText": "Sex",
+ "source": "Source",
+ "stroke": "Stroke",
+ "submitting": "Submitting",
+ "unableToFetch": "Unable to fetch person attribute type - {{personattributetype}}",
+ "unknown": "Unknown",
+ "unknownPatientAttributeType": "Patient attribute type has unknown format {{personAttributeTypeFormat}}",
+ "updatePatient": "Update Patient",
+ "updatePatientErrorSnackbarTitle": "Patient Details Update Failed",
+ "updatePatientSuccessSnackbarSubtitle": "The patient's information has been successfully updated",
+ "updatePatientSuccessSnackbarTitle": "Patient Details Updated",
+ "yearsEstimateRequired": "",
+ "yes": "Yes"
+}
diff --git a/packages/esm-patient-registration-app/translations/ar.json b/packages/esm-patient-registration-app/translations/ar.json
new file mode 100644
index 00000000..257f0d28
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/ar.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "أضف علاقة",
+ "addressHeader": "العنوان",
+ "allFieldsRequiredText": "جميع الحقول مطلوبة ما لم يتم التأشير عليها بأنها اختيارية",
+ "autoGeneratedPlaceholderText": "تم إنشاؤه تلقائيًا",
+ "birthdayNotInTheFuture": "",
+ "birthdayRequired": "",
+ "birthFieldLabelText": "الميلاد",
+ "cancel": "إلغاء",
+ "causeOfDeathInputLabel": "سبب الوفاة",
+ "closeOverlay": "Close overlay",
+ "codedPersonAttributeAnswerSetEmpty": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.",
+ "codedPersonAttributeAnswerSetInvalid": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.",
+ "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.",
+ "configure": "تكوين",
+ "configureIdentifiers": "Configure identifiers",
+ "contactSection": "تفاصيل الاتصال",
+ "createNew": "أنشئ جديد",
+ "dateOfBirthLabelText": "تاريخ الميلاد",
+ "deathDateInputLabel": "تاريخ الوفاة",
+ "deathdayNotInTheFuture": "",
+ "deathSection": "معلومات الوفاة",
+ "deleteIdentifierTooltip": "حذف",
+ "deleteRelationshipTooltipText": "حذف",
+ "demographicsSection": "معلومات أساسية",
+ "discard": "تجاهل",
+ "discardModalBody": "لم يتم حفظ التغييرات التي قمت بها في تفاصيل هذا المريض. هل تريد تجاهل التغييرات؟",
+ "discardModalHeader": "تأكيد تجاهل التغييرات",
+ "dobToggleLabelText": "هل تاريخ الميلاد معروف؟",
+ "edit": "تعديل",
+ "editIdentifierTooltip": "تعديل",
+ "editPatientDetails": "تعديل تفاصيل المريض",
+ "editPatientDetailsBreadcrumb": "Edit patient details",
+ "error": "خطأ",
+ "errorFetchingOrderedFields": "حدث خطأ أثناء جلب الحقول المرتبة لتسلسل العنوان",
+ "estimatedAgeInMonthsLabelText": "العمر المقدر بالشهور",
+ "estimatedAgeInYearsLabelText": "العمر المقدر بالسنوات",
+ "familyNameLabelText": "اسم العائلة",
+ "familyNameRequired": "",
+ "female": "أنثى",
+ "fullNameLabelText": "الاسم الكامل",
+ "genderLabelText": "الجنس",
+ "genderRequired": "",
+ "genderUnspecified": "",
+ "givenNameLabelText": "الاسم الأول",
+ "givenNameRequired": "",
+ "identifierValueRequired": "قيمة المعرف مطلوبة",
+ "idFieldLabelText": "المعرفات",
+ "IDInstructions": "Select the identifiers you'd like to add for this patient:",
+ "incompleteForm": "نموذج غير مكتمل",
+ "invalidEmail": "",
+ "invalidInput": "إدخال غير صالح",
+ "isDeadInputLabel": "هل المريض متوفى؟",
+ "jumpTo": "اذهب إلى",
+ "male": "ذكر",
+ "middleNameLabelText": "الاسم الأوسط",
+ "negativeMonths": "",
+ "negativeYears": "",
+ "no": "لا",
+ "numberInNameDubious": "",
+ "obsFieldUnknownDatatype": "Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'",
+ "optional": "اختياري",
+ "other": "آخر",
+ "patient": "المريض",
+ "patientNameKnown": "هل اسم المريض معروف؟",
+ "patientRegistrationBreadcrumb": "Patient Registration",
+ "registerPatient": "تسجيل المريض",
+ "registerPatientSuccessSnackbarSubtitle": "The patient can now be found by searching for them using their name or ID number",
+ "registerPatientSuccessSnackbarTitle": "New Patient Created",
+ "registrationErrorSnackbarTitle": "Patient Registration Failed",
+ "relationship": "العلاقة",
+ "relationshipPersonMustExist": "Related person must be an existing person",
+ "relationshipPlaceholder": "العلاقة",
+ "relationshipRemovedText": "تمت إزالة العلاقة",
+ "relationshipsSection": "العلاقات",
+ "relationshipToPatient": "العلاقة بالمريض",
+ "relativeFullNameLabelText": "الاسم الكامل للقريب",
+ "relativeNamePlaceholder": "الاسم الأول اسم العائلة",
+ "resetIdentifierTooltip": "إعادة تعيين",
+ "restoreRelationshipActionButton": "تراجع",
+ "searchAddress": "ابحث عن العنوان",
+ "searchIdentifierPlaceholder": "Search identifier",
+ "selectAnOption": "اختر خيارًا",
+ "sexFieldLabelText": "الجنس",
+ "source": "Source",
+ "stroke": "جلطة",
+ "submitting": "Submitting",
+ "unableToFetch": "تعذر الجلب نوع السمة الشخصية - {{personattributetype}}",
+ "unknown": "غير معروف",
+ "unknownPatientAttributeType": "Patient attribute type has unknown format {{personAttributeTypeFormat}}",
+ "updatePatient": "تحديث المريض",
+ "updatePatientErrorSnackbarTitle": "Patient Details Update Failed",
+ "updatePatientSuccessSnackbarSubtitle": "The patient's information has been successfully updated",
+ "updatePatientSuccessSnackbarTitle": "Patient Details Updated",
+ "yearsEstimateRequired": "",
+ "yes": "نعم"
+}
diff --git a/packages/esm-patient-registration-app/translations/en.json b/packages/esm-patient-registration-app/translations/en.json
new file mode 100644
index 00000000..7c39cd94
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/en.json
@@ -0,0 +1,103 @@
+{
+ "addRelationshipButtonText": "Add Relationship",
+ "addressHeader": "Address",
+ "allFieldsRequiredText": "All fields are required unless marked optional",
+ "autoGeneratedPlaceholderText": "Auto-generated",
+ "birthdayNotInTheFuture": "Birthday cannot be in the future",
+ "birthdayRequired": "Birthday is required",
+ "birthFieldLabelText": "Birth",
+ "cancel": "Cancel",
+ "causeOfDeathInputLabel": "Cause of Death",
+ "clientRegistry": "Client registry",
+ "closeOverlay": "Close overlay",
+ "codedPersonAttributeAnswerSetEmpty": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.",
+ "codedPersonAttributeAnswerSetInvalid": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.",
+ "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.",
+ "configure": "Configure",
+ "configureIdentifiers": "Configure identifiers",
+ "contactSection": "Contact Details",
+ "contactSupport": "Contact supportt",
+ "createNew": "Create New",
+ "dateOfBirthLabelText": "Date of Birth",
+ "deathDateInputLabel": "Date of Death",
+ "deathdayNotInTheFuture": "Death day cannot be in the future",
+ "deathSection": "Death Info",
+ "deleteIdentifierTooltip": "Delete",
+ "deleteRelationshipTooltipText": "Delete",
+ "demographicsSection": "Basic Info",
+ "discard": "Discard",
+ "discardModalBody": "The changes you made to this patient's details have not been saved. Discard changes?",
+ "discardModalHeader": "Confirm Discard Changes",
+ "dobToggleLabelText": "Date of Birth Known?",
+ "edit": "Edit",
+ "editIdentifierTooltip": "Edit",
+ "editPatientDetails": "Edit patient details",
+ "editPatientDetailsBreadcrumb": "Edit patient details",
+ "error": "Error",
+ "errorFetchingOrderedFields": "Error occured fetching ordered fields for address hierarchy",
+ "estimatedAgeInMonthsLabelText": "Estimated age in months",
+ "estimatedAgeInYearsLabelText": "Estimated age in years",
+ "familyNameLabelText": "Family Name",
+ "familyNameRequired": "Family name is required",
+ "female": "Female",
+ "fullNameLabelText": "Full Name",
+ "gender": "Gender",
+ "genderLabelText": "Sex",
+ "genderRequired": "Gender is required",
+ "genderUnspecified": "Gender is not specified",
+ "givenNameLabelText": "First Name",
+ "givenNameRequired": "Given name is required",
+ "identifierValueRequired": "Identifier value is required",
+ "idFieldLabelText": "Identifiers",
+ "IDInstructions": "Select the identifiers you'd like to add for this patient:",
+ "incompleteForm": "Incomplete form",
+ "invalidEmail": "A valid email has to be given",
+ "invalidInput": "Invalid Input",
+ "isDeadInputLabel": "Is Dead",
+ "jumpTo": "Jump to",
+ "male": "Male",
+ "middleNameLabelText": "Middle Name",
+ "negativeMonths": "Negative months",
+ "negativeYears": "Negative years",
+ "no": "No",
+ "numberInNameDubious": "Number in name is dubious",
+ "obsFieldUnknownDatatype": "Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'",
+ "optional": "optional",
+ "other": "Other",
+ "patient": "Patient",
+ "patientNameKnown": "Patient's Name is Known?",
+ "patientRegistrationBreadcrumb": "Patient Registration",
+ "patientVerification": "Patient Verification",
+ "registerPatient": "Register Patient",
+ "registerPatientSuccessSnackbarSubtitle": "The patient can now be found by searching for them using their name or ID number",
+ "registerPatientSuccessSnackbarTitle": "New Patient Created",
+ "registrationErrorSnackbarTitle": "Patient Registration Failed",
+ "relationship": "Relationship",
+ "relationshipPersonMustExist": "Related person must be an existing person",
+ "relationshipPlaceholder": "Relationship",
+ "relationshipRemovedText": "Relationship removed",
+ "relationshipsSection": "Relationships",
+ "relationshipToPatient": "Relationship to patient",
+ "relativeFullNameLabelText": "Related person",
+ "relativeNamePlaceholder": "Firstname Familyname",
+ "resetIdentifierTooltip": "Reset",
+ "restoreRelationshipActionButton": "Undo",
+ "searchAddress": "Search address",
+ "searchIdentifierPlaceholder": "Search identifier",
+ "searchRegistry": "Search Registry",
+ "selectAnOption": "Select an option",
+ "sexFieldLabelText": "Sex",
+ "source": "Source",
+ "stroke": "Stroke",
+ "submitting": "Submitting",
+ "unableToFetch": "Unable to fetch person attribute type {{personattributetype}}",
+ "unknown": "Unknown",
+ "unknownPatientAttributeType": "Patient attribute type has unknown format {{personAttributeTypeFormat}}",
+ "updatePatient": "Update Patient",
+ "updatePatientErrorSnackbarTitle": "Patient Details Update Failed",
+ "updatePatientSuccessSnackbarSubtitle": "The patient's information has been successfully updated",
+ "updatePatientSuccessSnackbarTitle": "Patient Details Updated",
+ "useValues": "Use Values",
+ "yearsEstimateRequired": "Years estimate required",
+ "yes": "Yes"
+}
diff --git a/packages/esm-patient-registration-app/translations/es.json b/packages/esm-patient-registration-app/translations/es.json
new file mode 100644
index 00000000..4993f873
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/es.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "Agregar relación",
+ "addressHeader": "Dirección",
+ "allFieldsRequiredText": "Todos los campos son obligatorios a menos que se indique como opcionales",
+ "autoGeneratedPlaceholderText": "Autogenerado",
+ "birthdayNotInTheFuture": "Cumpleaños no puede ser en el futuro",
+ "birthdayRequired": "Cumpleaños es obligatorio",
+ "birthFieldLabelText": "Nacimiento",
+ "cancel": "Cancelar",
+ "causeOfDeathInputLabel": "Causa de defunción",
+ "closeOverlay": "Cerrar superposición",
+ "codedPersonAttributeAnswerSetEmpty": "El campo de atributo de persona codificado '{{codedPersonAttributeFieldId}}' se ha definido con un conjunto de conceptos de respuesta UUID '{{answerConceptSetUuid}}' que no tiene respuestas de concepto.",
+ "codedPersonAttributeAnswerSetInvalid": "El campo de atributo de persona codificado '{{codedPersonAttributeFieldId}}' se ha definido con un UUID de conjunto de conceptos de respuesta no válido '{{answerConceptSetUuid}}'.",
+ "codedPersonAttributeNoAnswerSet": "El campo de atributo de persona '{{codedPersonAttributeFieldId}}' es de tipo 'codificado' pero se ha definido sin UUID de conjunto de conceptos de respuesta. La clave 'answerConceptSetUuid' es requerida.",
+ "configure": "Configurar",
+ "configureIdentifiers": "Configurar identificadores",
+ "contactSection": "Detalles de Contacto",
+ "createNew": "Crear Nuevo/a",
+ "dateOfBirthLabelText": "Fecha de Nacimiento",
+ "deathDateInputLabel": "Fecha de fallecimiento",
+ "deathdayNotInTheFuture": "El día de la muerte no puede ser en el futuro",
+ "deathSection": "Información de Fallecimiento",
+ "deleteIdentifierTooltip": "Eliminar",
+ "deleteRelationshipTooltipText": "Eliminar",
+ "demographicsSection": "Información Básica",
+ "discard": "Descartar",
+ "discardModalBody": "Los cambios realizados en los datos de este paciente no se han guardado. ¿Descartar cambios?",
+ "discardModalHeader": "Confirmar descarte de cambios",
+ "dobToggleLabelText": "¿Se conoce la fecha de nacimiento?",
+ "edit": "Editar",
+ "editIdentifierTooltip": "Editar",
+ "editPatientDetails": "Modificar los datos del paciente",
+ "editPatientDetailsBreadcrumb": "Editar detalles del paciente",
+ "error": "Error",
+ "errorFetchingOrderedFields": "Ocurrió un error al obtener campos ordenados para la jerarquía de direcciones",
+ "estimatedAgeInMonthsLabelText": "Edad estimada en meses",
+ "estimatedAgeInYearsLabelText": "Edad estimada en años",
+ "familyNameLabelText": "Apellidos",
+ "familyNameRequired": "Apellidos es obligatorio",
+ "female": "Femenino",
+ "fullNameLabelText": "Nombre y Apellidos",
+ "genderLabelText": "Sexo",
+ "genderRequired": "El sexo es obligatorio",
+ "genderUnspecified": "Género no especificado",
+ "givenNameLabelText": "Nombre",
+ "givenNameRequired": "El nombre es obligatorio",
+ "identifierValueRequired": "El valor del identificador es obligatorio",
+ "idFieldLabelText": "Identificadores",
+ "IDInstructions": "Selecciona los identificadores que te gustaría agregar para este paciente:",
+ "incompleteForm": "Formulario incompleto",
+ "invalidEmail": "Debe indicar un email válido",
+ "invalidInput": "Entrada no válida",
+ "isDeadInputLabel": "Está muerto",
+ "jumpTo": "Ir a",
+ "male": "Masculino",
+ "middleNameLabelText": "Segundo Nombre",
+ "negativeMonths": "Meses negativos",
+ "negativeYears": "Años negativos",
+ "no": "No",
+ "numberInNameDubious": "Número en nombre es dudoso",
+ "obsFieldUnknownDatatype": "El concepto para el campo de observación '{{fieldDefinitionId}}' tiene un tipo de datos desconocido '{{datatypeName}}'",
+ "optional": "Opcional",
+ "other": "Otro",
+ "patient": "Paciente",
+ "patientNameKnown": "¿Se sabe el nombre del paciente?",
+ "patientRegistrationBreadcrumb": "Registro de Pacientes",
+ "registerPatient": "Registrar paciente",
+ "registerPatientSuccessSnackbarSubtitle": "El paciente ahora se puede encontrar buscándolo por su nombre o número de identificación",
+ "registerPatientSuccessSnackbarTitle": "Nuevo paciente creado",
+ "registrationErrorSnackbarTitle": "Error en el registro del paciente",
+ "relationship": "Relación",
+ "relationshipPersonMustExist": "La persona relacionada debe ser una persona existente",
+ "relationshipPlaceholder": "Relación",
+ "relationshipRemovedText": "Relación eliminada",
+ "relationshipsSection": "Relaciones",
+ "relationshipToPatient": "Relación con el paciente",
+ "relativeFullNameLabelText": "Nombre y Apellidos",
+ "relativeNamePlaceholder": "Nombre Apellido",
+ "resetIdentifierTooltip": "Resetear",
+ "restoreRelationshipActionButton": "Deshacer",
+ "searchAddress": "Buscar dirección",
+ "searchIdentifierPlaceholder": "Buscar identificador",
+ "selectAnOption": "Seleccionar una opción",
+ "sexFieldLabelText": "Sexo",
+ "source": "Fuente",
+ "stroke": "Ictus",
+ "submitting": "Enviando",
+ "unableToFetch": "No se puede obtener el tipo de atributo de persona {{personattributetype}}",
+ "unknown": "Desconocido",
+ "unknownPatientAttributeType": "El tipo de atributo del paciente tiene un formato desconocido {{personAttributeTypeFormat}}",
+ "updatePatient": "Paciente Actualizado",
+ "updatePatientErrorSnackbarTitle": "Error al actualizar los detalles del paciente",
+ "updatePatientSuccessSnackbarSubtitle": "La información del paciente se ha actualizado correctamente",
+ "updatePatientSuccessSnackbarTitle": "Detalles del paciente actualizados",
+ "yearsEstimateRequired": "Estimación de años obligatoria",
+ "yes": "Sí"
+}
diff --git a/packages/esm-patient-registration-app/translations/fr.json b/packages/esm-patient-registration-app/translations/fr.json
new file mode 100644
index 00000000..d539c425
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/fr.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "Ajouter un lien de parenté",
+ "addressHeader": "Adresse",
+ "allFieldsRequiredText": "Tous les champs sont requis sauf si explicitement indiqués facultatifs",
+ "autoGeneratedPlaceholderText": "Auto-généré",
+ "birthdayNotInTheFuture": "La date de naissance ne peut pas être dans le futur",
+ "birthdayRequired": "La date de naissance est requise",
+ "birthFieldLabelText": "Naissance",
+ "cancel": "Annuler",
+ "causeOfDeathInputLabel": "Cause de décès",
+ "closeOverlay": "Fermer la superposition",
+ "codedPersonAttributeAnswerSetEmpty": "Le champ d'attribut de personne codé '{{codedPersonAttributeFieldId}}' a été défini avec un ensemble de concepts de réponse UUID '{{answerConceptSetUuid}}' qui n'a pas de réponses de concept.",
+ "codedPersonAttributeAnswerSetInvalid": "Le champ d'attribut de personne codé '{{codedPersonAttributeFieldId}}' a été défini avec un UUID d'ensemble de concepts de réponse invalide '{{answerConceptSetUuid}}'.",
+ "codedPersonAttributeNoAnswerSet": "Le champ d'attribut de personne '{{codedPersonAttributeFieldId}}' est de type 'codé' mais a été défini sans UUID d'ensemble de concepts de réponse. La clé 'answerConceptSetUuid' est requise.",
+ "configure": "Configurer",
+ "configureIdentifiers": "Configurer les identifiants",
+ "contactSection": "Détails de contact",
+ "createNew": "Créer un nouveau",
+ "dateOfBirthLabelText": "Date de naissance",
+ "deathDateInputLabel": "Date de décès",
+ "deathdayNotInTheFuture": "Le décès ne peut pas être dans le futur",
+ "deathSection": "Informations sur le décès",
+ "deleteIdentifierTooltip": "Supprimer",
+ "deleteRelationshipTooltipText": "Supprimer",
+ "demographicsSection": "Informations de base",
+ "discard": "Abandonner",
+ "discardModalBody": "Les modifications que vous avez apportées aux données de ce patient n'ont pas été enregistrées. Annuler les modifications?",
+ "discardModalHeader": "Confirmer l'abandon des modifications",
+ "dobToggleLabelText": "Date de naissance connue?",
+ "edit": "Éditer",
+ "editIdentifierTooltip": "Éditer",
+ "editPatientDetails": "Modifier les détails du patient",
+ "editPatientDetailsBreadcrumb": "Modifier les détails du patient",
+ "error": "Erreur",
+ "errorFetchingOrderedFields": "Une erreur s'est produite lors de la récupération des champs ordonnés pour la hiérarchie d'adresse",
+ "estimatedAgeInMonthsLabelText": "Âge estimé en mois",
+ "estimatedAgeInYearsLabelText": "Âge estimé en années",
+ "familyNameLabelText": "Nom de famille",
+ "familyNameRequired": "Le nom de famille est requis",
+ "female": "Femme",
+ "fullNameLabelText": "Nom et prénom",
+ "genderLabelText": "Sexe",
+ "genderRequired": "Le genre est requis",
+ "genderUnspecified": "Le genre n'est pas spécifié",
+ "givenNameLabelText": "Prénom",
+ "givenNameRequired": "Le prénom est requis",
+ "identifierValueRequired": "La valeur de l'identifiant est requise",
+ "idFieldLabelText": "Identifiants",
+ "IDInstructions": "Sélectionnez les identifiants que vous souhaitez ajouter pour ce patient:",
+ "incompleteForm": "Formulaire incomplet",
+ "invalidEmail": "Une adresse e-mail valide est requise",
+ "invalidInput": "Entrée invalide",
+ "isDeadInputLabel": "Est décédé",
+ "jumpTo": "Aller à",
+ "male": "Homme",
+ "middleNameLabelText": "Deuxième prénom",
+ "negativeMonths": "Mois négatifs",
+ "negativeYears": "Années négatives",
+ "no": "Non",
+ "numberInNameDubious": "Le chiffre dans le nom est suspect",
+ "obsFieldUnknownDatatype": "Le concept pour le champ d'observation '{{fieldDefinitionId}}' a un type de données inconnu '{{datatypeName}}'",
+ "optional": "Optionnel",
+ "other": "Autre",
+ "patient": "Patient",
+ "patientNameKnown": "Le nom du patient est connu?",
+ "patientRegistrationBreadcrumb": "Enregistrement du patient",
+ "registerPatient": "Enregistrer un patient",
+ "registerPatientSuccessSnackbarSubtitle": "Le patient peut maintenant être trouvé en le recherchant par son nom ou son numéro d'identification",
+ "registerPatientSuccessSnackbarTitle": "Nouveau patient créé",
+ "registrationErrorSnackbarTitle": "Échec de l'enregistrement du patient",
+ "relationship": "Relation",
+ "relationshipPersonMustExist": "La personne liée doit être une personne existante",
+ "relationshipPlaceholder": "Relation",
+ "relationshipRemovedText": "Relation supprimée",
+ "relationshipsSection": "Relations",
+ "relationshipToPatient": "Relation avec le patient",
+ "relativeFullNameLabelText": "Personne liée",
+ "relativeNamePlaceholder": "Prénom Nom de famille",
+ "resetIdentifierTooltip": "Réinitialiser",
+ "restoreRelationshipActionButton": "Restaurer",
+ "searchAddress": "Rechercher une adresse",
+ "searchIdentifierPlaceholder": "Rechercher un identifiant",
+ "selectAnOption": "Sélectionner une option",
+ "sexFieldLabelText": "Sexe",
+ "source": "Source",
+ "stroke": "Accident vasculaire cérébral",
+ "submitting": "En cours de soumission",
+ "unableToFetch": "Impossible de récupérer le type d'attribut de personne - {{personattributetype}}",
+ "unknown": "Inconnu",
+ "unknownPatientAttributeType": "Le type d'attribut de patient a un format inconnu {{personAttributeTypeFormat}}",
+ "updatePatient": "Mettre à jour le patient",
+ "updatePatientErrorSnackbarTitle": "Échec de la mise à jour des détails du patient",
+ "updatePatientSuccessSnackbarSubtitle": "Les informations du patient ont été mises à jour avec succès",
+ "updatePatientSuccessSnackbarTitle": "Détails du patient mis à jour",
+ "yearsEstimateRequired": "Estimation du nombre d'années requise",
+ "yes": "Oui"
+}
diff --git a/packages/esm-patient-registration-app/translations/he.json b/packages/esm-patient-registration-app/translations/he.json
new file mode 100644
index 00000000..bceadb81
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/he.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "הוסף יחס",
+ "addressHeader": "כתובת",
+ "allFieldsRequiredText": "כל השדות נדרשים אלא אם צוין אחרת",
+ "autoGeneratedPlaceholderText": "נוצר אוטומטית",
+ "birthdayNotInTheFuture": "תאריך הלידה לא יכול להיות בעתיד",
+ "birthdayRequired": "תאריך הלידה נדרש",
+ "birthFieldLabelText": "לידה",
+ "cancel": "ביטול",
+ "causeOfDeathInputLabel": "סיבת המוות",
+ "closeOverlay": "סגור חיפוש",
+ "codedPersonAttributeAnswerSetEmpty": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.",
+ "codedPersonAttributeAnswerSetInvalid": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.",
+ "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.",
+ "configure": "הגדר",
+ "configureIdentifiers": "הגדר זיהויים",
+ "contactSection": "פרטי יצירת קשר",
+ "createNew": "צור חדש",
+ "dateOfBirthLabelText": "תאריך לידה",
+ "deathDateInputLabel": "תאריך המוות",
+ "deathdayNotInTheFuture": "תאריך המוות לא יכול להיות בעתיד",
+ "deathSection": "מידע על המוות",
+ "deleteIdentifierTooltip": "מחק",
+ "deleteRelationshipTooltipText": "מחק",
+ "demographicsSection": "מידע בסיסי",
+ "discard": "התעלם",
+ "discardModalBody": "השינויים שביצעת בפרטי המטופל לא נשמרו. האם להתעלם מהשינויים?",
+ "discardModalHeader": "אשר התעלמות מהשינויים",
+ "dobToggleLabelText": "תאריך הלידה ידוע?",
+ "edit": "עריכה",
+ "editIdentifierTooltip": "ערוך",
+ "editPatientDetails": "ערוך פרטי מטופל",
+ "editPatientDetailsBreadcrumb": "ערוך פרטי מטופל",
+ "error": "שגיאה",
+ "errorFetchingOrderedFields": "שגיאה בעת קבלת השדות המסודרים להירכבות הכתובת",
+ "estimatedAgeInMonthsLabelText": "גיל משוער בחודשים",
+ "estimatedAgeInYearsLabelText": "גיל משוער בשנים",
+ "familyNameLabelText": "שם משפחה",
+ "familyNameRequired": "שם משפחה נדרש",
+ "female": "נקבה",
+ "fullNameLabelText": "שם מלא",
+ "genderLabelText": "מין",
+ "genderRequired": "מין נדרש",
+ "genderUnspecified": "מין לא מוגדר",
+ "givenNameLabelText": "שם פרטי",
+ "givenNameRequired": "שם פרטי נדרש",
+ "identifierValueRequired": "ערך זיהוי נדרש",
+ "idFieldLabelText": "זיהויים",
+ "IDInstructions": "בחר את הזיהויים שתרצה להוסיף למטופל זה:",
+ "incompleteForm": "טופס לא מלא",
+ "invalidEmail": "יש לספק כתובת אימייל חוקית",
+ "invalidInput": "קלט לא חוקי",
+ "isDeadInputLabel": "מת?",
+ "jumpTo": "קפיצה ל",
+ "male": "זכר",
+ "middleNameLabelText": "שם תוכני",
+ "negativeMonths": "חודשים שליליים",
+ "negativeYears": "שנים שליליות",
+ "no": "לא",
+ "numberInNameDubious": "מספר בשם חשוד",
+ "obsFieldUnknownDatatype": "Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'",
+ "optional": "אופציונלי",
+ "other": "אחר",
+ "patient": "מטופל",
+ "patientNameKnown": "שם המטופל ידוע?",
+ "patientRegistrationBreadcrumb": "רישום מטופל",
+ "registerPatient": "רשום מטופל",
+ "registerPatientSuccessSnackbarSubtitle": "The patient can now be found by searching for them using their name or ID number",
+ "registerPatientSuccessSnackbarTitle": "New Patient Created",
+ "registrationErrorSnackbarTitle": "Patient Registration Failed",
+ "relationship": "קשר",
+ "relationshipPersonMustExist": "האדם הקשור חייב להיות אדם קיים",
+ "relationshipPlaceholder": "קשר",
+ "relationshipRemovedText": "קשר הוסר",
+ "relationshipsSection": "קשרים",
+ "relationshipToPatient": "קשר למטופל",
+ "relativeFullNameLabelText": "שם מלא",
+ "relativeNamePlaceholder": "שם פרטי שם משפחה",
+ "resetIdentifierTooltip": "איפוס",
+ "restoreRelationshipActionButton": "ביטול",
+ "searchAddress": "חיפוש כתובת",
+ "searchIdentifierPlaceholder": "חיפוש זיהוי",
+ "selectAnOption": "בחר אפשרות",
+ "sexFieldLabelText": "מין",
+ "source": "מקור",
+ "stroke": "שבץ",
+ "submitting": "Submitting",
+ "unableToFetch": "אין אפשרות לאחזר סוג מאפיין אדם - {{personattributetype}}",
+ "unknown": "לא ידוע",
+ "unknownPatientAttributeType": "סוג המאפיין של המטופל לא ידוע {{personAttributeTypeFormat}}",
+ "updatePatient": "עדכון פרטי מטופל",
+ "updatePatientErrorSnackbarTitle": "Patient Details Update Failed",
+ "updatePatientSuccessSnackbarSubtitle": "The patient's information has been successfully updated",
+ "updatePatientSuccessSnackbarTitle": "Patient Details Updated",
+ "yearsEstimateRequired": "נדרש חיזוי שנים",
+ "yes": "כן"
+}
diff --git a/packages/esm-patient-registration-app/translations/km.json b/packages/esm-patient-registration-app/translations/km.json
new file mode 100644
index 00000000..2a445615
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/km.json
@@ -0,0 +1,97 @@
+{
+ "addRelationshipButtonText": "បន្ថែមទំនាក់ទំនង",
+ "addressHeader": "អាសយដ្ឋាន",
+ "allFieldsRequiredText": "តម្រូវឱ្យបំពេញគ្រប់កន្លែងចំហរទាំងអស់ លុះត្រាតែមានសម្គាល់ថាជាជម្រើស",
+ "autoGeneratedPlaceholderText": "ទាញចេញទិន្នន័យដោយស្វ័យប្រវត្តិ",
+ "birthdayNotInTheFuture": "ថ្ងៃខែឆ្នាំកំណើតមិនអាចមាននាពេលអនាគតទេ",
+ "birthdayRequired": "តម្រូវឱ្យបំពេញថ្ងៃខែឆ្នាំកំណើត",
+ "birthFieldLabelText": "ថ្ងៃខែឆ្នាំកំណើត",
+ "cancel": "បោះបង់ចោល",
+ "causeOfDeathInputLabel": "មូលហេតុនៃការស្លាប់",
+ "closeOverlay": "បិទការត្រួតលើគ្នា។",
+ "codedPersonAttributeAnswerSetEmpty": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an answer concept set UUID '{{answerConceptSetUuid}}' that does not have any concept answers.",
+ "codedPersonAttributeAnswerSetInvalid": "The coded person attribute field '{{codedPersonAttributeFieldId}}' has been defined with an invalid answer concept set UUID '{{answerConceptSetUuid}}'.",
+ "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.",
+ "configure": "កំណត់រចនាសម្ព័ន្ធ",
+ "configureIdentifiers": "ឯកសារកំណត់អត្តសញ្ញាណផ្សេងទៀត",
+ "contactSection": "ព័ត៌មានមរណភាព",
+ "createNew": "បង្កើតថ្មី",
+ "dateOfBirthLabelText": "ថ្ងៃខែឆ្នាំកំណើត",
+ "deathDateInputLabel": "កាលបរិច្ឆេទនៃការស្លាប់",
+ "deathdayNotInTheFuture": "ថ្ងៃស្លាប់មិនអាចមាននាពេលអនាគតទេ។",
+ "deathSection": "ព័ត៌មានមរណភាព",
+ "deleteIdentifierTooltip": "លុប",
+ "deleteRelationshipTooltipText": "លុប",
+ "demographicsSection": "ព័ត៌មានផ្ទាល់ខ្លួនអ្នកជំងឺ",
+ "discard": "បោះបង់",
+ "discardModalBody": "ការផ្លាស់ប្តូរដែលអ្នកបានធ្វើចំពោះព័ត៌មានលម្អិតរបស់អ្នកជំងឺនេះមិនត្រូវបានរក្សាទុកទេ។ បោះបង់ការផ្លាស់ប្តូរ?",
+ "discardModalHeader": "បញ្ជាក់ការផ្លាស់ប្តូរការបោះបង់",
+ "dobToggleLabelText": "ស្គាល់ថ្ងៃខែឆ្នាំកំណើត?",
+ "edit": "កែសម្រួល",
+ "editIdentifierTooltip": "កែសម្រួល",
+ "editPatientDetails": "កែសម្រួលព័ត៌មានលម្អិតអ្នកជំងឺ",
+ "editPatientDetailsBreadcrumb": "កែសម្រួលព័ត៌មានលម្អិតអ្នកជំងឺ",
+ "error": "មានបញ្ហាបច្ចេកទេស",
+ "errorFetchingOrderedFields": "មានបញ្ហាបច្ចេកទេសក្នុងការទាញទិន្នន័យតាមឋានានុក្រមអាសយដ្ឋាន",
+ "estimatedAgeInMonthsLabelText": "អាយុប៉ាន់ស្មានគិតជាខែ",
+ "estimatedAgeInYearsLabelText": "អាយុប៉ាន់ស្មានគិតជាឆ្នាំ",
+ "familyNameLabelText": "នាមត្រកូល",
+ "familyNameRequired": "តម្រូវឱ្យបំពេញនាមត្រកូល",
+ "female": "ស្រី",
+ "fullNameLabelText": "ឈ្មោះពេញ",
+ "genderLabelText": "ភេទ",
+ "genderRequired": "តម្រូវឱ្យបំពេញភេទ",
+ "genderUnspecified": "ភេទមិនត្រូវបានបញ្ជាក់ទេ",
+ "givenNameLabelText": "នាមខ្លួន",
+ "givenNameRequired": "តម្រូវឱ្យបំពេញឈ្មោះ",
+ "identifierValueRequired": "តម្រូវឱ្យមានកំណត់អត្តសញ្ញាណ",
+ "idFieldLabelText": "អ្នកកំណត់អត្តសញ្ញាណ",
+ "IDInstructions": "ជ្រើសរើសអត្តសញ្ញាណដែលអ្នកចង់បន្ថែមសម្រាប់អ្នកជំងឺនេះ៖",
+ "incompleteForm": "ទម្រង់មិនពេញលេញ",
+ "invalidEmail": "ត្រូវតែផ្តល់ឱ្យអ៊ីមែលដែលមានសុពលភាព",
+ "invalidInput": "ការបញ្ចូលមិនត្រឹមត្រូវ",
+ "isDeadInputLabel": "គឺស្លាប់",
+ "jumpTo": "រំលងទៅ",
+ "male": "ប្រុស",
+ "middleNameLabelText": "លេខជាឈ្មោះដែលសង្ស័យ",
+ "negativeMonths": "ខែអវិជ្ជមាន",
+ "negativeYears": "ឆ្នាំអវិជ្ជមាន",
+ "no": "ទេ",
+ "numberInNameDubious": "លេខគឺជាឈ្មោះគួរឱ្យសង្ស័យ",
+ "obsFieldUnknownDatatype": "Concept for obs field '{{fieldDefinitionId}}' has unknown datatype '{{datatypeName}}'",
+ "optional": "ជាជម្រើស",
+ "other": "ផ្សេងៗ",
+ "patient": "អ្នកជំងឺ",
+ "patientNameKnown": "ស្គាល់ឈ្មោះអ្នកជំងឺឬទេ?",
+ "patientRegistrationBreadcrumb": "ការចុះឈ្មោះអ្នកជំងឺ",
+ "registerPatient": "ចុះឈ្មោះអ្នកជំងឺ",
+ "registerPatientSuccessSnackbarSubtitle": "The patient can now be found by searching for them using their name or ID number",
+ "registerPatientSuccessSnackbarTitle": "New Patient Created",
+ "registrationErrorSnackbarTitle": "Patient Registration Failed",
+ "relationship": "ទំនាក់ទំនង",
+ "relationshipPersonMustExist": "Related person must be an existing person",
+ "relationshipPlaceholder": "ទំនាក់ទំនង",
+ "relationshipRemovedText": "ការទំនាក់ទំនងត្រូវបានដកចេញ",
+ "relationshipsSection": "ទំនាក់ទំនង",
+ "relationshipToPatient": "ការទាក់ទងទៅនឹងអ្នកជំងឺ",
+ "relativeFullNameLabelText": "ឈ្មោះពេញ",
+ "relativeNamePlaceholder": "នាមត្រកូលនាមខ្លួន",
+ "resetIdentifierTooltip": "រៀបចំឡើងវិញ",
+ "restoreRelationshipActionButton": "វិលត្រឡប់មកដើមវិញ",
+ "searchAddress": "ស្វែងរកអាសយដ្ឋាន",
+ "searchIdentifierPlaceholder": "ស្វែងរកអត្តសញ្ញាណ",
+ "selectAnOption": "យកជម្រើសមួយ",
+ "sexFieldLabelText": "ភេទ",
+ "source": "ប្រភព",
+ "stroke": "ជំងឺស្ទះសរសៃឈាមខួរក្បាល",
+ "submitting": "Submitting",
+ "unableToFetch": "មិនអាចទាញយកប្រភេទគុណលក្ខណៈតាមអ្នកជំងឺបានទេ - {{personattributetype}}",
+ "unknown": "មិនដឹង",
+ "unknownPatientAttributeType": "ប្រភេទនៃគុណលក្ខណៈរបស់អ្នកជំងឺគឺមិនស្គាល់។ {{personAttributeTypeFormat}}",
+ "updatePatient": "ធ្វើបច្ចុប្បន្នភាពអ្នកជំងឺ",
+ "updatePatientErrorSnackbarTitle": "Patient Details Update Failed",
+ "updatePatientSuccessSnackbarSubtitle": "The patient's information has been successfully updated",
+ "updatePatientSuccessSnackbarTitle": "Patient Details Updated",
+ "yearsEstimateRequired": "តម្រូវឱ្យមានការប៉ាន់ស្មានឆ្នាំ",
+ "yes": "បាទ/ចាស"
+}
diff --git a/packages/esm-patient-registration-app/translations/zh.json b/packages/esm-patient-registration-app/translations/zh.json
new file mode 100644
index 00000000..b39646ba
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/zh.json
@@ -0,0 +1,89 @@
+{
+ "addRelationshipButtonText": "添加关系",
+ "address1": "地址行1",
+ "address2": "地址行2",
+ "addressHeader": "地址",
+ "allFieldsRequiredText": "所有字段都是必填的,除非标记为可选。",
+ "autoGeneratedPlaceholderText": "自动生成",
+ "birthdayNotInTheFuture": "生日不能是未来的日期",
+ "birthdayRequired": "生日是必填项",
+ "birthFieldLabelText": "出生",
+ "cancel": "取消",
+ "causeOfDeathInputLabel": "死因",
+ "cityVillage": "城市",
+ "configure": "配置",
+ "country": "国家",
+ "countyDistrict": "区县",
+ "createNew": "新建",
+ "dateOfBirthLabelText": "出生日期",
+ "deathDateInputLabel": "死亡日期",
+ "deathdayNotInTheFuture": "死亡日期不能是未来的日期",
+ "deleteIdentifierTooltip": "删除",
+ "deleteRelationshipTooltipText": "删除",
+ "discard": "放弃",
+ "discardModalBody": "您对该患者的详细信息所做的更改尚未保存。放弃更改吗?",
+ "discardModalHeader": "确认放弃更改",
+ "dobToggleLabelText": "出生日期已知?",
+ "edit": "编辑",
+ "editIdentifierTooltip": "编辑",
+ "editPatientDetails": "编辑患者详情",
+ "emailLabelText": "电子邮件",
+ "estimatedAgeInMonthsLabelText": "月龄估算",
+ "estimatedAgeInYearsLabelText": "年龄估算",
+ "familyNameLabelText": "姓氏",
+ "familyNameRequired": "姓氏是必填项",
+ "female": "女性",
+ "fieldErrorTitleMessage": "以下字段存在错误:",
+ "fullNameLabelText": "全名",
+ "genderLabelText": "性别",
+ "genderRequired": "性别是必填项",
+ "genderUnspecified": "性别未指定",
+ "givenNameLabelText": "名字",
+ "givenNameRequired": "名字是必填项",
+ "identifierValueRequired": "ID标识是必填项",
+ "idFieldLabelText": "ID标识",
+ "incompleteForm": "表单未填完",
+ "invalidEmail": "需要提供一个有效的电子邮件地址",
+ "invalidInput": "输入无效",
+ "isDeadInputLabel": "已故",
+ "jumpTo": "跳转至",
+ "loadingResults": "正在加载结果",
+ "male": "男性",
+ "middleNameLabelText": "中间名",
+ "months": "月",
+ "negativeMonths": "负的月份",
+ "negativeYears": "负的年份",
+ "no": "否",
+ "noResultsFound": "未找到结果",
+ "numberInNameDubious": "姓名中含有数字",
+ "optional": "可选的",
+ "other": "其他",
+ "patient": "患者",
+ "patientNameKnown": "患者的姓名已知?",
+ "phoneEmailLabelText": "电话、电子邮件等。",
+ "phoneNumberInputLabelText": "电话号码",
+ "postalCode": "邮政编码",
+ "registerPatient": "注册患者",
+ "registrationSuccessToastDescription": "现在可以通过姓名或ID来搜索患者。",
+ "registrationSuccessToastTitle": "新患者已创建",
+ "relationship": "关系",
+ "relationshipPlaceholder": "关系",
+ "relationshipRemovedText": "关系已移除",
+ "relationshipToPatient": "与患者的关系",
+ "relativeFullNameLabelText": "全名",
+ "relativeNamePlaceholder": "名字 姓氏",
+ "resetIdentifierTooltip": "重置",
+ "restoreRelationshipActionButton": "撤销",
+ "searchAddress": "搜索地址",
+ "sexFieldLabelText": "性别",
+ "stateProvince": "省份",
+ "stroke": "卒中",
+ "unidentifiedPatient": "未知患者",
+ "unknown": "未知",
+ "updatePatient": "更新患者",
+ "updationSuccessToastDescription": "患者信息已成功更新",
+ "updationSuccessToastTitle": "患者详情已更新",
+ "years": "年",
+ "yearsEstimateRequired": "需要年龄估算",
+ "yes": "是"
+}
diff --git a/packages/esm-patient-registration-app/translations/zh_CN.json b/packages/esm-patient-registration-app/translations/zh_CN.json
new file mode 100644
index 00000000..b39646ba
--- /dev/null
+++ b/packages/esm-patient-registration-app/translations/zh_CN.json
@@ -0,0 +1,89 @@
+{
+ "addRelationshipButtonText": "添加关系",
+ "address1": "地址行1",
+ "address2": "地址行2",
+ "addressHeader": "地址",
+ "allFieldsRequiredText": "所有字段都是必填的,除非标记为可选。",
+ "autoGeneratedPlaceholderText": "自动生成",
+ "birthdayNotInTheFuture": "生日不能是未来的日期",
+ "birthdayRequired": "生日是必填项",
+ "birthFieldLabelText": "出生",
+ "cancel": "取消",
+ "causeOfDeathInputLabel": "死因",
+ "cityVillage": "城市",
+ "configure": "配置",
+ "country": "国家",
+ "countyDistrict": "区县",
+ "createNew": "新建",
+ "dateOfBirthLabelText": "出生日期",
+ "deathDateInputLabel": "死亡日期",
+ "deathdayNotInTheFuture": "死亡日期不能是未来的日期",
+ "deleteIdentifierTooltip": "删除",
+ "deleteRelationshipTooltipText": "删除",
+ "discard": "放弃",
+ "discardModalBody": "您对该患者的详细信息所做的更改尚未保存。放弃更改吗?",
+ "discardModalHeader": "确认放弃更改",
+ "dobToggleLabelText": "出生日期已知?",
+ "edit": "编辑",
+ "editIdentifierTooltip": "编辑",
+ "editPatientDetails": "编辑患者详情",
+ "emailLabelText": "电子邮件",
+ "estimatedAgeInMonthsLabelText": "月龄估算",
+ "estimatedAgeInYearsLabelText": "年龄估算",
+ "familyNameLabelText": "姓氏",
+ "familyNameRequired": "姓氏是必填项",
+ "female": "女性",
+ "fieldErrorTitleMessage": "以下字段存在错误:",
+ "fullNameLabelText": "全名",
+ "genderLabelText": "性别",
+ "genderRequired": "性别是必填项",
+ "genderUnspecified": "性别未指定",
+ "givenNameLabelText": "名字",
+ "givenNameRequired": "名字是必填项",
+ "identifierValueRequired": "ID标识是必填项",
+ "idFieldLabelText": "ID标识",
+ "incompleteForm": "表单未填完",
+ "invalidEmail": "需要提供一个有效的电子邮件地址",
+ "invalidInput": "输入无效",
+ "isDeadInputLabel": "已故",
+ "jumpTo": "跳转至",
+ "loadingResults": "正在加载结果",
+ "male": "男性",
+ "middleNameLabelText": "中间名",
+ "months": "月",
+ "negativeMonths": "负的月份",
+ "negativeYears": "负的年份",
+ "no": "否",
+ "noResultsFound": "未找到结果",
+ "numberInNameDubious": "姓名中含有数字",
+ "optional": "可选的",
+ "other": "其他",
+ "patient": "患者",
+ "patientNameKnown": "患者的姓名已知?",
+ "phoneEmailLabelText": "电话、电子邮件等。",
+ "phoneNumberInputLabelText": "电话号码",
+ "postalCode": "邮政编码",
+ "registerPatient": "注册患者",
+ "registrationSuccessToastDescription": "现在可以通过姓名或ID来搜索患者。",
+ "registrationSuccessToastTitle": "新患者已创建",
+ "relationship": "关系",
+ "relationshipPlaceholder": "关系",
+ "relationshipRemovedText": "关系已移除",
+ "relationshipToPatient": "与患者的关系",
+ "relativeFullNameLabelText": "全名",
+ "relativeNamePlaceholder": "名字 姓氏",
+ "resetIdentifierTooltip": "重置",
+ "restoreRelationshipActionButton": "撤销",
+ "searchAddress": "搜索地址",
+ "sexFieldLabelText": "性别",
+ "stateProvince": "省份",
+ "stroke": "卒中",
+ "unidentifiedPatient": "未知患者",
+ "unknown": "未知",
+ "updatePatient": "更新患者",
+ "updationSuccessToastDescription": "患者信息已成功更新",
+ "updationSuccessToastTitle": "患者详情已更新",
+ "years": "年",
+ "yearsEstimateRequired": "需要年龄估算",
+ "yes": "是"
+}
diff --git a/packages/esm-patient-registration-app/tsconfig.json b/packages/esm-patient-registration-app/tsconfig.json
new file mode 100644
index 00000000..54ce28cf
--- /dev/null
+++ b/packages/esm-patient-registration-app/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "../../tsconfig.json",
+ "include": ["src/**/*"],
+ "exclude": ["src/**/*.test.tsx"]
+}
diff --git a/packages/esm-patient-registration-app/webpack.config.js b/packages/esm-patient-registration-app/webpack.config.js
new file mode 100644
index 00000000..2c74029c
--- /dev/null
+++ b/packages/esm-patient-registration-app/webpack.config.js
@@ -0,0 +1 @@
+module.exports = require('openmrs/default-webpack-config');
diff --git a/yarn.lock b/yarn.lock
index 97814671..c218070f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -39,6 +39,29 @@ __metadata:
languageName: node
linkType: hard
+"@ampath/esm-patient-registration-app@workspace:packages/esm-patient-registration-app":
+ version: 0.0.0-use.local
+ resolution: "@ampath/esm-patient-registration-app@workspace:packages/esm-patient-registration-app"
+ dependencies:
+ "@carbon/react": "npm:~1.37.0"
+ core-js-pure: "npm:^3.34.0"
+ formik: "npm:^2.1.5"
+ geopattern: "npm:^1.2.3"
+ lodash-es: "npm:^4.17.15"
+ react-avatar: "npm:^5.0.3"
+ uuid: "npm:^8.3.2"
+ webpack: "npm:^5.74.0"
+ yup: "npm:^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
+ languageName: unknown
+ linkType: soft
+
"@ampath/esm-preappointment-app@workspace:packages/esm-preappointment-app":
version: 0.0.0-use.local
resolution: "@ampath/esm-preappointment-app@workspace:packages/esm-preappointment-app"
@@ -1382,6 +1405,15 @@ __metadata:
languageName: node
linkType: hard
+"@babel/runtime@npm:^7.10.5, @babel/runtime@npm:^7.22.15":
+ version: 7.23.9
+ resolution: "@babel/runtime@npm:7.23.9"
+ dependencies:
+ regenerator-runtime: "npm:^0.14.0"
+ checksum: 9a520fe1bf72249f7dd60ff726434251858de15cccfca7aa831bd19d0d3fb17702e116ead82724659b8da3844977e5e13de2bae01eb8a798f2823a669f122be6
+ languageName: node
+ linkType: hard
+
"@babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.14.5, @babel/runtime@npm:^7.15.4, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.5.5, @babel/runtime@npm:^7.7.2, @babel/runtime@npm:^7.8.4, @babel/runtime@npm:^7.9.2":
version: 7.20.1
resolution: "@babel/runtime@npm:7.20.1"
@@ -1409,15 +1441,6 @@ __metadata:
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.22.15":
- version: 7.23.9
- resolution: "@babel/runtime@npm:7.23.9"
- dependencies:
- regenerator-runtime: "npm:^0.14.0"
- checksum: 9a520fe1bf72249f7dd60ff726434251858de15cccfca7aa831bd19d0d3fb17702e116ead82724659b8da3844977e5e13de2bae01eb8a798f2823a669f122be6
- languageName: node
- linkType: hard
-
"@babel/runtime@npm:^7.8.7":
version: 7.22.15
resolution: "@babel/runtime@npm:7.22.15"
@@ -4838,6 +4861,16 @@ __metadata:
languageName: node
linkType: hard
+"@types/hoist-non-react-statics@npm:^3.3.1":
+ version: 3.3.5
+ resolution: "@types/hoist-non-react-statics@npm:3.3.5"
+ dependencies:
+ "@types/react": "npm:*"
+ hoist-non-react-statics: "npm:^3.3.0"
+ checksum: b645b062a20cce6ab1245ada8274051d8e2e0b2ee5c6bd58215281d0ec6dae2f26631af4e2e7c8abe238cdcee73fcaededc429eef569e70908f82d0cc0ea31d7
+ languageName: node
+ linkType: hard
+
"@types/html-minifier-terser@npm:^6.0.0":
version: 6.1.0
resolution: "@types/html-minifier-terser@npm:6.1.0"
@@ -6755,6 +6788,13 @@ __metadata:
languageName: node
linkType: hard
+"charenc@npm:0.0.2":
+ version: 0.0.2
+ resolution: "charenc@npm:0.0.2"
+ checksum: 81dcadbe57e861d527faf6dd3855dc857395a1c4d6781f4847288ab23cffb7b3ee80d57c15bba7252ffe3e5e8019db767757ee7975663ad2ca0939bb8fcaf2e5
+ languageName: node
+ linkType: hard
+
"cheerio-select@npm:^2.1.0":
version: 2.1.0
resolution: "cheerio-select@npm:2.1.0"
@@ -7332,6 +7372,13 @@ __metadata:
languageName: node
linkType: hard
+"core-js-pure@npm:^3.34.0":
+ version: 3.36.0
+ resolution: "core-js-pure@npm:3.36.0"
+ checksum: 64e8f4f8118e9ea0fcacafbf82b7c0a448375ec2e8e989af12a68e0fa3ad9825d68a6550a07ea15c119044457ad3b16a20cd17599ae2dcdde6836b3d614d7ae3
+ languageName: node
+ linkType: hard
+
"core-util-is@npm:~1.0.0":
version: 1.0.3
resolution: "core-util-is@npm:1.0.3"
@@ -7414,6 +7461,13 @@ __metadata:
languageName: node
linkType: hard
+"crypt@npm:0.0.2":
+ version: 0.0.2
+ resolution: "crypt@npm:0.0.2"
+ checksum: 2c72768de3d28278c7c9ffd81a298b26f87ecdfe94415084f339e6632f089b43fe039f2c93f612bcb5ffe447238373d93b2e8c90894cba6cfb0ac7a74616f8b9
+ languageName: node
+ linkType: hard
+
"crypto-random-string@npm:^2.0.0":
version: 2.0.0
resolution: "crypto-random-string@npm:2.0.0"
@@ -8159,6 +8213,13 @@ __metadata:
languageName: node
linkType: hard
+"deepmerge@npm:^2.1.1":
+ version: 2.2.1
+ resolution: "deepmerge@npm:2.2.1"
+ checksum: a3da411cd3d471a8ae86ff7fd5e19abb648377b3f8c42a9e4c822406c2960a391cb829e4cca53819b73715e68f56b06f53c643ca7bba21cab569fecc9a723de1
+ languageName: node
+ linkType: hard
+
"deepmerge@npm:^4.2.2":
version: 4.2.2
resolution: "deepmerge@npm:4.2.2"
@@ -9148,6 +9209,13 @@ __metadata:
languageName: node
linkType: hard
+"extend@npm:~1.2.1":
+ version: 1.2.1
+ resolution: "extend@npm:1.2.1"
+ checksum: bfebc6fd4d924f9a8872cfebbda8fc543bae58ca3ec7fe7a5189a706402bd465559b82a106db589088d60e8348e04d8597a1d3760cb5e3d8cf184bcd924ac569
+ languageName: node
+ linkType: hard
+
"external-editor@npm:^3.0.3":
version: 3.1.0
resolution: "external-editor@npm:3.1.0"
@@ -9382,6 +9450,13 @@ __metadata:
languageName: node
linkType: hard
+"fn-name@npm:~3.0.0":
+ version: 3.0.0
+ resolution: "fn-name@npm:3.0.0"
+ checksum: 3cfe9bfadb65af582624fb17f8d491eac3d122ff8ecf9debb5994cb243131697b88903a737563cbc7d8a919716bd5f53ed8cb87bdfe124744a0d8a690531b4c2
+ languageName: node
+ linkType: hard
+
"follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.14.0":
version: 1.15.2
resolution: "follow-redirects@npm:1.15.2"
@@ -9443,6 +9518,24 @@ __metadata:
languageName: node
linkType: hard
+"formik@npm:^2.1.5":
+ version: 2.4.5
+ resolution: "formik@npm:2.4.5"
+ dependencies:
+ "@types/hoist-non-react-statics": "npm:^3.3.1"
+ deepmerge: "npm:^2.1.1"
+ hoist-non-react-statics: "npm:^3.3.0"
+ lodash: "npm:^4.17.21"
+ lodash-es: "npm:^4.17.21"
+ react-fast-compare: "npm:^2.0.1"
+ tiny-warning: "npm:^1.0.2"
+ tslib: "npm:^2.0.0"
+ peerDependencies:
+ react: ">=16.8.0"
+ checksum: 223fb3e6b0a7803221c030364a015b9adb01b61f7aed7c64e28ef8341a3e7c94c7a70aef7ed9f65d03ac44e4e19972c1247fb0e39538e4e084833fd1fa3b11c4
+ languageName: node
+ linkType: hard
+
"forwarded@npm:0.2.0":
version: 0.2.0
resolution: "forwarded@npm:0.2.0"
@@ -9632,6 +9725,15 @@ __metadata:
languageName: node
linkType: hard
+"geopattern@npm:^1.2.3":
+ version: 1.2.3
+ resolution: "geopattern@npm:1.2.3"
+ dependencies:
+ extend: "npm:~1.2.1"
+ checksum: 75a2a7149b4615ec59ed89613155c8252d758de49a52aa3ac45398c83d821b6fe0db3252e573b9117f4e56530745a17f1ac666c484709a467da08c6a59aa7cda
+ languageName: node
+ linkType: hard
+
"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5":
version: 2.0.5
resolution: "get-caller-file@npm:2.0.5"
@@ -9987,6 +10089,15 @@ __metadata:
languageName: node
linkType: hard
+"hoist-non-react-statics@npm:^3.3.0":
+ version: 3.3.2
+ resolution: "hoist-non-react-statics@npm:3.3.2"
+ dependencies:
+ react-is: "npm:^16.7.0"
+ checksum: 1acbe85f33e5a39f90c822ad4d28b24daeb60f71c545279431dc98c312cd28a54f8d64788e477fe21dc502b0e3cf58589ebe5c1ad22af27245370391c2d24ea6
+ languageName: node
+ linkType: hard
+
"hosted-git-info@npm:^2.1.4":
version: 2.8.9
resolution: "hosted-git-info@npm:2.8.9"
@@ -10620,7 +10731,7 @@ __metadata:
languageName: node
linkType: hard
-"is-buffer@npm:^1.1.5":
+"is-buffer@npm:^1.1.5, is-buffer@npm:~1.1.6":
version: 1.1.6
resolution: "is-buffer@npm:1.1.6"
checksum: f63da109e74bbe8947036ed529d43e4ae0c5fcd0909921dce4917ad3ea212c6a87c29f525ba1d17c0858c18331cf1046d4fc69ef59ed26896b25c8288a627133
@@ -10896,6 +11007,13 @@ __metadata:
languageName: node
linkType: hard
+"is-retina@npm:^1.0.3":
+ version: 1.0.3
+ resolution: "is-retina@npm:1.0.3"
+ checksum: 7f8306095851aaa55d7dd4a2edffb53942f45388d4d19299a788ca7d30f9f2b7ae0884237b2262a5f8a6d9d5f57e934da3fdbec60b174469054ef080ac29012f
+ languageName: node
+ linkType: hard
+
"is-set@npm:^2.0.1, is-set@npm:^2.0.2":
version: 2.0.2
resolution: "is-set@npm:2.0.2"
@@ -12067,7 +12185,7 @@ __metadata:
languageName: node
linkType: hard
-"lodash-es@npm:4.17.21, lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
+"lodash-es@npm:4.17.21, lodash-es@npm:^4.17.11, lodash-es@npm:^4.17.15, lodash-es@npm:^4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43
@@ -12303,6 +12421,17 @@ __metadata:
languageName: node
linkType: hard
+"md5@npm:^2.0.0":
+ version: 2.3.0
+ resolution: "md5@npm:2.3.0"
+ dependencies:
+ charenc: "npm:0.0.2"
+ crypt: "npm:0.0.2"
+ is-buffer: "npm:~1.1.6"
+ checksum: 88dce9fb8df1a084c2385726dcc18c7f54e0b64c261b5def7cdfe4928c4ee1cd68695c34108b4fab7ecceb05838c938aa411c6143df9fdc0026c4ddb4e4e72fa
+ languageName: node
+ linkType: hard
+
"mdn-data@npm:2.0.14":
version: 2.0.14
resolution: "mdn-data@npm:2.0.14"
@@ -14170,6 +14299,13 @@ __metadata:
languageName: node
linkType: hard
+"property-expr@npm:^2.0.2":
+ version: 2.0.6
+ resolution: "property-expr@npm:2.0.6"
+ checksum: 89977f4bb230736c1876f460dd7ca9328034502fd92e738deb40516d16564b850c0bbc4e052c3df88b5b8cd58e51c93b46a94bea049a3f23f4a022c038864cab
+ languageName: node
+ linkType: hard
+
"proto-list@npm:~1.2.1":
version: 1.2.4
resolution: "proto-list@npm:1.2.4"
@@ -14305,6 +14441,21 @@ __metadata:
languageName: node
linkType: hard
+"react-avatar@npm:^5.0.3":
+ version: 5.0.3
+ resolution: "react-avatar@npm:5.0.3"
+ dependencies:
+ is-retina: "npm:^1.0.3"
+ md5: "npm:^2.0.0"
+ peerDependencies:
+ "@babel/runtime": ">=7"
+ core-js-pure: ">=3"
+ prop-types: ^15.0.0 || ^16.0.0
+ react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0
+ checksum: cdbb231d7d19cd3890873b465affe984aa006341c8b6130a670bfe406461665fb8f5607e0c3d7650aa4ec43b96acdad0b572b760e16c2cc41ddc15986aac7590
+ languageName: node
+ linkType: hard
+
"react-dom@npm:^18.1.0":
version: 18.2.0
resolution: "react-dom@npm:18.2.0"
@@ -14317,6 +14468,13 @@ __metadata:
languageName: node
linkType: hard
+"react-fast-compare@npm:^2.0.1":
+ version: 2.0.4
+ resolution: "react-fast-compare@npm:2.0.4"
+ checksum: e4e3218c0f5c29b88e9f184a12adb77b0a93a803dbd45cb98bbb754c8310dc74e6266c53dd70b90ba4d0939e0e1b8a182cb05d081bcab22507a0390fbcd768ac
+ languageName: node
+ linkType: hard
+
"react-hook-form@npm:^7.46.2":
version: 7.50.0
resolution: "react-hook-form@npm:7.50.0"
@@ -14344,7 +14502,7 @@ __metadata:
languageName: node
linkType: hard
-"react-is@npm:^16.13.1":
+"react-is@npm:^16.13.1, react-is@npm:^16.7.0":
version: 16.13.1
resolution: "react-is@npm:16.13.1"
checksum: 5aa564a1cde7d391ac980bedee21202fc90bdea3b399952117f54fb71a932af1e5902020144fb354b4690b2414a0c7aafe798eb617b76a3d441d956db7726fdf
@@ -15989,6 +16147,13 @@ __metadata:
languageName: node
linkType: hard
+"synchronous-promise@npm:^2.0.13":
+ version: 2.0.17
+ resolution: "synchronous-promise@npm:2.0.17"
+ checksum: dd74b1c05caab8ea34e26c8b52a0966efd70b0229ad39447ce066501dd6931d4d97a3f88b0f306880a699660cd334180a24d9738b385aed0bd0104a5be207ec1
+ languageName: node
+ linkType: hard
+
"synckit@npm:^0.8.5":
version: 0.8.6
resolution: "synckit@npm:0.8.6"
@@ -16180,6 +16345,13 @@ __metadata:
languageName: node
linkType: hard
+"tiny-warning@npm:^1.0.2":
+ version: 1.0.3
+ resolution: "tiny-warning@npm:1.0.3"
+ checksum: da62c4acac565902f0624b123eed6dd3509bc9a8d30c06e017104bedcf5d35810da8ff72864400ad19c5c7806fc0a8323c68baf3e326af7cb7d969f846100d71
+ languageName: node
+ linkType: hard
+
"tinycolor2@npm:^1.4.1":
version: 1.4.2
resolution: "tinycolor2@npm:1.4.2"
@@ -16272,6 +16444,13 @@ __metadata:
languageName: node
linkType: hard
+"toposort@npm:^2.0.2":
+ version: 2.0.2
+ resolution: "toposort@npm:2.0.2"
+ checksum: 6f128353e4ed9739e49a28fb756b0a00f3752b29fc9b862ff781446598ee3b486cd229697feebc4eabd916eac5de219f3dae450c585bf13673f6b133a7226e06
+ languageName: node
+ linkType: hard
+
"totalist@npm:^1.0.0":
version: 1.1.0
resolution: "totalist@npm:1.1.0"
@@ -16334,6 +16513,13 @@ __metadata:
languageName: node
linkType: hard
+"tslib@npm:^2.0.0, tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.0, tslib@npm:^2.6.2":
+ version: 2.6.2
+ resolution: "tslib@npm:2.6.2"
+ checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca
+ languageName: node
+ linkType: hard
+
"tslib@npm:^2.0.3":
version: 2.4.1
resolution: "tslib@npm:2.4.1"
@@ -16341,13 +16527,6 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.3.0, tslib@npm:^2.4.0, tslib@npm:^2.6.0, tslib@npm:^2.6.2":
- version: 2.6.2
- resolution: "tslib@npm:2.6.2"
- checksum: bd26c22d36736513980091a1e356378e8b662ded04204453d353a7f34a4c21ed0afc59b5f90719d4ba756e581a162ecbf93118dc9c6be5acf70aa309188166ca
- languageName: node
- linkType: hard
-
"turbo-darwin-64@npm:1.6.3":
version: 1.6.3
resolution: "turbo-darwin-64@npm:1.6.3"
@@ -18089,6 +18268,21 @@ __metadata:
languageName: node
linkType: hard
+"yup@npm:^0.29.1":
+ version: 0.29.3
+ resolution: "yup@npm:0.29.3"
+ dependencies:
+ "@babel/runtime": "npm:^7.10.5"
+ fn-name: "npm:~3.0.0"
+ lodash: "npm:^4.17.15"
+ lodash-es: "npm:^4.17.11"
+ property-expr: "npm:^2.0.2"
+ synchronous-promise: "npm:^2.0.13"
+ toposort: "npm:^2.0.2"
+ checksum: 78dde0d087cb5dc78c64b0e82c913009260604aa100f500f0b10a703ed263fa65791c4cfcec4feb886d95f74d4312dd2214802e47ba048f120a63d42f171eef6
+ languageName: node
+ linkType: hard
+
"zod@npm:^3.22.2":
version: 3.22.4
resolution: "zod@npm:3.22.4"