Skip to content

Commit

Permalink
Merge branch 'main' into O3-4016
Browse files Browse the repository at this point in the history
  • Loading branch information
chibongho authored Oct 22, 2024
2 parents 9267589 + fe14d60 commit 8d6847f
Show file tree
Hide file tree
Showing 16 changed files with 421 additions and 119 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions packages/esm-patient-registration-app/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface FieldDefinition {
required: boolean;
matches?: string;
};
locationTag?: string;
answerConceptSetUuid?: string;
customConceptAnswers?: Array<CustomConceptAnswer>;
}
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -126,7 +128,7 @@ export const Identifiers: React.FC = () => {
className={styles.configureIdentifiersButton}
onClick={() => setShowIdentifierOverlay(true)}
size={isDesktop(layout) ? 'sm' : 'md'}>
{t('configure', 'Configure')} <ArrowRight size={16} />
{t('configure', 'Configure')} <ArrowRight className={styles.arrowRightIcon} size={16} />
</Button>
</div>
</UserHasAccess>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<RegistrationConfig>);

const mockResourcesContextValue = {
addressTemplate: null,
addressTemplate: null as unknown as AddressTemplate,
currentSession: mockSession.data,
identifierTypes: [],
relationshipTypes: [],
Expand Down Expand Up @@ -52,7 +53,7 @@ const mockContextValues: PatientRegistrationContextProps = {
setInitialFormValues: jest.fn(),
validationSchema: null,
values: mockInitialFormValues,
};
} as unknown as PatientRegistrationContextProps;

describe('Identifiers', () => {
beforeEach(() => {
Expand Down Expand Up @@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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<string>('');
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 (
<div
className={classNames(styles.customField, styles.halfWidthInDesktopView, styles.locationAttributeFieldContainer)}>
<Layer>
<Field name={fieldName}>
{({ field, form: { touched, errors } }) => {
return (
<ComboBox
id={id}
name={`person-attribute-${personAttributeType.uuid}`}
titleText={label}
items={locationOptions}
placeholder={t('searchLocationPersonAttribute', 'Search location')}
onInputChange={handleInputChange}
required={required}
onChange={handleSelect}
selectedItem={selectedItem}
invalid={errors[fieldName] && touched[fieldName]}
typeahead
/>
);
}}
</Field>
</Layer>
{loadingNewData && (
<div className={styles.loadingContainer}>
<InlineLoading />
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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<LocationEntry>;
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<FetchResponse<LocationResponse>, Error>(
constructUrl,
openmrsFetch,
);

return useMemo(
() => ({
locations: data?.data?.entry || [],
isLoading,
loadingNewData: isValidating,
}),
[data, isLoading, isValidating],
);
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -41,6 +42,16 @@ export function PersonAttributeField({ fieldDefinition }: PersonAttributeFieldPr
required={fieldDefinition.validation?.required ?? false}
/>
);
case 'org.openmrs.Location':
return (
<LocationPersonAttributeField
personAttributeType={personAttributeType}
locationTag={fieldDefinition.locationTag}
label={fieldDefinition.label}
id={fieldDefinition?.id}
required={fieldDefinition.validation?.required ?? false}
/>
);
default:
return (
<InlineNotification kind="error" title="Error">
Expand Down
Original file line number Diff line number Diff line change
@@ -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: '',
Expand Down Expand Up @@ -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);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading

0 comments on commit 8d6847f

Please sign in to comment.