Skip to content

Commit

Permalink
(feat) (feat) O3-3367 Add support for person attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
samuelmale authored and CynthiaKamau committed Nov 21, 2024
1 parent e0b0512 commit 7b25040
Show file tree
Hide file tree
Showing 18 changed files with 292 additions and 55 deletions.
54 changes: 54 additions & 0 deletions src/adapters/person-attributes-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type PersonAttribute, type OpenmrsResource } from '@openmrs/esm-framework';
import { type FormContextProps } from '../provider/form-provider';
import { type FormField, type FormFieldValueAdapter, type FormProcessorContextProps } from '../types';
import { clearSubmission } from '../utils/common-utils';
import { isEmpty } from '../validators/form-validator';

export const PersonAttributesAdapter: FormFieldValueAdapter = {
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
return null;
}
field.meta.submission.newValue = {
value: value,
attributeType: field.questionOptions?.attribute?.type,
};
return field.meta.submission.newValue;
},
getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
const rendering = field.questionOptions.rendering;

const personAttributeValue = context?.customDependencies.personAttributes.find(
(attribute: PersonAttribute) => attribute.attributeType.uuid === field.questionOptions.attribute?.type,
)?.value;
if (rendering === 'text') {
if (typeof personAttributeValue === 'string') {
return personAttributeValue;
} else if (
personAttributeValue &&
typeof personAttributeValue === 'object' &&
'display' in personAttributeValue
) {
return personAttributeValue?.display;
}
} else if (rendering === 'ui-select-extended') {
if (personAttributeValue && typeof personAttributeValue === 'object' && 'uuid' in personAttributeValue) {
return personAttributeValue?.uuid;
}
}
return null;
},
getPreviousValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
return null;
},
getDisplayValue: function (field: FormField, value: any) {
if (value?.display) {
return value.display;
}
return value;
},
tearDown: function (): void {
return;
},
};
20 changes: 19 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { fhirBaseUrl, openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
import { encounterRepresentation } from '../constants';
import { type OpenmrsForm, type PatientIdentifier, type PatientProgramPayload } from '../types';
import { isUuid } from '../utils/boolean-utils';
Expand Down Expand Up @@ -180,3 +180,21 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati
body: JSON.stringify(patientIdentifier),
});
}

export function savePersonAttribute(personAttribute: PersonAttribute, personUuid: string) {
let url: string;

if (personAttribute.uuid) {
url = `${restBaseUrl}/person/${personUuid}/attribute/${personAttribute.uuid}`;
} else {
url = `${restBaseUrl}/person/${personUuid}/attribute`;
}

return openmrsFetch(url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(personAttribute),
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
selectedItem={selectedItem}
placeholder={isSearchable ? t('search', 'Search') + '...' : null}
shouldFilterItem={({ item, inputValue }) => {
if (!inputValue) {
if (!inputValue || items.find((item) => item.uuid == field.value)) {
// Carbon's initial call at component mount
return true;
}
Expand Down
62 changes: 61 additions & 1 deletion src/components/renderer/field/fieldLogic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { codedTypes } from '../../../constants';
import { type FormContextProps } from '../../../provider/form-provider';
import { type FormField } from '../../../types';
import { type FormFieldValidator, type SessionMode, type ValidationResult, type FormField } from '../../../types';
import { isTrue } from '../../../utils/boolean-utils';
import { hasRendering } from '../../../utils/common-utils';
import { evaluateAsyncExpression, evaluateExpression } from '../../../utils/expression-runner';
Expand Down Expand Up @@ -65,6 +65,21 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
},
).then((result) => {
setValue(dependent.id, result);
// validate calculated value
const { errors, warnings } = validateFieldValue(dependent, result, context.formFieldValidators, {
formFields,
values,
expressionContext: { patient, mode: sessionMode },
});
if (!dependent.meta.submission) {
dependent.meta.submission = {};
}
dependent.meta.submission.errors = errors;
dependent.meta.submission.warnings = warnings;
if (!errors.length) {
context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context);
}
updateFormField(dependent);
});
}
// evaluate hide
Expand Down Expand Up @@ -212,3 +227,48 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
setForm(formJson);
}
}

export interface ValidatorConfig {
formFields: FormField[];
values: Record<string, any>;
expressionContext: {
patient: fhir.Patient;
mode: SessionMode;
};
}

