diff --git a/packages/esm-outpatient-app/src/patient-info/appointment-details.component.tsx b/packages/esm-outpatient-app/src/patient-info/appointment-details.component.tsx index 45c501ea0..9c6b1ae24 100644 --- a/packages/esm-outpatient-app/src/patient-info/appointment-details.component.tsx +++ b/packages/esm-outpatient-app/src/patient-info/appointment-details.component.tsx @@ -26,13 +26,13 @@ const PastAppointmentDetails: React.FC = ({ pastApp {pastAppointments.length >= 1 ? ( pastAppointments.map((appointment, index) => { return ( - <> +

{t('lastEncounter', 'Last encounter')}

{formatDatetime(parseDate(appointment.startDateTime))} · {appointment.service.name} ·{' '} {appointment.location.name}{' '}

- +
); }) ) : ( @@ -52,13 +52,13 @@ const UpcomingAppointmentDetails: React.FC = ({ {upcomingAppointments.length >= 1 ? ( upcomingAppointments.map((appointment, index) => { return ( - <> +

{t('returnDate', 'Return date')}

{formatDatetime(parseDate(appointment.startDateTime))} · {appointment.service.name} ·{' '} {appointment.location.name}{' '}

- +
); }) ) : ( diff --git a/packages/esm-patient-registration-app/src/config-schema.ts b/packages/esm-patient-registration-app/src/config-schema.ts index 6a190af03..8fc459f9f 100644 --- a/packages/esm-patient-registration-app/src/config-schema.ts +++ b/packages/esm-patient-registration-app/src/config-schema.ts @@ -9,14 +9,14 @@ export interface SectionDefinition { export interface FieldDefinition { id: string; type: string; - label: string; + label: string | null; uuid: string; placeholder: string; validation: { required: boolean; - matches: string; + matches: string | null; }; - answerConceptSetUuid: string; + answerConceptSetUuid: string | null; } export interface RegistrationConfig { @@ -38,6 +38,11 @@ export interface RegistrationConfig { patientPhotoUuid: string; }; defaultPatientIdentifierTypes: Array; + registrationObs: { + encounterTypeUuid: string | null; + encounterProviderRoleUuid: string; + registrationFormUuid: string | null; + }; } export const builtInSections: Array = [ @@ -96,6 +101,11 @@ export const esmPatientRegistrationSchema = { _description: 'How this field will be referred to in the `fields` element of the `sectionDefinitions` configuration.', }, + type: { + _type: Type.String, + _description: "How this field's data will be stored—a person attribute or an obs.", + _validators: [validators.oneOf(['person attribute', 'obs'])], + }, uuid: { _type: Type.UUID, _description: "Person attribute type UUID that this field's data should be saved to.", @@ -162,7 +172,6 @@ export const esmPatientRegistrationSchema = { _default: '736e8771-e501-4615-bfa7-570c03f4bef5', }, }, - defaultPatientIdentifierTypes: { _type: Type.Array, _elements: { @@ -170,4 +179,77 @@ export const esmPatientRegistrationSchema = { }, _default: [], }, + registrationObs: { + encounterTypeUuid: { + _type: Type.UUID, + _default: null, + _description: + 'Obs created during registration will be associated with an encounter of this type. This must be set in order to use fields of type `obs`.', + }, + encounterProviderRoleUuid: { + _type: Type.UUID, + _default: 'a0b03050-c99b-11e0-9572-0800200c9a66', + _description: "The provider role to use for the registration encounter. Default is 'Unkown'.", + }, + registrationFormUuid: { + _type: Type.UUID, + _default: null, + _description: + 'The form UUID to associate with the registration encounter. By default no form will be associated.', + }, + }, + _validators: [ + validator( + (config: RegistrationConfig) => + !config.fieldDefinitions.some((d) => d.type == 'obs') || config.registrationObs.encounterTypeUuid != null, + "If fieldDefinitions contains any fields of type 'obs', `registrationObs.encounterTypeUuid` must be specified.", + ), + validator( + (config: RegistrationConfig) => + config.sections.every((s) => + [...builtInSections, ...config.sectionDefinitions].map((sDef) => sDef.id).includes(s), + ), + (config: RegistrationConfig) => { + const allowedSections = [...builtInSections, ...config.sectionDefinitions].map((sDef) => sDef.id); + const badSection = config.sections.find((s) => !allowedSections.includes(s)); + return ( + `'${badSection}' is not a valid section ID. Valid section IDs include the built-in sections ${stringifyDefinitions( + builtInSections, + )}` + + (config.sectionDefinitions.length + ? `; and the defined sections ${stringifyDefinitions(config.sectionDefinitions)}.` + : '.') + ); + }, + ), + validator( + (config: RegistrationConfig) => + config.sectionDefinitions.every((sectionDefinition) => + sectionDefinition.fields.every((f) => + [...builtInFields, ...config.fieldDefinitions.map((fDef) => fDef.id)].includes(f), + ), + ), + (config: RegistrationConfig) => { + const allowedFields = [...builtInFields, ...config.fieldDefinitions.map((fDef) => fDef.id)]; + const badSection = config.sectionDefinitions.find((sectionDefinition) => + sectionDefinition.fields.some((f) => !allowedFields.includes(f)), + ); + const badField = badSection.fields.find((f) => !allowedFields.includes(f)); + return ( + `The section definition '${ + badSection.id + }' contains an invalid field '${badField}'. 'fields' can only contain the built-in fields '${builtInFields.join( + "', '", + )}'` + + (config.fieldDefinitions.length + ? `; or the defined fields ${stringifyDefinitions(config.fieldDefinitions)}.` + : '.') + ); + }, + ), + ], }; + +function stringifyDefinitions(sectionDefinitions: Array) { + return `'${sectionDefinitions.map((s) => s.id).join("', '")}'`; +} diff --git a/packages/esm-patient-registration-app/src/offline.ts b/packages/esm-patient-registration-app/src/offline.ts index 5647db9db..d712df636 100644 --- a/packages/esm-patient-registration-app/src/offline.ts +++ b/packages/esm-patient-registration-app/src/offline.ts @@ -68,9 +68,11 @@ export async function syncPatientRegistration( queuedPatient._patientRegistrationData.patientUuidMap, queuedPatient._patientRegistrationData.initialAddressFieldValues, queuedPatient._patientRegistrationData.capturePhotoProps, - queuedPatient._patientRegistrationData.patientPhotoConceptUuid, queuedPatient._patientRegistrationData.currentLocation, queuedPatient._patientRegistrationData.initialIdentifierValues, + queuedPatient._patientRegistrationData.currentUser, + queuedPatient._patientRegistrationData.config, + queuedPatient._patientRegistrationData.savePatientTransactionManager, options.abort, ); } diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts new file mode 100644 index 000000000..c4f12827f --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/__mocks__/field.resource.ts @@ -0,0 +1,60 @@ +import { ConceptResponse } from '../../patient-registration-types'; + +export const useConcept = jest.fn(function mockUseConceptImplementation(uuid: string): { + data: ConceptResponse; + isLoading: boolean; +} { + let data; + if (uuid == 'weight-uuid') { + data = { + uuid: 'weight-uuid', + display: 'Weight (kg)', + datatype: { display: 'Numeric', uuid: 'num' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'chief-complaint-uuid') { + data = { + uuid: 'chief-complaint-uuid', + display: 'Chief Complaint', + datatype: { display: 'Text', uuid: 'txt' }, + answers: [], + setMembers: [], + }; + } else if (uuid == 'nationality-uuid') { + data = { + uuid: 'nationality-uuid', + display: 'Nationality', + datatype: { display: 'Coded', uuid: 'cdd' }, + answers: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + setMembers: [], + }; + } + return { + data: data ?? null, + isLoading: !data, + }; +}); + +export const useConceptAnswers = jest.fn((uuid: string) => { + if (uuid == 'nationality-uuid') { + return { + data: [ + { display: 'USA', uuid: 'usa' }, + { display: 'Mexico', uuid: 'mex' }, + ], + isLoading: false, + }; + } else if (uuid == 'other-countries-uuid') { + return { + data: [ + { display: 'Kenya', uuid: 'ke' }, + { display: 'Uganda', uuid: 'ug' }, + ], + isLoading: false, + }; + } +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx index cbdc6be00..9c0586261 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/field/custom-field.component.tsx @@ -1,6 +1,7 @@ import { useConfig } from '@openmrs/esm-framework'; import React from 'react'; import { RegistrationConfig } from '../../config-schema'; +import { ObsField } from './obs/obs-field.component'; import { PersonAttributeField } from './person-attributes/person-attribute-field.component'; export interface CustomFieldProps { @@ -11,5 +12,11 @@ export function CustomField({ name }: CustomFieldProps) { const config = useConfig() as RegistrationConfig; const fieldDefinition = config.fieldDefinitions.filter((def) => def.id == name)[0]; - return ; + if (fieldDefinition.type === 'person attribute') { + return ; + } else if (fieldDefinition.type === 'obs') { + return ; + } else { + return
Error: Unknown field type {fieldDefinition.type}
; + } } diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts new file mode 100644 index 000000000..78af78ed5 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.resource.ts @@ -0,0 +1,35 @@ +import { FetchResponse, openmrsFetch, showToast } from '@openmrs/esm-framework'; +import useSWRImmutable from 'swr/immutable'; +import { ConceptAnswers, ConceptResponse } from '../patient-registration-types'; + +export function useConcept(conceptUuid: string): { data: ConceptResponse; isLoading: boolean } { + const shouldFetch = typeof conceptUuid === 'string' && conceptUuid !== ''; + const { data, error } = useSWRImmutable, Error>( + shouldFetch ? `/ws/rest/v1/concept/${conceptUuid}` : null, + openmrsFetch, + ); + if (error) { + showToast({ + title: error.name, + description: error.message, + kind: 'error', + }); + } + return { data: data?.data, isLoading: !data && !error }; +} + +export function useConceptAnswers(conceptUuid: string): { data: Array; isLoading: boolean } { + const shouldFetch = typeof conceptUuid === 'string' && conceptUuid !== ''; + const { data, error } = useSWRImmutable, Error>( + shouldFetch ? `/ws/rest/v1/concept/${conceptUuid}` : null, + openmrsFetch, + ); + if (error) { + showToast({ + title: error.name, + description: error.message, + kind: 'error', + }); + } + return { data: data?.data?.answers, isLoading: !data && !error }; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/field.scss b/packages/esm-patient-registration-app/src/patient-registration/field/field.scss index e1ec261e7..a42a1a72f 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/field/field.scss +++ b/packages/esm-patient-registration-app/src/patient-registration/field/field.scss @@ -83,7 +83,7 @@ margin-left: $spacing-03; } -.attributeField { +.customField { margin-bottom: $spacing-05; } diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx new file mode 100644 index 000000000..a6f7b2297 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.component.tsx @@ -0,0 +1,168 @@ +import { useConfig } from '@openmrs/esm-framework'; +import { InlineNotification, Select, SelectItem } from 'carbon-components-react'; +import { Field } from 'formik'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { FieldDefinition, RegistrationConfig } from '../../../config-schema'; +import { Input } from '../../input/basic-input/input/input.component'; +import { ConceptResponse } from '../../patient-registration-types'; +import { useConcept, useConceptAnswers } from '../field.resource'; +import styles from './../field.scss'; + +export interface ObsFieldProps { + fieldDefinition: FieldDefinition; +} + +export function ObsField({ fieldDefinition }: ObsFieldProps) { + const { data: concept, isLoading } = useConcept(fieldDefinition.uuid); + const config = useConfig() as RegistrationConfig; + + if (!config.registrationObs.encounterTypeUuid) { + console.error( + 'The registration form has been configured to have obs fields, ' + + 'but no registration encounter type has been configured. Obs fields ' + + 'will not be displayed.', + ); + return null; + } + + if (isLoading) { + return null; + } + switch (concept.datatype.display) { + case 'Text': + return ( + + ); + case 'Numeric': + return ; + case 'Coded': + return ( + + ); + default: + return ( + + Concept has unknown datatype "{concept.datatype.display}" + + ); + } +} + +interface TextObsFieldProps { + concept: ConceptResponse; + validationRegex: string; + label: string; +} + +function TextObsField({ concept, validationRegex, label }: TextObsFieldProps) { + const { t } = useTranslation(); + + const validateInput = (value: string) => { + if (!value || !validationRegex || validationRegex === '' || typeof validationRegex !== 'string' || value === '') { + return; + } + const regex = new RegExp(validationRegex); + if (regex.test(value)) { + return; + } else { + return t('invalidInput', 'Invalid Input'); + } + }; + + const fieldName = `obs.${concept.uuid}`; + + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +} + +interface NumericObsFieldProps { + concept: ConceptResponse; + label: string; +} + +function NumericObsField({ concept, label }: NumericObsFieldProps) { + const { t } = useTranslation(); + + const fieldName = `obs.${concept.uuid}`; + + return ( +
+ + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + +
+ ); +} + +interface CodedObsFieldProps { + concept: ConceptResponse; + answerConceptSetUuid?: string; + label?: string; +} + +function CodedObsField({ concept, answerConceptSetUuid, label }: CodedObsFieldProps) { + const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers( + answerConceptSetUuid ?? concept.uuid, + ); + const fieldName = `obs.${concept.uuid}`; + + return ( +
+ {!isLoadingConceptAnswers ? ( + + {({ field, form: { touched, errors }, meta }) => { + return ( + + ); + }} + + ) : null} +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx new file mode 100644 index 000000000..c0e758754 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/obs/obs-field.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FieldDefinition, RegistrationConfig } from '../../../config-schema'; +import { ObsField } from './obs-field.component'; +import { useConfig } from '@openmrs/esm-framework'; +import { useConcept, useConceptAnswers } from '../field.resource'; +import userEvent from '@testing-library/user-event'; + +const mockUseConfig = useConfig as jest.Mock; + +// The UUIDs in this test all refer to ones that are in `__mocks__/field.resource.ts` +jest.mock('../field.resource'); + +type FieldProps = { + children: ({ field, form: { touched, errors } }) => React.ReactNode; +}; +jest.mock('formik', () => ({ + ...(jest.requireActual('formik') as object), + Field: jest.fn(({ children }: FieldProps) => <>{children({ field: {}, form: { touched: {}, errors: {} } })}), + useField: jest.fn(() => [{ value: null }, {}]), +})); + +const textFieldDef: FieldDefinition = { + id: 'chief-complaint', + type: 'obs', + label: '', + placeholder: '', + uuid: 'chief-complaint-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, +}; + +const numberFieldDef: FieldDefinition = { + id: 'weight', + type: 'obs', + label: '', + placeholder: '', + uuid: 'weight-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, +}; + +const codedFieldDef: FieldDefinition = { + id: 'nationality', + type: 'obs', + label: '', + placeholder: '', + uuid: 'nationality-uuid', + validation: { + required: false, + matches: null, + }, + answerConceptSetUuid: null, +}; + +describe('ObsField', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue({ registrationObs: { encounterTypeUuid: 'reg-enc-uuid' } }); + }); + + it("logs an error and doesn't render if no registration encounter type is provided", () => { + mockUseConfig.mockReturnValue({ registrationObs: { encounterTypeUuid: null } }); + console.error = jest.fn(); + render(); + expect(console.error).toHaveBeenCalledWith(expect.stringMatching(/no registration encounter type.*configure/i)); + expect(screen.queryByRole('textbox')).toBeNull(); + }); + + it('renders a text box for text concept', () => { + render(); + // I don't know why the labels aren't in the DOM, but they aren't + // expect(screen.getByLabelText("Chief Complaint")).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('renders a number box for number concept', () => { + render(); + // expect(screen.getByLabelText("Weight (kg)")).toBeInTheDocument(); + expect(screen.getByRole('spinbutton')).toBeInTheDocument(); + }); + + it('renders a select for a coded concept', () => { + render(); + // expect(screen.getByLabelText("Nationality")).toBeInTheDocument(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + expect(select).toHaveDisplayValue(''); + }); + + it('select uses answerConcept for answers when it is provided', () => { + render(); + // expect(screen.getByLabelText("Nationality")).toBeInTheDocument(); + const select = screen.getByRole('combobox'); + expect(select).toBeInTheDocument(); + userEvent.selectOptions(select, 'Kenya'); + }); +}); 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 index d868802aa..c9f5bb1d4 100644 --- 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 @@ -2,7 +2,7 @@ import React from 'react'; import styles from './../field.scss'; import { Input } from '../../input/basic-input/input/input.component'; import { Select, SelectItem } from 'carbon-components-react'; -import { useConceptAnswers } from './person-attributes.resource'; +import { useConceptAnswers } from '../field.resource'; import { PersonAttributeTypeResponse } from '../../patient-registration-types'; export interface CodedPersonAttributeFieldProps { @@ -19,7 +19,7 @@ export function CodedPersonAttributeField({ const { data: conceptAnswers, isLoading: isLoadingConceptAnswers } = useConceptAnswers(answerConceptSetUuid); return ( -
+
{!isLoadingConceptAnswers && conceptAnswers?.length ? (