diff --git a/__mocks__/concepts.mock.json b/__mocks__/concepts.mock.json new file mode 100644 index 000000000..fd7f6b747 --- /dev/null +++ b/__mocks__/concepts.mock.json @@ -0,0 +1,140 @@ +{ + "results": [ + { + "uuid": "3cd6f600-26fe-102b-80cb-0017a47871b2", + "display": "Yes", + "conceptMappings": [ + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "SNOMED CT" + }, + "code": "373066001" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "YES" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "CIEL" + }, + "code": "1065" + } + } + ] + }, + { + "uuid": "3cd6f86c-26fe-102b-80cb-0017a47871b2", + "display": "No", + "conceptMappings": [ + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "NO" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "CIEL" + }, + "code": "1066" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "SNOMED CT" + }, + "code": "373067005" + } + } + ] + }, + { + "uuid": "3cccf632-26fe-102b-80cb-0017a47871b2", + "display": "Cough", + "conceptMappings": [ + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "SNOMED CT" + }, + "code": "263731006" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "AMPATH" + }, + "code": "5956" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "SNOMED CT" + }, + "code": "49727002" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "COUGH" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "107" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "CIEL" + }, + "code": "143264" + } + } + ] + }, + { + "uuid": "f8134959-62d2-4f94-af6c-3580312b07a0", + "display": "Occurrence of trauma", + "conceptMappings": [ + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "8848" + } + }, + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "PIH" + }, + "code": "Occurrence of trauma" + } + } + ] + } + ] +} diff --git a/__mocks__/forms/ohri-forms/reference-by-mapping-form.json b/__mocks__/forms/ohri-forms/reference-by-mapping-form.json new file mode 100644 index 000000000..65647507a --- /dev/null +++ b/__mocks__/forms/ohri-forms/reference-by-mapping-form.json @@ -0,0 +1,54 @@ +{ + "encounterType": "92fd09b4-5335-4f7e-9f63-b2a663fd09a6", + "name": "Reference By Mapping Form", + "processor": "EncounterFormProcessor", + "referencedForms": [], + "uuid": "3e360508-2357-4098-a3d8-48d793a08fa0", + "version": "1.0", + "pages": [ + { + "label": "First Page", + "sections": [ + { + "label": "Another Section", + "isExpanded": "true", + "questions": [ + { + "type": "obs", + "questionOptions": { + "rendering": "radio", + "concept": "PIH:Occurrence of trauma", + "answers": [ + { + "concept": "PIH:Yes" + }, + { + "concept": "PIH:No" + } + ] + }, + "id": "traumaQuestion" + }, + { + "type": "obs", + "questionOptions": { + "rendering": "radio", + "concept": "PIH:COUGH", + "answers": [ + { + "concept": "PIH:Yes" + }, + { + "concept": "PIH:No" + } + ] + }, + "id": "coughQuestion" + } + ] + } + ] + } + ], + "description": "comment" +} diff --git a/src/api/types.ts b/src/api/types.ts index 0046b5b8d..7fd3b69aa 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -150,7 +150,6 @@ export interface OHRIFormQuestionOptions { maxLength?: string; minLength?: string; showDate?: string; - conceptMappings?: Array>; answers?: Array>; weeksList?: string; locationTag?: string; diff --git a/src/components/encounter/ohri-encounter-form.component.tsx b/src/components/encounter/ohri-encounter-form.component.tsx index de3bc81b5..1b4b42ee3 100644 --- a/src/components/encounter/ohri-encounter-form.component.tsx +++ b/src/components/encounter/ohri-encounter-form.component.tsx @@ -15,6 +15,7 @@ import { OHRIFormContext } from '../../ohri-form-context'; import { cascadeVisibityToChildFields, evaluateFieldReadonlyProp, + findConceptByReference, findPagesWithErrors, voidObsValueOnFieldHidden, } from '../../utils/ohri-form-helper'; @@ -28,6 +29,7 @@ import { scrollIntoView } from '../../utils/ohri-sidebar'; import { useEncounter } from '../../hooks/useEncounter'; import { useInitialValues } from '../../hooks/useInitialValues'; import { useEncounterRole } from '../../hooks/useEncounterRole'; +import { useConcepts } from '../../hooks/useConcepts'; interface OHRIEncounterFormProps { formJson: OHRIFormSchema; @@ -102,8 +104,10 @@ export const OHRIEncounterForm: React.FC = ({ ); const { encounterRole } = useEncounterRole(); - const flattenedFields = useMemo(() => { + // given the form, flatten the fields and pull out all concept references + const [flattenedFields, conceptReferences] = useMemo(() => { const flattenedFieldsTemp = []; + const conceptReferencesTemp = new Set(); form.pages?.forEach(page => page.sections?.forEach(section => { section.questions?.forEach(question => { @@ -128,7 +132,19 @@ export const OHRIEncounterForm: React.FC = ({ }); }), ); - return flattenedFieldsTemp; + flattenedFieldsTemp.forEach(field => { + if (field.questionOptions?.concept) { + conceptReferencesTemp.add(field.questionOptions.concept); + } + if (field.questionOptions?.answers) { + field.questionOptions.answers.forEach(answer => { + if (answer.concept) { + conceptReferencesTemp.add(answer.concept); + } + }); + } + }); + return [flattenedFieldsTemp, conceptReferencesTemp]; }, []); const { initialValues: tempInitialValues, isFieldEncounterBindingComplete } = useInitialValues( @@ -137,6 +153,9 @@ export const OHRIEncounterForm: React.FC = ({ encounterContext, ); + // look up concepts via their references + const { concepts } = useConcepts(conceptReferences); + const addScrollablePages = useCallback(() => { formJson.pages.forEach(page => { if (!page.isSubform) { @@ -211,6 +230,24 @@ export const OHRIEncounterForm: React.FC = ({ }, ); } + + // for each question and answer, see if we find a matching concept, and, if so: + // 1) replace the concept reference with uuid (for the case when the form references the concept by mapping) + // 2) use the concept display as the label if no label specified + const matchingConcept = findConceptByReference(field.questionOptions.concept, concepts); + field.questionOptions.concept = matchingConcept ? matchingConcept.uuid : field.questionOptions.concept; + field.label = field.label ? field.label : matchingConcept?.display; + if (field.questionOptions.answers) { + field.questionOptions.answers = field.questionOptions.answers.map(answer => { + const matchingAnswerConcept = findConceptByReference(answer.concept, concepts); + return { + ...answer, + concept: matchingAnswerConcept ? matchingAnswerConcept.uuid : answer.concept, + label: answer.label ? answer.label : matchingAnswerConcept?.display, + }; + }); + } + return field; }), ); @@ -237,7 +274,7 @@ export const OHRIEncounterForm: React.FC = ({ setIsFieldInitializationComplete(true); } } - }, [tempInitialValues]); + }, [tempInitialValues, concepts]); useEffect(() => { if (sessionMode == 'enter' && !isTrue(formJson.formOptions?.usePreviousValueDisabled)) { diff --git a/src/hooks/useConcepts.tsx b/src/hooks/useConcepts.tsx new file mode 100644 index 000000000..c87b8eb0e --- /dev/null +++ b/src/hooks/useConcepts.tsx @@ -0,0 +1,14 @@ +import useSWRImmutable from 'swr/immutable'; +import { openmrsFetch, OpenmrsResource } from '@openmrs/esm-framework'; + +const conceptRepresentation = + 'custom:(uuid,display,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))'; + +export function useConcepts(references: Set) { + // TODO: handle paging (ie when number of concepts greater than default limit per page) + const { data, error, isLoading } = useSWRImmutable<{ data: { results: Array } }, Error>( + `/ws/rest/v1/concept?references=${Array.from(references).join(',')}&v=${conceptRepresentation}`, + openmrsFetch, + ); + return { concepts: data?.data.results, error, isLoading }; +} diff --git a/src/ohri-form.component.test.tsx b/src/ohri-form.component.test.tsx index cb12ffd21..9be9a2f29 100644 --- a/src/ohri-form.component.test.tsx +++ b/src/ohri-form.component.test.tsx @@ -11,7 +11,9 @@ import next_visit_form from '../__mocks__/forms/ohri-forms/next-visit-test-form. import months_on_art_form from '../__mocks__/forms/ohri-forms/months-on-art-form.json'; import age_validation_form from '../__mocks__/forms/ohri-forms/age-validation-form.json'; import viral_load_status_form from '../__mocks__/forms/ohri-forms/viral-load-status-form.json'; +import reference_by_mapping_form from '../__mocks__/forms/ohri-forms/reference-by-mapping-form.json'; import external_data_source_form from '../__mocks__/forms/ohri-forms/external_data_source_form.json'; +import mock_concepts from '../__mocks__/concepts.mock.json'; import { mockPatient } from '../__mocks__/patient.mock'; import { mockSessionDataResponse } from '../__mocks__/session.mock'; import demoHtsOpenmrsForm from '../__mocks__/forms/omrs-forms/demo_hts-form.json'; @@ -20,11 +22,12 @@ import demoHtsOhriForm from '../__mocks__/forms/ohri-forms/demo_hts-form.json'; import { assertFormHasAllFields, findMultiSelectInput, - findNumberInput, + findNumberInput, findRadioGroupInput, findRadioGroupMember, findSelectInput, findTextOrDateInput, } from './utils/test-utils'; import { mockVisit } from '../__mocks__/visit.mock'; +import {async} from "rxjs"; ////////////////////////////////////////// ////// Base setup @@ -320,6 +323,29 @@ describe('OHRI Forms:', () => { }); }); + describe("Concept references", () => { + const conceptResourcePath = when((url: string) => url.includes('/ws/rest/v1/concept?references=PIH:Occurrence of trauma,PIH:Yes,PIH:No,PIH:COUGH')); + + when(mockOpenmrsFetch) + .calledWith(conceptResourcePath) + .mockReturnValue({ data: mock_concepts }); + + it('should add default labels based on concept display and substitute mapping references with uuids', async () => { + await act(async () => renderForm(null, reference_by_mapping_form)); + + const yes = await screen.findAllByRole('radio', { name: 'Yes' }) as Array; + const no = await screen.findAllByRole('radio', { name: 'No' }) as Array; + await assertFormHasAllFields(screen, [ + { fieldName: 'Cough', fieldType: 'radio' }, + { fieldName: 'Occurrence of trauma', fieldType: 'radio' }, + ]); + await act(async () => expect(no[0].value).toBe("3cd6f86c-26fe-102b-80cb-0017a47871b2")) + await act(async () => expect(no[1].value).toBe("3cd6f86c-26fe-102b-80cb-0017a47871b2")) + await act(async () => expect(yes[0].value).toBe("3cd6f600-26fe-102b-80cb-0017a47871b2")) + await act(async () => expect(yes[1].value).toBe("3cd6f600-26fe-102b-80cb-0017a47871b2")) + }) + }) + function renderForm(formUUID, formJson, intent?: string) { return act(() => { render( diff --git a/src/utils/ohri-form-helper.test.ts b/src/utils/ohri-form-helper.test.ts new file mode 100644 index 000000000..2bd17582f --- /dev/null +++ b/src/utils/ohri-form-helper.test.ts @@ -0,0 +1,83 @@ +import { findConceptByReference } from './ohri-form-helper'; + +describe('OHRI Form Helper', () => { + describe('findConceptByReference', () => { + const concepts = [ + { + uuid: '3cd6f600-26fe-102b-80cb-0017a47871b2', + display: 'Yes', + conceptMappings: [ + { + conceptReferenceTerm: { + conceptSource: { + name: 'SNOMED CT', + }, + code: '373066001', + }, + }, + { + conceptReferenceTerm: { + conceptSource: { + name: 'PIH', + }, + code: 'YES', + }, + }, + { + conceptReferenceTerm: { + conceptSource: { + name: 'CIEL', + }, + code: '1065', + }, + }, + ], + }, + { + uuid: '3cd6f86c-26fe-102b-80cb-0017a47871b2', + display: 'No', + conceptMappings: [ + { + conceptReferenceTerm: { + conceptSource: { + name: 'PIH', + }, + code: 'NO', + }, + }, + { + conceptReferenceTerm: { + conceptSource: { + name: 'CIEL', + }, + code: '1066', + }, + }, + { + conceptReferenceTerm: { + conceptSource: { + name: 'SNOMED CT', + }, + code: '373067005', + }, + }, + ], + }, + ]; + + it('should find concept by mapping', () => { + expect(findConceptByReference('CIEL:1066', concepts).uuid).toBe('3cd6f86c-26fe-102b-80cb-0017a47871b2'); + }); + it('should find concept by uuid', () => { + expect(findConceptByReference('3cd6f86c-26fe-102b-80cb-0017a47871b2', concepts).uuid).toBe( + '3cd6f86c-26fe-102b-80cb-0017a47871b2', + ); + }); + it('should return undefined if no match', () => { + expect(findConceptByReference('CIEL:9999', concepts)).toBeUndefined(); + }); + it('should return undefined if null input', () => { + expect(findConceptByReference(null, concepts)).toBeUndefined(); + }); + }); +}); diff --git a/src/utils/ohri-form-helper.ts b/src/utils/ohri-form-helper.ts index 27b6a7f12..097d41e24 100644 --- a/src/utils/ohri-form-helper.ts +++ b/src/utils/ohri-form-helper.ts @@ -120,3 +120,30 @@ export function parseToLocalDateTime(dateString: string): Date { } return dateObj; } + +/** + * Given a reference to a concept (either the uuid, or the source and reference term, ie "CIEL:1234") and a set of concepts, return matching concept, if any + * + * @param reference a uuid or source/term mapping, ie "3cd6f86c-26fe-102b-80cb-0017a47871b2" or "CIEL:1234" + * @param concepts + */ +export function findConceptByReference(reference: string, concepts) { + if (reference?.includes(':')) { + // handle mapping + const [source, code] = reference.split(':'); + + return concepts?.find(concept => { + return concept?.conceptMappings?.find(mapping => { + return ( + mapping?.conceptReferenceTerm?.conceptSource?.name.toUpperCase() === source.toUpperCase() && + mapping?.conceptReferenceTerm?.code.toUpperCase() === code.toUpperCase() + ); + }); + }); + } else { + // handle uuid + return concepts?.find(concept => { + return concept.uuid === reference; + }); + } +}