export function validateFieldValue(
field: FormField,
value: any,
validators: Record<string, FormFieldValidator>,
context: ValidatorConfig,
): { errors: ValidationResult[]; warnings: ValidationResult[] } {
const errors: ValidationResult[] = [];
const warnings: ValidationResult[] = [];

if (field.meta.submission?.unspecified) {
return { errors: [], warnings: [] };
}

try {
field.validators.forEach((validatorConfig) => {
const results = validators[validatorConfig.type]?.validate?.(field, value, {
...validatorConfig,
...context,
});
if (results) {
results.forEach((result) => {
if (result.resultType === 'error') {
errors.push(result);
} else if (result.resultType === 'warning') {
warnings.push(result);
}
});
}
});
} catch (error) {
console.error(error);
}

return { errors, warnings };
}
47 changes: 1 addition & 46 deletions src/components/renderer/field/form-field-renderer.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { getFieldControlWithFallback, getRegisteredControl } from '../../../regi
import styles from './form-field-renderer.scss';
import { isTrue } from '../../../utils/boolean-utils';
import UnspecifiedField from '../../inputs/unspecified/unspecified.component';
import { handleFieldLogic } from './fieldLogic';
import { handleFieldLogic, validateFieldValue } from './fieldLogic';

export interface FormFieldRendererProps {
fieldId: string;
Expand Down Expand Up @@ -221,51 +221,6 @@ function ErrorFallback({ error }) {
);
}

export interface ValidatorConfig {
formFields: FormField[];
values: Record<string, any>;
expressionContext: {
patient: fhir.Patient;
mode: SessionMode;
};
}

function validateFieldValue(
field: FormField,
value: any,
validators: Record<string, FormFieldValidator>,
context: ValidatorConfig,
): { errors: ValidationResult[]; warnings: ValidationResult[] } {
const errors: ValidationResult[] = [];
const warnings: ValidationResult[] = [];

if (field.meta.submission?.unspecified) {
return { errors: [], warnings: [] };
}

try {
field.validators.forEach((validatorConfig) => {
const results = validators[validatorConfig.type]?.validate?.(field, value, {
...validatorConfig,
...context,
});
if (results) {
results.forEach((result) => {
if (result.resultType === 'error') {
errors.push(result);
} else if (result.resultType === 'warning') {
warnings.push(result);
}
});
}
});
} catch (error) {
console.error(error);
}

return { errors, warnings };
}

/**
* Determines whether a field can be unspecified
*/
Expand Down
16 changes: 16 additions & 0 deletions src/datasources/person-attribute-datasource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { BaseOpenMRSDataSource } from './data-source';

export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource {
constructor() {
super(null);
}

async fetchData(searchTerm: string, config?: Record<string, any>, uuid?: string): Promise<any[]> {
const rep = 'v=custom:(uuid,display)';
const url = `${restBaseUrl}/location?${rep}`;
const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url);

return data?.results;
}
}
2 changes: 1 addition & 1 deletion src/datasources/select-concept-answers-datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource {
}

fetchData(searchTerm: string, config?: Record<string, any>): Promise<any[]> {
const apiUrl = this.url.replace('conceptUuid', config.referencedValue || config.concept);
const apiUrl = this.url.replace('conceptUuid', config.concept || config.referencedValue);
return openmrsFetch(apiUrl).then(({ data }) => {
return data['setMembers'].length ? data['setMembers'] : data['answers'];
});
Expand Down
16 changes: 16 additions & 0 deletions src/form-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,8 @@ describe('Form engine component', () => {

describe('Calculated values', () => {
it('should evaluate BMI', async () => {
const saveEncounterMock = jest.spyOn(api, 'saveEncounter');

await act(async () => renderForm(null, bmiForm));

const bmiField = screen.getByRole('textbox', { name: /bmi/i });
Expand All @@ -694,9 +696,17 @@ describe('Form engine component', () => {
expect(heightField).toHaveValue(150);
expect(weightField).toHaveValue(50);
expect(bmiField).toHaveValue('22.2');

await user.click(screen.getByRole('button', { name: /save/i }));

const encounter = saveEncounterMock.mock.calls[0][1];
expect(encounter.obs.length).toEqual(3);
expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bmi').value).toBe(22.2);
});

it('should evaluate BSA', async () => {
const saveEncounterMock = jest.spyOn(api, 'saveEncounter');

await act(async () => renderForm(null, bsaForm));

const bsaField = screen.getByRole('textbox', { name: /bsa/i });
Expand All @@ -710,6 +720,12 @@ describe('Form engine component', () => {
expect(heightField).toHaveValue(190.5);
expect(weightField).toHaveValue(95);
expect(bsaField).toHaveValue('2.24');

await user.click(screen.getByRole('button', { name: /save/i }));

const encounter = saveEncounterMock.mock.calls[0][1];
expect(encounter.obs.length).toEqual(3);
expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bsa').value).toBe(2.24);
});

it('should evaluate EDD', async () => {
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/usePersonAttributes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
import { useEffect, useState } from 'react';

export const usePersonAttributes = (patientUuid: string) => {
const [personAttributes, setPersonAttributes] = useState<Array<PersonAttribute>>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
if (patientUuid) {
openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`)
.then((response) => {
setPersonAttributes(response?.data?.attributes);
setIsLoading(false);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, [patientUuid]);

return {
personAttributes,
error,
isLoading: isLoading,
};
};
Loading

0 comments on commit 7b25040

Please sign in to comment.