diff --git a/resources/seeds/Questionnaire/repeatable-group.yaml b/resources/seeds/Questionnaire/repeatable-group.yaml new file mode 100644 index 00000000..0646ced0 --- /dev/null +++ b/resources/seeds/Questionnaire/repeatable-group.yaml @@ -0,0 +1,20 @@ +id: repeatable-group +name: Repeatable Group +title: Repeatable Group +status: active +meta: + profile: + - https://beda.software/beda-emr-questionnaire +item: + - text: Items + type: group + linkId: surgeries-group + item: + - item: + - text: Text + type: string + linkId: repeatable-group-text + type: group + linkId: repeatable-group + repeats: true +resourceType: Questionnaire diff --git a/src/components/BaseQuestionnaireResponseForm/readonly-widgets/number.tsx b/src/components/BaseQuestionnaireResponseForm/readonly-widgets/number.tsx index 96a5a379..94399325 100644 --- a/src/components/BaseQuestionnaireResponseForm/readonly-widgets/number.tsx +++ b/src/components/BaseQuestionnaireResponseForm/readonly-widgets/number.tsx @@ -51,7 +51,7 @@ export function QuestionQuantity({ parentPath, questionItem }: QuestionItemProps const { linkId, text, hidden } = questionItem; const fieldName = [...parentPath, linkId, 0, 'value']; const { value } = useFieldController(fieldName, questionItem); - const quantity: Quantity | undefined = value?.Quantity; + const quantity: Quantity | undefined = value?.Quantity; if (hidden) { return null; @@ -60,7 +60,9 @@ export function QuestionQuantity({ parentPath, questionItem }: QuestionItemProps return ( {text} - {quantity && _.isNumber(quantity?.value) ? `${quantity.value} ${quantity.unit}` : '-'} + + {quantity && _.isNumber(quantity?.value) ? `${quantity.value} ${quantity.unit}` : '-'} + ); } diff --git a/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/__tests__/RepeatableGroups.test.tsx b/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/__tests__/RepeatableGroups.test.tsx new file mode 100644 index 00000000..11605a7b --- /dev/null +++ b/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/__tests__/RepeatableGroups.test.tsx @@ -0,0 +1,233 @@ +import { i18n } from '@lingui/core'; +import { I18nProvider } from '@lingui/react'; +import { screen, render, act, fireEvent, waitFor } from '@testing-library/react'; +import { Patient, Practitioner, QuestionnaireResponse } from 'fhir/r4b'; +import { expect, test, vi } from 'vitest'; + +import { getFHIRResources } from 'aidbox-react/lib/services/fhir'; +import { axiosInstance } from 'aidbox-react/lib/services/instance'; + +import { ensure, extractBundleResources, WithId, withRootAccess } from '@beda.software/fhir-react'; + +import { PatientDocument } from 'src/containers/PatientDetails/PatientDocument'; +import { createPatient, createPractitionerRole, loginAdminUser } from 'src/setupTests'; +import { ThemeProvider } from 'src/theme'; +import { evaluate } from 'src/utils'; + +type ProcedureCase = { + case: { text: string }[]; +}; + +const CASES: ProcedureCase[] = [ + { + case: [ + { + text: 'Test 1', + }, + ], + }, + { + case: [ + { + text: 'Test 2', + }, + { + text: 'Test 3', + }, + ], + }, + { + case: [ + { + text: 'Test 4', + }, + { + text: 'Test 5', + }, + { + text: 'Test 6', + }, + ], + }, + { + case: [ + { + text: 'Test 7', + }, + { + text: 'Test 8', + }, + { + text: 'Test 9', + }, + { + text: 'Test 10', + }, + { + text: 'Test 11', + }, + ], + }, +]; + +describe('Repeatable group creates correct questionnaire response', async () => { + async function setup() { + await loginAdminUser(); + return await withRootAccess(axiosInstance, async () => { + const patient = await createPatient({ + name: [{ given: ['John'], family: 'Smith' }], + }); + + const { practitioner, practitionerRole } = await createPractitionerRole({}); + + return { patient, practitioner, practitionerRole }; + }); + } + + async function renderRepeatableGroupForm(patient: Patient, practitioner: WithId) { + const onSuccess = vi.fn(); + + act(() => { + i18n.activate('en'); + }); + + render( + + + + + , + ); + + return onSuccess; + } + + test.each(CASES)( + 'Test group adding first and then filling all fields', + async (caseData) => { + const { patient, practitioner } = await setup(); + + const onSuccess = await renderRepeatableGroupForm(patient, practitioner); + + if (caseData.case.length > 1) { + const addAnotherAnswerButton = (await screen.findByText('Add another answer')).parentElement!; + caseData.case.slice(1).forEach(() => { + act(() => { + fireEvent.click(addAnotherAnswerButton); + }); + }); + } + + const textFields = await screen.findAllByTestId('repeatable-group-text'); + textFields.forEach((textField, textFieldIndex) => { + expect(textField).toBeEnabled(); + + const textInput = textField.querySelector('input')!; + act(() => { + fireEvent.change(textInput, { + target: { value: caseData.case[textFieldIndex]!.text }, + }); + }); + }); + + const submitButton = await screen.findByTestId('submit-button'); + expect(submitButton).toBeEnabled(); + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + + await withRootAccess(axiosInstance, async () => { + const qrsBundleRD = await getFHIRResources('QuestionnaireResponse', { + questionnaire: 'repeatable-group', + _sort: ['-createdAt', '_id'], + }); + + const qrs = extractBundleResources(ensure(qrsBundleRD)).QuestionnaireResponse; + expect(qrs.length).toBeGreaterThan(0); + + const currentQR = qrs[0]; + + const repeatableGroupTexts = evaluate( + currentQR, + "QuestionnaireResponse.repeat(item).where(linkId='repeatable-group-text')", + ); + expect(repeatableGroupTexts.length).toBe(caseData.case.length); + + repeatableGroupTexts.forEach((text, textIndex) => { + expect(text!.answer[0].value.string).toBe(caseData.case[textIndex]!.text); + }); + }); + }, + 60000, + ); + + test.each(CASES)( + 'Test filling all fields and adding one by one', + async (caseData) => { + const { patient, practitioner } = await setup(); + + const onSuccess = await renderRepeatableGroupForm(patient, practitioner); + + for (const [caseIndex, caseItem] of caseData.case.entries()) { + const textFields = await screen.findAllByTestId('repeatable-group-text'); + const textField = textFields[caseIndex]; + expect(textField).toBeEnabled(); + + const textInput = textField!.querySelector('input')!; + act(() => { + fireEvent.change(textInput, { + target: { value: caseItem.text }, + }); + }); + + const isLastText = caseIndex === caseData.case.length - 1; + if (!isLastText) { + const addAnotherAnswerButton = (await screen.findByText('Add another answer')).parentElement!; + act(() => { + fireEvent.click(addAnotherAnswerButton); + }); + } + } + + const submitButton = await screen.findByTestId('submit-button'); + expect(submitButton).toBeEnabled(); + + act(() => { + fireEvent.click(submitButton); + }); + + await waitFor(() => expect(onSuccess).toHaveBeenCalled()); + + await withRootAccess(axiosInstance, async () => { + const qrsBundleRD = await getFHIRResources('QuestionnaireResponse', { + questionnaire: 'repeatable-group', + _sort: ['-createdAt', '_id'], + }); + + const qrs = extractBundleResources(ensure(qrsBundleRD)).QuestionnaireResponse; + expect(qrs.length).toBeGreaterThan(0); + + const currentQR = qrs[0]; + + const repeatableGroupTexts = evaluate( + currentQR, + "QuestionnaireResponse.repeat(item).where(linkId='repeatable-group-text')", + ); + expect(repeatableGroupTexts.length).toBe(caseData.case.length); + + repeatableGroupTexts.forEach((text, textIndex) => { + expect(text!.answer[0].value.string).toBe(caseData.case[textIndex]!.text); + }); + }); + }, + 60000, + ); +}); diff --git a/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/index.tsx b/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/index.tsx index e99ac91b..a701226d 100644 --- a/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/index.tsx +++ b/src/components/BaseQuestionnaireResponseForm/widgets/Group/RepeatableGroups/index.tsx @@ -3,6 +3,7 @@ import { Trans } from '@lingui/macro'; import { Button } from 'antd'; import _ from 'lodash'; import React, { ReactNode } from 'react'; +import { useFormContext } from 'react-hook-form'; import { GroupItemProps, QuestionItems } from 'sdc-qrf'; import { useFieldController } from 'src/components/BaseQuestionnaireResponseForm/hooks'; @@ -13,6 +14,11 @@ import s from './RepeatableGroups.module.scss'; interface RepeatableGroupsProps { groupItem: GroupItemProps; renderGroup?: (props: RepeatableGroupProps) => ReactNode; + buildValue?: (existingItems: Array) => any; +} + +function defaultBuildValue(exisingItems: Array) { + return [...exisingItems, {}]; } export function RepeatableGroups(props: RepeatableGroupsProps) { @@ -20,8 +26,14 @@ export function RepeatableGroups(props: RepeatableGroupsProps) { const { parentPath, questionItem } = groupItem; const { linkId, required } = questionItem; const fieldName = [...parentPath, linkId]; - const { value, onChange } = useFieldController(fieldName, questionItem); + const { onChange } = useFieldController(fieldName, questionItem); + + const { getValues } = useFormContext(); + + const value = _.get(getValues(), fieldName); + const items = value.items && value.items.length ? value.items : required ? [{}] : []; + const buildValue = props.buildValue ?? defaultBuildValue; return (
@@ -56,8 +68,7 @@ export function RepeatableGroups(props: RepeatableGroupsProps) { type="link" className={s.addButton} onClick={() => { - const existingItems = items || []; - const updatedInput = { items: [...existingItems, {}] }; + const updatedInput = { items: buildValue(items ?? []) }; onChange(updatedInput); }} size="small" diff --git a/src/components/BaseQuestionnaireResponseForm/widgets/Group/index.tsx b/src/components/BaseQuestionnaireResponseForm/widgets/Group/index.tsx index 173e98e2..1760d573 100644 --- a/src/components/BaseQuestionnaireResponseForm/widgets/Group/index.tsx +++ b/src/components/BaseQuestionnaireResponseForm/widgets/Group/index.tsx @@ -1,4 +1,5 @@ import classNames from 'classnames'; +import _ from 'lodash'; import { GroupItemProps, QuestionItems } from 'sdc-qrf'; import { Text } from 'src/components/Typography'; diff --git a/src/setupTests.ts b/src/setupTests.ts index 8f4612d8..54fb8a4b 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -1,5 +1,6 @@ import '@testing-library/jest-dom/extend-expect'; +import { cleanup } from '@testing-library/react'; import { Consent, Encounter, @@ -220,6 +221,8 @@ afterEach(async () => { data: { query: `select drop_before_all(${txId});` }, }); }); + cleanup(); + vi.clearAllMocks(); }); // afterAll(() => {