From ab5d8584f815f5874caef19f9bec2ba8654d25ff Mon Sep 17 00:00:00 2001 From: Mark Goodrich Date: Fri, 26 May 2023 15:50:48 -0400 Subject: [PATCH 1/3] (feat) O3-1911: Reference Mappings instead of Concept UUIDs (feat) O3-2129: Form Engine: Support Default Labels form obs questions and answers --- .../ohri-encounter-form.component.tsx | 43 +++++++++++++++++-- src/hooks/useConcepts.tsx | 14 ++++++ src/utils/ohri-form-helper.ts | 24 +++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useConcepts.tsx diff --git a/src/components/encounter/ohri-encounter-form.component.tsx b/src/components/encounter/ohri-encounter-form.component.tsx index de3bc81b5..35807eba9 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 = []; 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.push(field.questionOptions.concept); + } + if (field.questionOptions?.answers) { + field.questionOptions.answers.forEach((answer) => { + if (answer.concept) { + conceptReferencesTemp.push(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..dc8001481 --- /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: Array) { + // 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=${references.join(',')}&v=${conceptRepresentation}`, + openmrsFetch, + ); + return { concepts: data?.data.results, error, isLoading }; +} diff --git a/src/utils/ohri-form-helper.ts b/src/utils/ohri-form-helper.ts index 27b6a7f12..d8331f36b 100644 --- a/src/utils/ohri-form-helper.ts +++ b/src/utils/ohri-form-helper.ts @@ -120,3 +120,27 @@ export function parseToLocalDateTime(dateString: string): Date { } return dateObj; } + +/** + * Given a mapping reference to a concept by source and term (ie "CIEL:1234") and a set of concepts, return the concept, if any, with that mapping + * + * @param reference + * @param concepts + */ +export function findConceptByReference(reference: string, concepts) { + // we only currently support uuids and reference term pairs in the format "SOURCE:TERM", so if no ":" return null + if (!reference.includes(':')) { + return null; + } + + 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() + ); + }); + }); +} From 2f26e49b022c89398e5dd19b3094007c1700a2c9 Mon Sep 17 00:00:00 2001 From: Mark Goodrich Date: Fri, 26 May 2023 15:51:54 -0400 Subject: [PATCH 2/3] (feat) O3-1911: Reference Mappings instead of Concept UUIDs (feat) O3-2129: Form Engine: Support Default Labels form obs questions and answers --- src/components/encounter/ohri-encounter-form.component.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/encounter/ohri-encounter-form.component.tsx b/src/components/encounter/ohri-encounter-form.component.tsx index 35807eba9..358966228 100644 --- a/src/components/encounter/ohri-encounter-form.component.tsx +++ b/src/components/encounter/ohri-encounter-form.component.tsx @@ -137,7 +137,7 @@ export const OHRIEncounterForm: React.FC = ({ conceptReferencesTemp.push(field.questionOptions.concept); } if (field.questionOptions?.answers) { - field.questionOptions.answers.forEach((answer) => { + field.questionOptions.answers.forEach(answer => { if (answer.concept) { conceptReferencesTemp.push(answer.concept); } From 1a2470f1b6556744b328485c7361801b91e88e6d Mon Sep 17 00:00:00 2001 From: Mark Goodrich Date: Tue, 30 May 2023 17:05:26 -0400 Subject: [PATCH 3/3] (feat) O3-1911: Reference Mappings instead of Concept UUIDs (feat) O3-2129: Form Engine: Support Default Labels form obs questions and answers --- __mocks__/concepts.mock.json | 140 ++++++++++++++++++ .../ohri-forms/reference-by-mapping-form.json | 54 +++++++ src/api/types.ts | 1 - .../ohri-encounter-form.component.tsx | 6 +- src/hooks/useConcepts.tsx | 4 +- src/ohri-form.component.test.tsx | 28 +++- src/utils/ohri-form-helper.test.ts | 83 +++++++++++ src/utils/ohri-form-helper.ts | 33 +++-- 8 files changed, 327 insertions(+), 22 deletions(-) create mode 100644 __mocks__/concepts.mock.json create mode 100644 __mocks__/forms/ohri-forms/reference-by-mapping-form.json create mode 100644 src/utils/ohri-form-helper.test.ts 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 358966228..1b4b42ee3 100644 --- a/src/components/encounter/ohri-encounter-form.component.tsx +++ b/src/components/encounter/ohri-encounter-form.component.tsx @@ -107,7 +107,7 @@ export const OHRIEncounterForm: React.FC = ({ // given the form, flatten the fields and pull out all concept references const [flattenedFields, conceptReferences] = useMemo(() => { const flattenedFieldsTemp = []; - const conceptReferencesTemp = []; + const conceptReferencesTemp = new Set(); form.pages?.forEach(page => page.sections?.forEach(section => { section.questions?.forEach(question => { @@ -134,12 +134,12 @@ export const OHRIEncounterForm: React.FC = ({ ); flattenedFieldsTemp.forEach(field => { if (field.questionOptions?.concept) { - conceptReferencesTemp.push(field.questionOptions.concept); + conceptReferencesTemp.add(field.questionOptions.concept); } if (field.questionOptions?.answers) { field.questionOptions.answers.forEach(answer => { if (answer.concept) { - conceptReferencesTemp.push(answer.concept); + conceptReferencesTemp.add(answer.concept); } }); } diff --git a/src/hooks/useConcepts.tsx b/src/hooks/useConcepts.tsx index dc8001481..c87b8eb0e 100644 --- a/src/hooks/useConcepts.tsx +++ b/src/hooks/useConcepts.tsx @@ -4,10 +4,10 @@ import { openmrsFetch, OpenmrsResource } from '@openmrs/esm-framework'; const conceptRepresentation = 'custom:(uuid,display,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))'; -export function useConcepts(references: Array) { +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=${references.join(',')}&v=${conceptRepresentation}`, + `/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 d8331f36b..097d41e24 100644 --- a/src/utils/ohri-form-helper.ts +++ b/src/utils/ohri-form-helper.ts @@ -122,25 +122,28 @@ export function parseToLocalDateTime(dateString: string): Date { } /** - * Given a mapping reference to a concept by source and term (ie "CIEL:1234") and a set of concepts, return the concept, if any, with that mapping + * 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 + * @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) { - // we only currently support uuids and reference term pairs in the format "SOURCE:TERM", so if no ":" return null - if (!reference.includes(':')) { - return null; - } - - const [source, code] = reference.split(':'); + 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() - ); + 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; + }); + } }