diff --git a/package.json b/package.json index b4f900fb8..2c0565fe1 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "sass": "^1.29.0", "sass-loader": "^10.1.0", "swc-loader": "^0.2.3", - "turbo": "^2.0.6", + "turbo": "^2.2.3", "typedoc": "^0.22.15", "typescript": "^4.0.3", "webpack": "^5.74.0", diff --git a/packages/esm-patient-registration-app/src/config-schema.ts b/packages/esm-patient-registration-app/src/config-schema.ts index 1e2cadd78..7dc42f171 100644 --- a/packages/esm-patient-registration-app/src/config-schema.ts +++ b/packages/esm-patient-registration-app/src/config-schema.ts @@ -18,6 +18,7 @@ export interface FieldDefinition { required: boolean; matches?: string; }; + locationTag?: string; answerConceptSetUuid?: string; customConceptAnswers?: Array; } @@ -183,6 +184,12 @@ export const esmPatientRegistrationSchema = { _description: 'Optional RegEx for testing the validity of the input.', }, }, + locationTag: { + _type: Type.String, + _default: null, + _description: + 'Only for fields with "person attribute" type `org.openmrs.Location`. This filters the list of location options in the dropdown based on their location tag. By default, all locations are shown.', + }, answerConceptSetUuid: { _type: Type.ConceptUuid, _default: null, 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 7f3595392..887abea6b 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 @@ -99,7 +99,13 @@ align-items: center; } +.arrowRightIcon { + fill: currentColor !important; +} + .configureIdentifiersButton { + display: flex; + align-items: center; margin: 0 0 layout.$spacing-05 layout.$spacing-05; svg { @@ -115,6 +121,17 @@ margin-bottom: layout.$spacing-05; } +.locationAttributeFieldContainer { + position: relative; + + .loadingContainer { + background-color: colors.$white; + position: absolute; + right: layout.$spacing-07; + bottom: layout.$spacing-02; + } +} + :global(.omrs-breakpoint-lt-desktop) { .grid { grid-template-columns: 1fr; diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx index cfd27ba5e..44dbdca60 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.component.tsx @@ -25,14 +25,16 @@ export function setIdentifierSource( selectedSource: IdentifierSource; } { const autoGeneration = identifierSource?.autoGenerationOption?.automaticGenerationEnabled; + const manualEntryEnabled = identifierSource?.autoGenerationOption?.manualEntryEnabled; return { selectedSource: identifierSource, autoGeneration, - identifierValue: autoGeneration - ? 'auto-generated' - : identifierValue !== 'auto-generated' - ? identifierValue - : initialValue, + identifierValue: + autoGeneration && !manualEntryEnabled + ? 'auto-generated' + : identifierValue !== 'auto-generated' + ? identifierValue + : initialValue, }; } @@ -126,7 +128,7 @@ export const Identifiers: React.FC = () => { className={styles.configureIdentifiersButton} onClick={() => setShowIdentifierOverlay(true)} size={isDesktop(layout) ? 'sm' : 'md'}> - {t('configure', 'Configure')} + {t('configure', 'Configure')} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx index 0b9e2ab20..6865cc827 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/field/id/id-field.test.tsx @@ -1,18 +1,19 @@ import React from 'react'; -import userEvent from '@testing-library/user-event'; -import { render, screen } from '@testing-library/react'; import { Form, Formik } from 'formik'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework'; -import { Identifiers } from './id-field.component'; -import { mockOpenmrsId, mockIdentifierTypes, mockPatient, mockSession } from '__mocks__'; -import { type RegistrationConfig, esmPatientRegistrationSchema } from '../../../config-schema'; -import { type Resources, ResourcesContext } from '../../../offline.resources'; +import { type AddressTemplate, type IdentifierSource } from '../../patient-registration.types'; +import { mockIdentifierTypes, mockOpenmrsId, mockPatient, mockSession } from '__mocks__'; +import { esmPatientRegistrationSchema, type RegistrationConfig } from '../../../config-schema'; +import { ResourcesContext, type Resources } from '../../../offline.resources'; import { PatientRegistrationContext, type PatientRegistrationContextProps } from '../../patient-registration-context'; +import { Identifiers, setIdentifierSource } from './id-field.component'; const mockUseConfig = jest.mocked(useConfig); const mockResourcesContextValue = { - addressTemplate: null, + addressTemplate: null as unknown as AddressTemplate, currentSession: mockSession.data, identifierTypes: [], relationshipTypes: [], @@ -52,7 +53,7 @@ const mockContextValues: PatientRegistrationContextProps = { setInitialFormValues: jest.fn(), validationSchema: null, values: mockInitialFormValues, -}; +} as unknown as PatientRegistrationContextProps; describe('Identifiers', () => { beforeEach(() => { @@ -120,3 +121,21 @@ describe('Identifiers', () => { expect(screen.getByRole('button', { name: 'Close overlay' })).toBeInTheDocument(); }); }); + +describe('setIdentifierSource', () => { + describe('auto-generation', () => { + it('should return auto-generated as the identifier value', () => { + const identifierSource = { autoGenerationOption: { automaticGenerationEnabled: true } } as IdentifierSource; + const { identifierValue } = setIdentifierSource(identifierSource, '', ''); + expect(identifierValue).toBe('auto-generated'); + }); + + it('should return the identifier value when manual entry enabled', () => { + const identifierSource = { + autoGenerationOption: { automaticGenerationEnabled: true, manualEntryEnabled: true }, + } as IdentifierSource; + const { identifierValue } = setIdentifierSource(identifierSource, '10001V', ''); + expect(identifierValue).toBe('10001V'); + }); + }); +}); diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.component.tsx new file mode 100644 index 000000000..ba1156a23 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.component.tsx @@ -0,0 +1,105 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import classNames from 'classnames'; +import { Field, useField } from 'formik'; +import { type PersonAttributeTypeResponse } from '../../patient-registration.types'; +import styles from './../field.scss'; +import { useLocations } from './location-person-attribute-field.resource'; +import { ComboBox, InlineLoading, Layer } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; + +export interface LocationPersonAttributeFieldProps { + id: string; + personAttributeType: PersonAttributeTypeResponse; + label?: string; + locationTag: string; + required?: boolean; +} + +export function LocationPersonAttributeField({ + personAttributeType, + id, + label, + locationTag, + required, +}: LocationPersonAttributeFieldProps) { + const { t } = useTranslation(); + const fieldName = `attributes.${personAttributeType.uuid}`; + const [field, meta, { setValue }] = useField(`attributes.${personAttributeType.uuid}`); + const [searchQuery, setSearchQuery] = useState(''); + const { locations, isLoading, loadingNewData } = useLocations(locationTag || null, searchQuery); + const prevLocationOptions = useRef([]); + + const locationOptions = useMemo(() => { + if (!(isLoading && loadingNewData)) { + const newOptions = locations.map(({ resource: { id, name } }) => ({ value: id, label: name })); + prevLocationOptions.current = newOptions; + return newOptions; + } + return prevLocationOptions.current; + }, [locations, isLoading, loadingNewData]); + + const selectedItem = useMemo(() => { + if (typeof meta.value === 'string') { + return locationOptions.find(({ value }) => value === meta.value) || null; + } + if (typeof meta.value === 'object' && meta.value) { + return locationOptions.find(({ value }) => value === meta.value.uuid) || null; + } + return null; + }, [locationOptions, meta.value]); + + // Callback for when updating the combobox input + const handleInputChange = useCallback( + (value: string | null) => { + if (value) { + // If the value exists in the locationOptions (i.e. a label matches the input), exit the function + if (locationOptions.find(({ label }) => label === value)) return; + // If the input is a new value, set the search query + setSearchQuery(value); + // Clear the current selected value since the input doesn't match any existing options + setValue(null); + } + }, + [locationOptions, setValue], + ); + const handleSelect = useCallback( + ({ selectedItem }) => { + if (selectedItem) { + setValue(selectedItem.value); + } + }, + [setValue], + ); + + return ( +
+ + + {({ field, form: { touched, errors } }) => { + return ( + + ); + }} + + + {loadingNewData && ( +
+ +
+ )} +
+ ); +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.resource.tsx b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.resource.tsx new file mode 100644 index 000000000..5ce5d8837 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/field/person-attributes/location-person-attribute-field.resource.tsx @@ -0,0 +1,48 @@ +import { useMemo } from 'react'; +import { type FetchResponse, fhirBaseUrl, openmrsFetch, useDebounce } from '@openmrs/esm-framework'; +import { type LocationEntry, type LocationResponse } from '@openmrs/esm-service-queues-app/src/types'; +import useSWR from 'swr'; + +interface UseLocationsResult { + locations: Array; + isLoading: boolean; + loadingNewData: boolean; +} + +export function useLocations(locationTag: string | null, searchQuery: string = ''): UseLocationsResult { + const debouncedSearchQuery = useDebounce(searchQuery); + + const constructUrl = useMemo(() => { + let url = `${fhirBaseUrl}/Location?`; + let urlSearchParameters = new URLSearchParams(); + urlSearchParameters.append('_summary', 'data'); + + if (!debouncedSearchQuery) { + urlSearchParameters.append('_count', '10'); + } + + if (locationTag) { + urlSearchParameters.append('_tag', locationTag); + } + + if (typeof debouncedSearchQuery === 'string' && debouncedSearchQuery != '') { + urlSearchParameters.append('name:contains', debouncedSearchQuery); + } + + return url + urlSearchParameters.toString(); + }, [locationTag, debouncedSearchQuery]); + + const { data, error, isLoading, isValidating } = useSWR, Error>( + constructUrl, + openmrsFetch, + ); + + return useMemo( + () => ({ + locations: data?.data?.entry || [], + isLoading, + loadingNewData: isValidating, + }), + [data, isLoading, isValidating], + ); +} 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 index fe16305a8..8dd8f4347 100644 --- 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 @@ -1,11 +1,12 @@ import React, { useMemo } from 'react'; -import { InlineNotification, TextInputSkeleton, SkeletonText } from '@carbon/react'; +import { InlineNotification, TextInputSkeleton } 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'; +import { LocationPersonAttributeField } from './location-person-attribute-field.component'; export interface PersonAttributeFieldProps { fieldDefinition: FieldDefinition; @@ -41,6 +42,16 @@ export function PersonAttributeField({ fieldDefinition }: PersonAttributeFieldPr required={fieldDefinition.validation?.required ?? false} /> ); + case 'org.openmrs.Location': + return ( + + ); default: 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 index ae625319e..b54a3ee48 100644 --- 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 @@ -1,8 +1,11 @@ import { FormManager } from './form-manager'; import { type FormValues } from './patient-registration.types'; +import { generateIdentifier } from './patient-registration.resource'; jest.mock('./patient-registration.resource'); +const mockGenerateIdentifier = generateIdentifier as jest.Mock; + const formValues: FormValues = { patientUuid: '', givenName: '', @@ -66,5 +69,20 @@ describe('FormManager', () => { }, ]); }); + + it('should generate identifier if it has autoGeneration and manual entry disabled', async () => { + formValues.identifiers.foo.autoGeneration = true; + formValues.identifiers.foo.selectedSource.autoGenerationOption.manualEntryEnabled = false; + mockGenerateIdentifier.mockResolvedValue({ data: { identifier: '10001V' } }); + await FormManager.savePatientIdentifiers(true, undefined, formValues.identifiers, {}, 'Nyc'); + expect(mockGenerateIdentifier.mock.calls).toHaveLength(1); + }); + + it('should not generate identifiers if manual entry enabled and identifier value given', async () => { + formValues.identifiers.foo.autoGeneration = true; + formValues.identifiers.foo.selectedSource.autoGenerationOption.manualEntryEnabled = true; + await FormManager.savePatientIdentifiers(true, undefined, formValues.identifiers, {}, 'Nyc'); + expect(mockGenerateIdentifier.mock.calls).toHaveLength(0); + }); }); }); 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 index b682d038f..531e8dc44 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/form-manager.ts +++ b/packages/esm-patient-registration-app/src/patient-registration/form-manager.ts @@ -241,11 +241,16 @@ export class FormManager { initialValue, } = patientIdentifier; - const identifier = !autoGeneration - ? identifierValue - : await ( - await generateIdentifier(selectedSource.uuid) - ).data.identifier; + const autoGenerationManualEntry = + autoGeneration && selectedSource?.autoGenerationOption?.manualEntryEnabled && !!identifierValue; + + const identifier = + !autoGeneration || autoGenerationManualEntry + ? identifierValue + : await ( + await generateIdentifier(selectedSource.uuid) + ).data.identifier; + const identifierToCreate = { uuid: identifierUuid, identifier, 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 index 3a3166ceb..a7053512c 100644 --- 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 @@ -26,7 +26,8 @@ const IdentifierInput: React.FC = ({ patientIdentifier, fi () => identifierTypes.find((identifierType) => identifierType.uuid === patientIdentifier.identifierTypeUuid), [patientIdentifier, identifierTypes], ); - const { autoGeneration, initialValue, identifierValue, identifierName, required } = patientIdentifier; + const { autoGeneration, initialValue, identifierValue, identifierName, required, selectedSource } = patientIdentifier; + const manualEntryEnabled = selectedSource?.autoGenerationOption?.manualEntryEnabled; const [hideInputField, setHideInputField] = useState(autoGeneration || initialValue === identifierValue); const name = `identifiers.${fieldName}.identifierValue`; const [identifierField, identifierFieldMeta] = useField(name); @@ -46,8 +47,8 @@ const IdentifierInput: React.FC = ({ patientIdentifier, fi setFieldValue(`identifiers.${fieldName}`, { ...patientIdentifier, identifierValue: initialValue, - selectedSource: null, - autoGeneration: false, + selectedSource, + autoGeneration, } as PatientIdentifierValue); // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialValue, setHideInputField]); @@ -57,6 +58,7 @@ const IdentifierInput: React.FC = ({ patientIdentifier, fi setFieldValue(`identifiers.${fieldName}`, { ...patientIdentifier, ...setIdentifierSource(identifierType?.identifierSources?.[0], initialValue, initialValue), + ...(autoGeneration && manualEntryEnabled && { identifierValue: initialValue ?? '' }), }); }; @@ -83,9 +85,12 @@ const IdentifierInput: React.FC = ({ patientIdentifier, fi } }; + const showEditButton = !required && hideInputField && (!!initialValue || manualEntryEnabled); + const showResetButton = + (!!initialValue && initialValue !== identifierValue) || (!hideInputField && manualEntryEnabled); return (
- {!autoGeneration && !hideInputField ? ( + {!hideInputField ? ( = ({ patientIdentifier, fi /> ) : (
-

{identifierName}

-

+

+ {required ? identifierName : `${t('optionalIdentifierLabel', { identifierName })}`} +

+

{autoGeneration ? t('autoGeneratedPlaceholderText', 'Auto-generated') : identifierValue}

@@ -110,10 +117,11 @@ const IdentifierInput: React.FC = ({ patientIdentifier, fi )}
)} -
- {!patientIdentifier.required && patientIdentifier.initialValue && hideInputField && ( +
+ {showEditButton && ( )} - {initialValue && initialValue !== identifierValue && ( + {showResetButton && (