From c16113f412ac4b2428a548fa68ca1d56ef94231f Mon Sep 17 00:00:00 2001 From: Usama Idriss Kakumba Date: Thu, 21 Nov 2024 13:49:52 +0300 Subject: [PATCH] feat: add support for nested obsgroups --- src/adapters/obs-adapter.test.ts | 279 +++++++++++++++++- src/components/group/obs-group.component.tsx | 61 ++-- src/components/group/obs-group.scss | 4 + .../field/form-field-renderer.component.tsx | 2 +- src/components/repeat/repeat.component.tsx | 12 +- src/hooks/useFormFields.ts | 56 ++-- .../encounter/encounter-form-processor.ts | 2 +- .../encounter/encounter-processor-helper.ts | 90 +++--- .../default-schema-transformer.ts | 11 +- 9 files changed, 420 insertions(+), 97 deletions(-) diff --git a/src/adapters/obs-adapter.test.ts b/src/adapters/obs-adapter.test.ts index 98ad20515..0a0ae706b 100644 --- a/src/adapters/obs-adapter.test.ts +++ b/src/adapters/obs-adapter.test.ts @@ -1,6 +1,6 @@ import { type FormContextProps } from '../provider/form-provider'; import { type FormField } from '../types'; -import { hasPreviousObsValueChanged, findObsByFormField, ObsAdapter } from './obs-adapter'; +import { findObsByFormField, hasPreviousObsValueChanged, ObsAdapter } from './obs-adapter'; const formContext = { methods: null, @@ -944,3 +944,280 @@ describe('findObsByFormField', () => { expect(matchedObs[0]).toBe(obsList[3]); }); }); + +describe('ObsAdapter - handling nested obsGroups', () => { + const createNestedFields = (): FormField => ({ + label: 'Parent obsGroup', + type: 'obsGroup', + required: false, + id: 'parentObsgroup', + questionOptions: { + rendering: 'group', + concept: '163770AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + questions: [ + { + label: 'Health Center', + type: 'obs', + required: false, + id: 'healthCenter', + questionOptions: { + rendering: 'select', + concept: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [ + { + concept: '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Family member', + }, + { + concept: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Health clinic/post', + }, + { + concept: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Other', + }, + ], + }, + }, + { + label: 'Nested obsGroup', + type: 'obsGroup', + required: false, + id: 'nestedObsgroup', + questionOptions: { + rendering: 'group', + concept: '3f824eeb-8452-4df0-b346-6ed056cbc5b9', + }, + questions: [ + { + label: 'Comment', + type: 'obs', + required: false, + id: 'comment', + questionOptions: { + rendering: 'textarea', + concept: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }, + { + label: 'Other Diagnoses', + type: 'obs', + required: false, + id: 'otherDiagnoses', + questionOptions: { + rendering: 'select', + concept: '159947AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [ + { + concept: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Diagnosis certainty', + }, + ], + }, + }, + ], + }, + ], + }); + + const createNestedObs = () => ({ + uuid: 'encounter-uuid', + obs: [ + { + uuid: 'parent-group-uuid', + concept: { + uuid: '163770AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + groupMembers: [ + { + uuid: 'health-center-uuid', + concept: { + uuid: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + value: { + uuid: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + formFieldPath: 'rfe-forms-healthCenter', + }, + { + uuid: 'nested-group-uuid', + concept: { + uuid: '3f824eeb-8452-4df0-b346-6ed056cbc5b9', + }, + groupMembers: [ + { + uuid: 'comment-uuid', + concept: { + uuid: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + value: 'Test comment for nested group', + formFieldPath: 'rfe-forms-comment', + }, + { + uuid: 'diagnosis-uuid', + concept: { + uuid: '159947AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + value: { + uuid: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + formFieldPath: 'rfe-forms-otherDiagnoses', + }, + ], + }, + ], + }, + ], + }); + + beforeEach(() => { + formContext.domainObjectValue = createNestedObs(); + ObsAdapter.tearDown(); + }); + + it('should get initial values from nested obs groups', async () => { + const fields = createNestedFields(); + + const healthCenterField = fields.questions[0]; + const healthCenterValue = await ObsAdapter.getInitialValue( + healthCenterField, + formContext.domainObjectValue, + formContext, + ); + expect(healthCenterValue).toBe('1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + + const commentField = fields.questions[1].questions[0]; + const commentValue = await ObsAdapter.getInitialValue(commentField, formContext.domainObjectValue, formContext); + expect(commentValue).toBe('Test comment for nested group'); + + const diagnosisField = fields.questions[1].questions[1]; + const diagnosisValue = await ObsAdapter.getInitialValue(diagnosisField, formContext.domainObjectValue, formContext); + expect(diagnosisValue).toBe('159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + }); + + it('should transform values in nested groups', () => { + const fields = createNestedFields(); + + const healthCenterField = fields.questions[0]; + const healthCenterObs = ObsAdapter.transformFieldValue( + healthCenterField, + '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + formContext, + ); + expect(healthCenterObs).toEqual({ + concept: '1745AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-healthCenter', + value: '1560AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + + const commentField = fields.questions[1].questions[0]; + const commentObs = ObsAdapter.transformFieldValue(commentField, 'New test comment', formContext); + expect(commentObs).toEqual({ + concept: '161011AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-comment', + value: 'New test comment', + }); + }); + + it('should edit existing values in nested groups', () => { + formContext.sessionMode = 'edit'; + const fields = createNestedFields(); + + const healthCenterField = fields.questions[0]; + healthCenterField.meta = { + previousValue: { + uuid: 'health-center-uuid', + value: { + uuid: '1588AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }, + }; + + const healthCenterObs = ObsAdapter.transformFieldValue( + healthCenterField, + '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + formContext, + ); + + expect(healthCenterObs).toEqual({ + uuid: 'health-center-uuid', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-healthCenter', + value: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }); + + const commentField = fields.questions[1].questions[0]; + commentField.meta = { + previousValue: { + uuid: 'comment-uuid', + value: 'Test comment for nested group', + }, + }; + + const commentObs = ObsAdapter.transformFieldValue(commentField, 'Updated comment text', formContext); + + expect(commentObs).toEqual({ + uuid: 'comment-uuid', + formFieldNamespace: 'rfe-forms', + formFieldPath: 'rfe-forms-comment', + value: 'Updated comment text', + }); + }); + + it('should void deleted values in nested groups', () => { + formContext.sessionMode = 'edit'; + const fields = createNestedFields(); + + const commentField = fields.questions[1].questions[0]; + commentField.meta = { + previousValue: { + uuid: 'comment-uuid', + value: 'Test comment for nested group', + }, + }; + + ObsAdapter.transformFieldValue(commentField, '', formContext); + expect(commentField.meta.submission.voidedValue).toEqual({ + uuid: 'comment-uuid', + voided: true, + }); + expect(commentField.meta.submission.newValue).toBe(null); + + const diagnosisField = fields.questions[1].questions[1]; + diagnosisField.meta = { + previousValue: { + uuid: 'diagnosis-uuid', + value: { + uuid: '159394AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }, + }; + + ObsAdapter.transformFieldValue(diagnosisField, null, formContext); + expect(diagnosisField.meta.submission.voidedValue).toEqual({ + uuid: 'diagnosis-uuid', + voided: true, + }); + expect(diagnosisField.meta.submission.newValue).toBe(null); + }); + + it('should handle empty nested groups', async () => { + const emptyEncounter = { + uuid: 'encounter-uuid', + obs: [], + }; + + const fields = createNestedFields(); + + const healthCenterField = fields.questions[0]; + const healthCenterValue = await ObsAdapter.getInitialValue(healthCenterField, emptyEncounter, formContext); + expect(healthCenterValue).toBe(''); + + const commentField = fields.questions[1].questions[0]; + const commentValue = await ObsAdapter.getInitialValue(commentField, emptyEncounter, formContext); + expect(commentValue).toBe(''); + }); +}); diff --git a/src/components/group/obs-group.component.tsx b/src/components/group/obs-group.component.tsx index 29bea9cdb..b17233a31 100644 --- a/src/components/group/obs-group.component.tsx +++ b/src/components/group/obs-group.component.tsx @@ -1,29 +1,54 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import classNames from 'classnames'; import { type FormFieldInputProps } from '../../types'; import styles from './obs-group.scss'; -import { FormFieldRenderer } from '../renderer/field/form-field-renderer.component'; +import { FormFieldRenderer, isGroupField } from '../renderer/field/form-field-renderer.component'; import { useFormProviderContext } from '../../provider/form-provider'; +import { FormGroup } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; -export const ObsGroup: React.FC = ({ field }) => { +export const ObsGroup: React.FC = ({ field, ...restProps }) => { + const { t } = useTranslation(); const { formFieldAdapters } = useFormProviderContext(); + const showLabel = useMemo(() => field.questions?.length > 1, [field]); - const groupContent = field.questions - ?.filter((child) => !child.isHidden) - .map((child, index) => { - const keyId = child.id + '_' + index; - if (formFieldAdapters[child.type]) { - return ( -
-
- -
-
- ); - } - }); + const content = useMemo( + () => + field.questions + ?.filter((child) => !child.isHidden) + .map((child, index) => { + const keyId = `${child.id}_${index}`; - return
{groupContent}
; + if (child.type === 'obsGroup' && isGroupField(child.questionOptions.rendering)) { + return ( +
+ +
+ ); + } else if (formFieldAdapters[child.type]) { + return ( +
+
+ +
+
+ ); + } + }), + [field], + ); + + return ( +
+ {showLabel ? ( + + {content} + + ) : ( + content + )} +
+ ); }; export default ObsGroup; diff --git a/src/components/group/obs-group.scss b/src/components/group/obs-group.scss index 349cf4b81..40db60a7a 100644 --- a/src/components/group/obs-group.scss +++ b/src/components/group/obs-group.scss @@ -10,3 +10,7 @@ .groupContainer { margin: 0.5rem 0; } + +.boldLegend > legend { + font-weight: bolder; +} diff --git a/src/components/renderer/field/form-field-renderer.component.tsx b/src/components/renderer/field/form-field-renderer.component.tsx index 0bdd9580c..80a99fd67 100644 --- a/src/components/renderer/field/form-field-renderer.component.tsx +++ b/src/components/renderer/field/form-field-renderer.component.tsx @@ -237,6 +237,6 @@ export function isUnspecifiedSupported(question: FormField) { ); } -function isGroupField(rendering: RenderType) { +export function isGroupField(rendering: RenderType) { return rendering === 'group' || rendering === 'repeating'; } diff --git a/src/components/repeat/repeat.component.tsx b/src/components/repeat/repeat.component.tsx index 28aaba4ae..90c1041bb 100644 --- a/src/components/repeat/repeat.component.tsx +++ b/src/components/repeat/repeat.component.tsx @@ -1,5 +1,4 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { FormGroup } from '@carbon/react'; import { useTranslation } from 'react-i18next'; import type { FormField, FormFieldInputProps, RenderType } from '../../types'; import { evaluateAsyncExpression, evaluateExpression } from '../../utils/expression-runner'; @@ -78,6 +77,7 @@ const Repeat: React.FC = ({ field }) => { }); } } + const clonedField = cloneRepeatField(field, null, counter); // run necessary expressions if (clonedField.type === 'obsGroup') { @@ -168,15 +168,7 @@ const Repeat: React.FC = ({ field }) => { return ( - {isGrouped ? ( -
- - {nodes} - -
- ) : ( -
{nodes}
- )} +
{nodes}
); }; diff --git a/src/hooks/useFormFields.ts b/src/hooks/useFormFields.ts index e267ecbac..2d079a734 100644 --- a/src/hooks/useFormFields.ts +++ b/src/hooks/useFormFields.ts @@ -1,35 +1,45 @@ import { useMemo } from 'react'; -import { type FormSchema, type FormField } from '../types'; +import { type FormField, type FormSchema } from '../types'; export function useFormFields(form: FormSchema): { formFields: FormField[]; conceptReferences: Set } { const [flattenedFields, conceptReferences] = useMemo(() => { - const flattenedFieldsTemp = []; + const flattenedFieldsTemp: FormField[] = []; const conceptReferencesTemp = new Set(); + + const flattenFields = (fields: FormField[]) => { + fields.forEach((field) => { + flattenedFieldsTemp.push(field); + + // If the field is an obsGroup, we need to flatten its nested questions + if (field.type === 'obsGroup' && field.questions) { + field.questions.forEach((groupedField) => { + groupedField.meta.groupId = field.id; + flattenFields([groupedField]); + }); + } + + // Collect concept references + 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); + } + }); + } + }); + }; + form.pages?.forEach((page) => page.sections?.forEach((section) => { - section.questions?.forEach((question) => { - flattenedFieldsTemp.push(question); - if (question.type == 'obsGroup') { - question.questions.forEach((groupedField) => { - groupedField.meta.groupId = question.id; - flattenedFieldsTemp.push(groupedField); - }); - } - }); + if (section.questions) { + flattenFields(section.questions); + } }), ); - 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]; }, [form]); diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 2ff47387b..3be2185ef 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -100,7 +100,7 @@ export class EncounterFormProcessor extends FormProcessor { field.meta.fixedValue = field.value; delete field.value; } - if (field.questionOptions?.rendering == 'group') { + if (field.questionOptions?.rendering == 'group' || field.type === 'obsGroup') { field.questions?.forEach((child) => { child.readonly = child.readonly ?? field.readonly; return prepareFormField(child, section, page, schema); diff --git a/src/processors/encounter/encounter-processor-helper.ts b/src/processors/encounter/encounter-processor-helper.ts index 852187109..d4fbeb7d1 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -1,16 +1,16 @@ import { - type PatientProgram, type FormField, + type FormProcessorContextProps, type OpenmrsEncounter, type OpenmrsObs, type PatientIdentifier, + type PatientProgram, type PatientProgramPayload, - type FormProcessorContextProps, } from '../../types'; import { saveAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api'; import { hasRendering, hasSubmission } from '../../utils/common-utils'; import dayjs from 'dayjs'; -import { voidObs, constructObs, assignedObsIds } from '../../adapters/obs-adapter'; +import { assignedObsIds, constructObs, voidObs } from '../../adapters/obs-adapter'; import { type FormContextProps } from '../../provider/form-provider'; import { ConceptTrue } from '../../constants'; import { DefaultValueValidator } from '../../validators/default-value-validator'; @@ -185,43 +185,53 @@ export function getMutableSessionProps(context: FormContextProps) { // Helpers function prepareObs(obsForSubmission: OpenmrsObs[], fields: FormField[]) { - fields - .filter((field) => hasSubmittableObs(field)) - .forEach((field) => { - if ((field.isHidden || field.isParentHidden) && field.meta.previousValue) { - const valuesArray = Array.isArray(field.meta.previousValue) - ? field.meta.previousValue - : [field.meta.previousValue]; - addObsToList( - obsForSubmission, - valuesArray.map((obs) => voidObs(obs)), - ); - return; - } - if (field.type == 'obsGroup') { - if (field.meta.submission?.voidedValue) { - addObsToList(obsForSubmission, field.meta.submission.voidedValue); - return; - } - const obsGroup = constructObs(field, null); - if (field.meta.previousValue) { - obsGroup.uuid = field.meta.previousValue.uuid; - } - field.questions.forEach((groupedField) => { - if (hasSubmission(groupedField)) { - addObsToList(obsGroup.groupMembers, groupedField.meta.submission.newValue); - addObsToList(obsGroup.groupMembers, groupedField.meta.submission.voidedValue); - } - }); - if (obsGroup.groupMembers.length || obsGroup.voided) { - addObsToList(obsForSubmission, obsGroup); - } - } - if (hasSubmission(field)) { - addObsToList(obsForSubmission, field.meta.submission.newValue); - addObsToList(obsForSubmission, field.meta.submission.voidedValue); - } - }); + fields.filter((field) => hasSubmittableObs(field)).forEach((field) => processObsField(obsForSubmission, field)); +} + +function processObsField(obsForSubmission: OpenmrsObs[], field: FormField) { + if ((field.isHidden || field.isParentHidden) && field.meta.previousValue) { + const valuesArray = Array.isArray(field.meta.previousValue) ? field.meta.previousValue : [field.meta.previousValue]; + addObsToList( + obsForSubmission, + valuesArray.map((obs) => voidObs(obs)), + ); + return; + } + + if (field.type === 'obsGroup') { + processObsGroup(obsForSubmission, field); + } else if (hasSubmission(field)) { + // For non-group obs with a submission + addObsToList(obsForSubmission, field.meta.submission.newValue); + addObsToList(obsForSubmission, field.meta.submission.voidedValue); + } +} + +function processObsGroup(obsForSubmission: OpenmrsObs[], groupField: FormField) { + if (groupField.meta.submission?.voidedValue) { + addObsToList(obsForSubmission, groupField.meta.submission.voidedValue); + return; + } + + const obsGroup = constructObs(groupField, null); + if (groupField.meta.previousValue) { + obsGroup.uuid = groupField.meta.previousValue.uuid; + } + + groupField.questions.forEach((nestedField) => { + if (nestedField.type === 'obsGroup') { + const nestedObsGroup: OpenmrsObs[] = []; + processObsGroup(nestedObsGroup, nestedField); + addObsToList(obsGroup.groupMembers, nestedObsGroup); + } else if (hasSubmission(nestedField)) { + addObsToList(obsGroup.groupMembers, nestedField.meta.submission.newValue); + addObsToList(obsGroup.groupMembers, nestedField.meta.submission.voidedValue); + } + }); + + if (obsGroup.groupMembers?.length || obsGroup.voided) { + addObsToList(obsForSubmission, obsGroup); + } } function prepareOrders(fields: FormField[]) { diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index 0433ae339..fb686b961 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -1,4 +1,4 @@ -import { type FormField, type FormSchemaTransformer, type FormSchema, type RenderType } from '../types'; +import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType } from '../types'; import { isTrue } from '../utils/boolean-utils'; import { hasRendering } from '../utils/common-utils'; @@ -39,8 +39,13 @@ function handleQuestion(question: FormField, form: FormSchema) { setFieldValidators(question); transformByType(question); transformByRendering(question); - if (question?.questions?.length) { - question.questions.forEach((question) => handleQuestion(question, form)); + + if (question.questions?.length) { + if (question.type === 'obsGroup' && question.questions.length) { + question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, form)); + } else { + question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, form)); + } } } catch (error) { console.error(error);