diff --git a/__mocks__/forms/rfe-forms/conditional-required-form.json b/__mocks__/forms/rfe-forms/conditional-required-form.json index f743ef6e5..f3e3b4e09 100644 --- a/__mocks__/forms/rfe-forms/conditional-required-form.json +++ b/__mocks__/forms/rfe-forms/conditional-required-form.json @@ -52,8 +52,7 @@ "concept": "dc1942b2-5e50-4adc-949d-ad6c905f054e" }, "validators": [], - "hide": { - } + "hide": {} }, { "label": "If Unscheduled, actual text area scheduled date", @@ -278,4 +277,4 @@ "version": "1.0", "description": "des", "encounterType": "181820aa-88c9-479b-9077-af92f5364329" -} \ No newline at end of file +} diff --git a/__mocks__/forms/rfe-forms/labour_and_delivery_test_form.json b/__mocks__/forms/rfe-forms/labour_and_delivery_test_form.json index 099deec39..a164bdfa0 100644 --- a/__mocks__/forms/rfe-forms/labour_and_delivery_test_form.json +++ b/__mocks__/forms/rfe-forms/labour_and_delivery_test_form.json @@ -328,10 +328,7 @@ "readonly": "true", "questionOptions": { "concept": "164803AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", - "rendering": "text", - "calculate": { - "calculateExpression": "!isEmpty('MotherPtracker_id') ? myValue = customGenerateInfantPTrackerId(node.value.id, MotherPtracker_id) : ''" - } + "rendering": "text" }, "behaviours": [ { diff --git a/__mocks__/forms/rfe-forms/radio-button-form.json b/__mocks__/forms/rfe-forms/radio-button-form.json new file mode 100644 index 000000000..a52b4ccb4 --- /dev/null +++ b/__mocks__/forms/rfe-forms/radio-button-form.json @@ -0,0 +1,198 @@ +{ + "name": "A Radio Button Test", + "pages": [ + { + "label": "Page 1", + "sections": [ + { + "label": "1", + "isExpanded": "true", + "questions": [ + { + "label": "Visit type", + "type": "obs", + "required": "false", + "id": "visitType", + "questionOptions": { + "rendering": "radio", + "concept": "164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "conceptMappings": [ + { + "relationship": "SAME-AS", + "type": "CIEL", + "value": "164181" + } + ], + "answers": [ + { + "concept": "164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "New visit" + }, + { + "concept": "160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Return visit type" + }, + { + "concept": "5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Other" + } + ] + }, + "validators": [ + { + "type": "form_field" + }, + { + "type": "default_value" + } + ], + "meta": { + "submission": null, + "concept": { + "uuid": "164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Visit type", + "conceptClass": { + "uuid": "8d491e50-c2cc-11de-8d13-0010c6dffd0f", + "display": "Question" + }, + "answers": [ + { + "uuid": "164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "New visit" + }, + { + "uuid": "160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Return visit type" + }, + { + "uuid": "5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Other" + } + ], + "conceptMappings": [ + { + "conceptReferenceTerm": { + "conceptSource": { + "name": "CIEL" + }, + "code": "164181" + } + } + ] + } + }, + "isHidden": false, + "isRequired": false, + "isDisabled": false + }, + { + "label": "Visit punctuality", + "type": "obs", + "required": false, + "id": "visitPunctuality", + "questionOptions": { + "rendering": "radio", + "concept": "d81d4698-e78c-420d-aac5-cb1f606fb32e", + "answers": [ + { + "concept": "5c3ce9c9-75bd-4730-8878-08c03ec02e9d", + "label": "Early" + }, + { + "concept": "90af4b72-442a-4fcc-84c2-2bb1c0361737", + "label": "Late" + }, + { + "concept": "66cdc0a1-aa19-4676-af51-80f66d78d9eb", + "label": "On time" + } + ] + }, + "validators": [ + { + "type": "form_field" + }, + { + "type": "default_value" + } + ], + "meta": { + "submission": null, + "concept": { + "uuid": "d81d4698-e78c-420d-aac5-cb1f606fb32e", + "display": "Visit Punctuality", + "conceptClass": { + "uuid": "8d491e50-c2cc-11de-8d13-0010c6dffd0f", + "display": "Question" + }, + "answers": [ + { + "uuid": "66cdc0a1-aa19-4676-af51-80f66d78d9eb", + "display": "On time" + }, + { + "uuid": "90af4b72-442a-4fcc-84c2-2bb1c0361737", + "display": "Late" + }, + { + "uuid": "5c3ce9c9-75bd-4730-8878-08c03ec02e9d", + "display": "Early" + } + ], + "conceptMappings": [] + } + }, + "isHidden": false, + "isRequired": false, + "isDisabled": false + } + ], + "isHidden": false + } + ], + "isHidden": false + } + ], + "processor": "EncounterFormProcessor", + "encounterType": "0e8230ce-bd1d-43f5-a863-cf44344fa4b0", + "referencedForms": [], + "uuid": "fd067f44-99fd-4b07-b238-cea7b278c2b2", + "description": "A Radio Button Test", + "version": "1.0", + "translations": {}, + "conceptReferences": { + "164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "uuid": "164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Visit type" + }, + "164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "uuid": "164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "New visit" + }, + "160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "uuid": "160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Return visit type" + }, + "5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA": { + "uuid": "5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "display": "Other" + }, + "d81d4698-e78c-420d-aac5-cb1f606fb32e": { + "uuid": "d81d4698-e78c-420d-aac5-cb1f606fb32e", + "display": "Visit Punctuality" + }, + "5c3ce9c9-75bd-4730-8878-08c03ec02e9d": { + "uuid": "5c3ce9c9-75bd-4730-8878-08c03ec02e9d", + "display": "Early" + }, + "90af4b72-442a-4fcc-84c2-2bb1c0361737": { + "uuid": "90af4b72-442a-4fcc-84c2-2bb1c0361737", + "display": "Late" + }, + "66cdc0a1-aa19-4676-af51-80f66d78d9eb": { + "uuid": "66cdc0a1-aa19-4676-af51-80f66d78d9eb", + "display": "On time" + } + }, + "encounter": "" +} diff --git a/jest.config.js b/jest.config.js index ad9ab4dbe..eaa3dd468 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,7 +20,7 @@ module.exports = { '^dexie$': require.resolve('dexie'), '\\.(s?css)$': 'identity-obj-proxy', '@openmrs/esm-framework': '@openmrs/esm-framework/mock', - 'react-i18next': '/__mocks__/react-i18next.js', + '^react-i18next$': '/__mocks__/react-i18next.js', 'lodash-es': 'lodash', 'react-markdown': '/__mocks__/react-markdown.tsx', '^uuid$': '/node_modules/uuid/dist/index.js', diff --git a/src/components/inputs/radio/radio.component.tsx b/src/components/inputs/radio/radio.component.tsx index b9526c9d5..cadfb6605 100644 --- a/src/components/inputs/radio/radio.component.tsx +++ b/src/components/inputs/radio/radio.component.tsx @@ -37,7 +37,7 @@ const Radio: React.FC = ({ field, value, errors, warnings, legendText={} className={styles.boldedLegend} disabled={field.isDisabled} - invalid={errors.length > 0}> + invalid={errors?.length > 0}> { + const originalModule = jest.requireActual('../../../api'); + + return { + ...originalModule, + getPreviousEncounter: jest.fn().mockImplementation(() => Promise.resolve(null)), + }; +}); + +jest.mock('src/provider/form-provider', () => ({ + useFormProviderContext: jest.fn(), +})); + +const mockUseFormProviderContext = useFormProviderContext as jest.Mock; + +const addTestValues = { + field: { + label: 'Visit type', + type: 'obs', + required: false, + id: 'visitType', + questionOptions: { + rendering: 'radio', + concept: '164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + conceptMappings: [ + { + relationship: 'SAME-AS', + type: 'CIEL', + value: '164181', + }, + ], + answers: [ + { + concept: '164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'New visit', + }, + { + concept: '160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Return visit type', + }, + { + concept: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + label: 'Other', + }, + ], + }, + validators: [ + { + type: 'form_field', + }, + { + type: 'default_value', + }, + ], + meta: { + submission: null, + concept: { + uuid: '164181AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + display: 'Visit type', + conceptClass: { + uuid: '8d491e50-c2cc-11de-8d13-0010c6dffd0f', + display: 'Question', + }, + answers: [ + { + uuid: '164180AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + display: 'New visit', + }, + { + uuid: '160530AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + display: 'Return visit type', + }, + { + uuid: '5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + display: 'Other', + }, + ], + conceptMappings: [ + { + conceptReferenceTerm: { + conceptSource: { + name: 'CIEL', + }, + code: '164181', + }, + }, + ], + }, + }, + isHidden: false, + isRequired: false, + isDisabled: false, + }, + value: null, + errors: null, + warnings: undefined, + setFieldValue: null, +}; + +const renderForm = async (props) => { + await act(() => render()); +}; + +let formProcessor; + +const mockProviderValues = { + layoutType: 'small-desktop', + sessionMode: 'enter', + workspaceLayout: 'minimized', + formFieldAdapters: {}, + patient: mockPatient, + methods: undefined, + formJson: radioButtonFormSchema as any, + visit: mockVisit, + sessionDate: new Date(), + location: mockVisit.location, + currentProvider: mockVisit.encounters[0]?.encounterProvider, + processor: formProcessor, +}; + +describe('Radio Component', () => { + beforeEach(() => { + formProcessor = { + getInitialValues: jest.fn(), + }; + mockOpenmrsFetch.mockResolvedValue({ + data: { results: [{ ...radioButtonFormSchema }] }, + } as unknown as FetchResponse); + + mockUseSession.mockReturnValue(mockSessionDataResponse.data); + + mockUsePatient.mockReturnValue({ + isLoading: false, + patient: mockPatient, + patientUuid: mockPatient.id, + error: null, + }); + }); + + it('renders correctly', async () => { + mockUseFormProviderContext.mockReturnValue({ + ...mockProviderValues, + }); + + await renderForm(addTestValues); + expect(screen.getByText('Visit type')).toBeInTheDocument(); + expect(screen.getByLabelText('New visit')).toBeInTheDocument(); + expect(screen.getByLabelText('Return visit type')).toBeInTheDocument(); + expect(screen.getByLabelText('Other')).toBeInTheDocument(); + + const radioButtons = screen.getAllByRole('radio'); + expect(radioButtons).toHaveLength(3); + }); + + it('renders correctly on view mode', async () => { + mockUseFormProviderContext.mockReturnValue({ + ...mockProviderValues, + sessionMode: 'view', + }); + + await renderForm(addTestValues); + expect(screen.getByRole('button', { name: /visit type/i })).toBeInTheDocument(); + const visitTypeElements = screen.getAllByText('Visit type'); + expect(visitTypeElements.length).toBeGreaterThan(0); + }); + + it('renders radio buttons as disabled when the field is disabled', async () => { + mockUseFormProviderContext.mockReturnValue({ + ...mockProviderValues, + }); + + await renderForm({ ...addTestValues, field: { ...addTestValues.field, isDisabled: true } }); + expect(screen.getByText('Visit type')).toBeInTheDocument(); + + const radioButtons = screen.getAllByRole('radio'); + expect(radioButtons).toHaveLength(3); + radioButtons.forEach((radio) => { + expect(radio).toBeDisabled(); + }); + expect(screen.getByLabelText('New visit')).toBeDisabled(); + expect(screen.getByLabelText('Return visit type')).toBeDisabled(); + expect(screen.getByLabelText('Other')).toBeDisabled(); + }); +}); diff --git a/src/components/inputs/select/dropdown.component.tsx b/src/components/inputs/select/dropdown.component.tsx index 9583ce872..aa10e917d 100644 --- a/src/components/inputs/select/dropdown.component.tsx +++ b/src/components/inputs/select/dropdown.component.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { Dropdown as DropdownInput, Layer } from '@carbon/react'; import { shouldUseInlineLayout } from '../../../utils/form-helper'; diff --git a/src/components/inputs/unspecified/unspecified.component.tsx b/src/components/inputs/unspecified/unspecified.component.tsx index 2ce2fc794..f5c50e204 100644 --- a/src/components/inputs/unspecified/unspecified.component.tsx +++ b/src/components/inputs/unspecified/unspecified.component.tsx @@ -29,10 +29,10 @@ const UnspecifiedField: React.FC = ({ field, fieldValue, }, []); useEffect(() => { - if (field.meta.submission?.newValue) { + if (field.meta.submission?.unspecified && field.meta.submission.newValue) { setIsUnspecified(false); field.meta.submission.unspecified = false; - updateFormField({ ...field }); + updateFormField(field); } }, [field.meta?.submission]); diff --git a/src/components/label/label.component.tsx b/src/components/label/label.component.tsx index 40bced5bb..6c66b2ee7 100644 --- a/src/components/label/label.component.tsx +++ b/src/components/label/label.component.tsx @@ -10,7 +10,7 @@ type LabelProps = { const LabelField: React.FC = ({ value, tooltipText }) => { return (
- + {`${value}:`}
diff --git a/src/components/processor-factory/form-processor-factory.component.tsx b/src/components/processor-factory/form-processor-factory.component.tsx index a61487fd0..5021f8896 100644 --- a/src/components/processor-factory/form-processor-factory.component.tsx +++ b/src/components/processor-factory/form-processor-factory.component.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import useProcessorDependencies from '../../hooks/useProcessorDependencies'; import useInitialValues from '../../hooks/useInitialValues'; import { FormRenderer } from '../renderer/form/form-renderer.component'; -import { type FormSchema, type FormProcessorContextProps } from '../../types'; +import { type FormProcessorContextProps, type FormSchema } from '../../types'; import { CustomHooksRenderer } from '../renderer/custom-hooks-renderer.component'; import { useFormFields } from '../../hooks/useFormFields'; import { useConcepts } from '../../hooks/useConcepts'; @@ -31,9 +31,6 @@ const FormProcessorFactory = ({ const processor = useMemo(() => { const ProcessorClass = formProcessors[formJson.processor]; - if (processor) { - return processor; - } if (ProcessorClass) { return new ProcessorClass(formJson); } @@ -65,7 +62,6 @@ const FormProcessorFactory = ({ const useCustomHooks = processor.getCustomHooks().useCustomHooks; const [isLoadingCustomHooks, setIsLoadingCustomHooks] = useState(!!useCustomHooks); const [isLoadingProcessorDependencies, setIsLoadingProcessorDependencies] = useState(true); - const { isLoadingInitialValues, initialValues, diff --git a/src/components/renderer/custom-hooks-renderer.component.tsx b/src/components/renderer/custom-hooks-renderer.component.tsx index fb7c7d4fe..b1ae9ab98 100644 --- a/src/components/renderer/custom-hooks-renderer.component.tsx +++ b/src/components/renderer/custom-hooks-renderer.component.tsx @@ -9,11 +9,11 @@ export const CustomHooksRenderer = ({ }: { context: FormProcessorContextProps; setContext: (context: FormProcessorContextProps) => void; - useCustomHooks: (context: FormProcessorContextProps) => { + useCustomHooks: (context: Partial) => { data: any; isLoading: boolean; error: any; - updateContext: (setContext: (context: FormProcessorContextProps) => void) => void; + updateContext: (setContext: React.Dispatch>) => void; }; setIsLoadingCustomHooks: (isLoading: boolean) => void; }) => { diff --git a/src/components/renderer/field/form-field-renderer.component.tsx b/src/components/renderer/field/form-field-renderer.component.tsx index 43d7478bd..7abec1b76 100644 --- a/src/components/renderer/field/form-field-renderer.component.tsx +++ b/src/components/renderer/field/form-field-renderer.component.tsx @@ -1,17 +1,18 @@ import React, { useEffect, useMemo, useState } from 'react'; import { type FormField, - type RenderType, - type ValidationResult, + type FormFieldInputProps, type FormFieldValidator, + type FormFieldValueAdapter, + type RenderType, type SessionMode, + type ValidationResult, type ValueAndDisplay, } from '../../../types'; import { Controller, useWatch } from 'react-hook-form'; import { ToastNotification } from '@carbon/react'; import { useTranslation } from 'react-i18next'; import { ErrorBoundary } from 'react-error-boundary'; -import { type FormFieldValueAdapter, type FormFieldInputProps } from '../../../types'; import { hasRendering } from '../../../utils/common-utils'; import { useFormProviderContext } from '../../../provider/form-provider'; import { isEmpty } from '../../../validators/form-validator'; @@ -112,7 +113,7 @@ export const FormFieldRenderer = ({ fieldId, valueAdapter, repeatOptions }: Form value, formFieldValidators, { - fields: formFields, + formFields: formFields, values: getValues(), expressionContext: { patient, mode: sessionMode }, }, @@ -217,7 +218,7 @@ function ErrorFallback({ error }) { } export interface ValidatorConfig { - fields: FormField[]; + formFields: FormField[]; values: Record; expressionContext: { patient: fhir.Patient; diff --git a/src/form-engine.component.tsx b/src/form-engine.component.tsx index 1ee98f97c..752bd938e 100644 --- a/src/form-engine.component.tsx +++ b/src/form-engine.component.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { FormField, SessionMode, FormSchema } from './types'; +import type { FormField, FormSchema, SessionMode } from './types'; import { useSession, type Visit } from '@openmrs/esm-framework'; import { useFormJson } from '.'; import FormProcessorFactory from './components/processor-factory/form-processor-factory.component'; @@ -9,7 +9,7 @@ import { useWorkspaceLayout } from './hooks/useWorkspaceLayout'; import { FormFactoryProvider } from './provider/form-factory-provider'; import classNames from 'classnames'; import styles from './form-engine.scss'; -import { ButtonSet, Button, InlineLoading } from '@carbon/react'; +import { Button, ButtonSet, InlineLoading } from '@carbon/react'; import { I18nextProvider, useTranslation } from 'react-i18next'; import PatientBanner from './components/patient-banner/patient-banner.component'; import MarkdownWrapper from './components/inputs/markdown/markdown-wrapper.component'; diff --git a/src/form-engine.test.tsx b/src/form-engine.test.tsx index d448c7e5a..f50c2cf99 100644 --- a/src/form-engine.test.tsx +++ b/src/form-engine.test.tsx @@ -1,9 +1,15 @@ import React from 'react'; import dayjs from 'dayjs'; import userEvent from '@testing-library/user-event'; -import { act, cleanup, render, screen, within, fireEvent, waitFor } from '@testing-library/react'; -import { restBaseUrl } from '@openmrs/esm-framework'; -import { parseDate } from '@internationalized/date'; +import { act, cleanup, render, screen, within } from '@testing-library/react'; +import { + ExtensionSlot, + OpenmrsDatePicker, + openmrsFetch, + restBaseUrl, + usePatient, + useSession, +} from '@openmrs/esm-framework'; import { when } from 'jest-when'; import * as api from './api'; import { assertFormHasAllFields, findMultiSelectInput, findSelectInput } from './utils/test-utils'; @@ -11,72 +17,63 @@ import { evaluatePostSubmissionExpression } from './utils/post-submission-action import { mockPatient } from '__mocks__/patient.mock'; import { mockSessionDataResponse } from '__mocks__/session.mock'; import { mockVisit } from '__mocks__/visit.mock'; -import ageValidationForm from '__mocks__/forms/rfe-forms/age-validation-form.json'; -import bmiForm from '__mocks__/forms/rfe-forms/bmi-test-form.json'; -import bsaForm from '__mocks__/forms/rfe-forms/bsa-test-form.json'; import demoHtsForm from '__mocks__/forms/rfe-forms/demo_hts-form.json'; import demoHtsOpenmrsForm from '__mocks__/forms/afe-forms/demo_hts-form.json'; -import eddForm from '__mocks__/forms/rfe-forms/edd-test-form.json'; -import externalDataSourceForm from '__mocks__/forms/rfe-forms/external_data_source_form.json'; import filterAnswerOptionsTestForm from '__mocks__/forms/rfe-forms/filter-answer-options-test-form.json'; import htsPocForm from '__mocks__/packages/hiv/forms/hts_poc/1.1.json'; import labourAndDeliveryTestForm from '__mocks__/forms/rfe-forms/labour_and_delivery_test_form.json'; import mockConceptsForm from '__mocks__/concepts.mock.json'; -import monthsOnArtForm from '__mocks__/forms/rfe-forms/months-on-art-form.json'; -import nextVisitForm from '__mocks__/forms/rfe-forms/next-visit-test-form.json'; import obsGroupTestForm from '__mocks__/forms/rfe-forms/obs-group-test_form.json'; import postSubmissionTestForm from '__mocks__/forms/rfe-forms/post-submission-test-form.json'; import referenceByMappingForm from '__mocks__/forms/rfe-forms/reference-by-mapping-form.json'; import sampleFieldsForm from '__mocks__/forms/rfe-forms/sample_fields.json'; import testEnrolmentForm from '__mocks__/forms/rfe-forms/test-enrolment-form.json'; -import viralLoadStatusForm from '__mocks__/forms/rfe-forms/viral-load-status-form.json'; import historicalExpressionsForm from '__mocks__/forms/rfe-forms/historical-expressions-form.json'; import mockHxpEncounter from '__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json'; import requiredTestForm from '__mocks__/forms/rfe-forms/required-form.json'; import conditionalRequiredTestForm from '__mocks__/forms/rfe-forms/conditional-required-form.json'; import conditionalAnsweredForm from '__mocks__/forms/rfe-forms/conditional-answered-form.json'; +import ageValidationForm from '__mocks__/forms/rfe-forms/age-validation-form.json'; +import bmiForm from '__mocks__/forms/rfe-forms/bmi-test-form.json'; +import bsaForm from '__mocks__/forms/rfe-forms/bsa-test-form.json'; +import eddForm from '__mocks__/forms/rfe-forms/edd-test-form.json'; +import externalDataSourceForm from '__mocks__/forms/rfe-forms/external_data_source_form.json'; +import monthsOnArtForm from '__mocks__/forms/rfe-forms/months-on-art-form.json'; +import nextVisitForm from '__mocks__/forms/rfe-forms/next-visit-test-form.json'; +import viralLoadStatusForm from '__mocks__/forms/rfe-forms/viral-load-status-form.json'; + import FormEngine from './form-engine.component'; const patientUUID = '8673ee4f-e2ab-4077-ba55-4980f408773e'; const visit = mockVisit; -const mockOpenmrsFetch = jest.fn(); const formsResourcePath = when((url: string) => url.includes(`${restBaseUrl}/form/`)); -const clobdataResourcePath = when((url: string) => url.includes(`${restBaseUrl}/clobdata/`)); +const clobDataResourcePath = when((url: string) => url.includes(`${restBaseUrl}/clobdata/`)); global.ResizeObserver = require('resize-observer-polyfill'); +const mockOpenmrsFetch = jest.mocked(openmrsFetch); +const mockExtensionSlot = jest.mocked(ExtensionSlot); +const mockUsePatient = jest.mocked(usePatient); +const mockUseSession = jest.mocked(useSession); +const mockOpenmrsDatePicker = jest.mocked(OpenmrsDatePicker); + +mockOpenmrsDatePicker.mockImplementation(({ id, labelText, value, onChange, isInvalid, invalidText }) => { + return ( + <> + + { + onChange(dayjs(evt.target.value).toDate()); + }} + /> + {isInvalid && {invalidText}} + + ); +}); + when(mockOpenmrsFetch).calledWith(formsResourcePath).mockReturnValue({ data: demoHtsOpenmrsForm }); -when(mockOpenmrsFetch).calledWith(clobdataResourcePath).mockReturnValue({ data: demoHtsForm }); - -const locale = window.i18next.language == 'en' ? 'en-GB' : window.i18next.language; - -// jest.mock('@openmrs/esm-framework', () => { -// const originalModule = jest.requireActual('@openmrs/esm-framework'); - -// return { -// ...originalModule, -// createErrorHandler: jest.fn(), -// showNotification: jest.fn(), -// showToast: jest.fn(), -// getAsyncLifecycle: jest.fn(), -// usePatient: jest.fn().mockImplementation(() => ({ patient: mockPatient })), -// registerExtension: jest.fn(), -// useSession: jest.fn().mockImplementation(() => mockSessionDataResponse.data), -// openmrsFetch: jest.fn().mockImplementation((args) => mockOpenmrsFetch(args)), -// OpenmrsDatePicker: jest.fn().mockImplementation(({ id, labelText, value, onChange, isInvalid, invalidText }) => { -// return ( -// <> -// -// onChange(parseDate(dayjs(evt.target.value).format('YYYY-MM-DD')))} -// /> -// {isInvalid && invalidText && {invalidText}} -// -// ); -// }), -// }; -// }); +when(mockOpenmrsFetch).calledWith(clobDataResourcePath).mockReturnValue({ data: demoHtsForm }); jest.mock('../src/api', () => { const originalModule = jest.requireActual('../src/api'); @@ -92,10 +89,54 @@ jest.mock('../src/api', () => { }); jest.mock('./hooks/useRestMaxResultsCount', () => jest.fn().mockReturnValue({ systemSetting: { value: '50' } })); - -xdescribe('Form engine component', () => { +jest.mock('./hooks/useEncounterRole', () => ({ + useEncounterRole: jest.fn().mockReturnValue({ + isLoading: false, + encounterRole: { name: 'Clinician', uuid: 'clinician-uuid' }, + error: undefined, + }), +})); + +jest.mock('./hooks/useConcepts', () => ({ + useConcepts: jest.fn().mockImplementation((references: Set) => { + if ([...references].join(',').includes('PIH:Occurrence of trauma,PIH:Yes,PIH:No,PIH:COUGH')) { + return { + isLoading: false, + concepts: mockConceptsForm.results, + error: undefined, + }; + } + return { + isLoading: false, + concepts: undefined, + error: undefined, + }; + }), +})); + +describe('Form engine component', () => { const user = userEvent.setup(); + beforeEach(() => { + Object.defineProperty(window, 'i18next', { + writable: true, + configurable: true, + value: { + language: 'en', + t: jest.fn(), + }, + }); + + mockExtensionSlot.mockImplementation((ext) => <>{ext.name}); + mockUsePatient.mockImplementation(() => ({ + patient: mockPatient, + isLoading: false, + error: undefined, + patientUuid: mockPatient.id, + })); + mockUseSession.mockImplementation(() => mockSessionDataResponse.data); + }); + afterEach(() => { jest.useRealTimers(); }); @@ -187,23 +228,24 @@ xdescribe('Form engine component', () => { name: /reason for hospitalization:/i, }); - expect(hospitalizationHistoryDropdown); - expect(hospitalizationReasonDropdown); + expect(hospitalizationHistoryDropdown).toBeInTheDocument(); + expect(hospitalizationReasonDropdown).toBeInTheDocument(); - fireEvent.click(hospitalizationHistoryDropdown); + await user.click(hospitalizationHistoryDropdown); expect(screen.getByText(/yes/i)).toBeInTheDocument(); expect(screen.getByText(/no/i)).toBeInTheDocument(); - fireEvent.click(screen.getByText(/no/i)); + await user.click(screen.getByRole('option', { name: /no/i })); + await user.click(screen.getByText(/No/i)); - fireEvent.click(hospitalizationReasonDropdown); + await user.click(hospitalizationReasonDropdown); expect(screen.getByText(/Maternal Visit/i)).toBeInTheDocument(); expect(screen.getByText(/Emergency Visit/i)).toBeInTheDocument(); expect(screen.getByText(/Unscheduled visit late/i)).toBeInTheDocument(); - fireEvent.click(screen.getByText(/Maternal Visit/i)); + await user.click(screen.getByText(/Maternal Visit/i)); const errorMessage = screen.getByText( /Providing diagnosis but didn't answer that patient was hospitalized in question/i, @@ -211,8 +253,8 @@ xdescribe('Form engine component', () => { expect(errorMessage).toBeInTheDocument(); - fireEvent.click(hospitalizationHistoryDropdown); - fireEvent.click(screen.getByText(/yes/i)); + await user.click(hospitalizationHistoryDropdown); + await user.click(screen.getByText(/yes/i)); expect(errorMessage).not.toBeInTheDocument(); }); @@ -231,11 +273,8 @@ xdescribe('Form engine component', () => { expect(api.getPreviousEncounter).toHaveBeenCalled(); expect(api.getPreviousEncounter).toHaveReturnedWith(Promise.resolve(mockHxpEncounter)); - const reuseValueButton = screen.getByRole('button', { name: /reuse value/i }); - const evaluatedHistoricalValue = screen.getByText(/Entry into a country/i); - - expect(reuseValueButton).toBeInTheDocument; - expect(evaluatedHistoricalValue).toBeInTheDocument; + expect(screen.getByRole('button', { name: /reuse value/i })).toBeInTheDocument; + expect(screen.getByText(/Entry into a country/i)); }); }); @@ -306,10 +345,9 @@ xdescribe('Form engine component', () => { // const dateInputField = await screen.getByLabelText(/If Unscheduled, actual scheduled date/i); // expect(dateInputField).toHaveClass('cds--date-picker__input--invalid'); const errorMessage = await screen.findByText( - 'Patient visit marked as unscheduled. Please provide the scheduled date.', + /Patient visit marked as unscheduled. Please provide the scheduled date./i, ); expect(errorMessage).toBeInTheDocument(); - // Validate text field const textInputField = screen.getByLabelText(/If Unscheduled, actual text scheduled date/i); expect(textInputField).toHaveClass('cds--text-input--invalid'); @@ -618,20 +656,30 @@ xdescribe('Form engine component', () => { const eddField = screen.getByRole('textbox', { name: /edd/i }); const lmpField = screen.getByRole('textbox', { name: /lmp/i }); - await user.click(lmpField); await user.paste('2022-07-06T00:00:00.000Z'); await user.tab(); - expect(lmpField).toHaveValue(dayjs('2022-07-06').toDate().toLocaleDateString(locale)); - expect(eddField).toHaveValue(dayjs('2023-04-12').toDate().toLocaleDateString(locale)); + expect(lmpField).toHaveValue('06/07/2022'); + expect(eddField).toHaveValue('12/04/2023'); }); it('should evaluate months on ART', async () => { await act(async () => renderForm(null, monthsOnArtForm)); - jest.useFakeTimers(); - jest.setSystemTime(new Date(2022, 9, 1)); + jest + .useFakeTimers({ + doNotFake: [ + 'nextTick', + 'setImmediate', + 'clearImmediate', + 'setInterval', + 'clearInterval', + 'setTimeout', + 'clearTimeout', + ], + }) + .setSystemTime(new Date(2022, 9, 1)); let artStartDateField = screen.getByRole('textbox', { name: /antiretroviral treatment start date/i, @@ -643,12 +691,11 @@ xdescribe('Form engine component', () => { expect(artStartDateField).not.toHaveValue(); expect(monthsOnArtField).not.toHaveValue(); - fireEvent.change(artStartDateField, { target: { value: '02/05/2022' } }); - fireEvent.blur(artStartDateField, { target: { value: '02/05/2022' } }); + await user.click(artStartDateField); + await user.paste('2022-02-05'); + await user.tab(); - await waitFor(() => { - expect(monthsOnArtField).toHaveValue(7); - }); + expect(monthsOnArtField).toHaveValue(7); }); it('should evaluate viral load status', async () => { @@ -664,13 +711,12 @@ xdescribe('Form engine component', () => { name: /unsuppressed/i, }); - fireEvent.blur(viralLoadCountField, { target: { value: 30 } }); + await user.type(viralLoadCountField, '30'); + await user.tab(); - await waitFor(() => { - expect(viralLoadCountField).toHaveValue(30); - expect(suppressedField).toBeChecked(); - expect(unsuppressedField).not.toBeChecked(); - }); + expect(viralLoadCountField).toHaveValue(30); + expect(suppressedField).toBeChecked(); + expect(unsuppressedField).not.toBeChecked(); }); it('should only show question when age is under 5', async () => { @@ -689,7 +735,7 @@ xdescribe('Form engine component', () => { name: /mrn/i, }); - expect(enrollmentDate).toHaveValue(new Date('1975-07-06T00:00:00.000Z').toLocaleDateString(locale)); + expect(enrollmentDate).toHaveValue('06/07/1975'); expect(mrn).toBeVisible(); }); @@ -701,7 +747,7 @@ xdescribe('Form engine component', () => { name: /body weight/i, }); - await waitFor(() => expect(bodyWeightField).toHaveValue(60)); + expect(bodyWeightField).toHaveValue(60); }); it('should evaluate next visit date', async () => { @@ -731,12 +777,6 @@ xdescribe('Form engine component', () => { }); describe('Concept references', () => { - const conceptResourcePath = when((url: string) => - url.includes(`${restBaseUrl}/concept?references=PIH:Occurrence of trauma,PIH:Yes,PIH:No,PIH:COUGH`), - ); - - when(mockOpenmrsFetch).calledWith(conceptResourcePath).mockReturnValue({ data: mockConceptsForm }); - it('should add default labels based on concept display and substitute mapping references with uuids', async () => { await act(async () => renderForm(null, referenceByMappingForm)); @@ -811,6 +851,7 @@ xdescribe('Form engine component', () => { patientUUID={patientUUID} formSessionIntent={intent} visit={visit} + mode="enter" />, ); } diff --git a/src/hooks/useFormStateHelpers.ts b/src/hooks/useFormStateHelpers.ts index edf7f338a..50e5e5380 100644 --- a/src/hooks/useFormStateHelpers.ts +++ b/src/hooks/useFormStateHelpers.ts @@ -1,13 +1,14 @@ import { type Dispatch, useCallback } from 'react'; -import { type FormSchema, type FormField } from '../types'; +import { type FormField, type FormSchema } from '../types'; import { type Action } from '../components/renderer/form/state'; +import cloneDeep from 'lodash/cloneDeep'; export function useFormStateHelpers(dispatch: Dispatch, formFields: FormField[]) { const addFormField = useCallback((field: FormField) => { dispatch({ type: 'ADD_FORM_FIELD', value: field }); }, []); const updateFormField = useCallback((field: FormField) => { - dispatch({ type: 'UPDATE_FORM_FIELD', value: structuredClone(field) }); + dispatch({ type: 'UPDATE_FORM_FIELD', value: cloneDeep(field) }); }, []); const getFormField = useCallback( diff --git a/src/hooks/useInitialValues.ts b/src/hooks/useInitialValues.ts index f25c4cf48..15ef1f377 100644 --- a/src/hooks/useInitialValues.ts +++ b/src/hooks/useInitialValues.ts @@ -31,7 +31,7 @@ const useInitialValues = ( setIsLoadingInitialValues(false); }); } - }, [formProcessor, isLoadingContextDependencies, context]); + }, [formProcessor, isLoadingContextDependencies, context, initialValues]); return { isLoadingInitialValues, initialValues, error }; }; diff --git a/src/hooks/useProcessorDependencies.ts b/src/hooks/useProcessorDependencies.ts index 7ec440ee3..78848b90b 100644 --- a/src/hooks/useProcessorDependencies.ts +++ b/src/hooks/useProcessorDependencies.ts @@ -1,6 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { type FormProcessorContextProps } from '../types'; import { type FormProcessor } from '../processors/form-processor'; +import { reportError } from '../utils/error-utils'; const useProcessorDependencies = ( formProcessor: FormProcessor, @@ -20,9 +21,10 @@ const useProcessorDependencies = ( }) .catch((error) => { setError(error); + reportError(error, 'Encountered error while loading dependencies'); }); } - }, []); + }, [loadDependencies]); return { isLoading, error }; }; diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 7d52b53c7..1b8faf7be 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -1,15 +1,15 @@ import { - type ValueAndDisplay, type FormField, - type FormProcessorContextProps, type FormPage, + type FormProcessorContextProps, + type FormSchema, type FormSection, + type ValueAndDisplay, } from '../../types'; import { usePatientPrograms } from '../../hooks/usePatientPrograms'; import { useEffect, useState } from 'react'; import { useEncounter } from '../../hooks/useEncounter'; import { isEmpty } from '../../validators/form-validator'; -import { type FormSchema } from '../../types'; import { type FormContextProps } from '../../provider/form-provider'; import { FormProcessor } from '../form-processor'; import { @@ -28,8 +28,9 @@ import { moduleName } from '../../globals'; import { extractErrorMessagesFromResponse } from '../../utils/error-utils'; import { getPreviousEncounter, saveEncounter } from '../../api'; import { useEncounterRole } from '../../hooks/useEncounterRole'; -import { type FormNode, evaluateAsyncExpression, evaluateExpression } from '../../utils/expression-runner'; +import { evaluateAsyncExpression, evaluateExpression, type FormNode } from '../../utils/expression-runner'; import { hasRendering } from '../../utils/common-utils'; +import { extractObsValueAndDisplay } from '../../utils/form-helper'; function useCustomHooks(context: Partial) { const [isLoading, setIsLoading] = useState(true); @@ -77,8 +78,9 @@ const contextInitializableTypes = [ 'encounterLocation', 'patientIdentifier', 'encounterRole', - 'programState' + 'programState', ]; + export class EncounterFormProcessor extends FormProcessor { prepareFormSchema(schema: FormSchema) { schema.pages.forEach((page) => { @@ -105,6 +107,7 @@ export class EncounterFormProcessor extends FormProcessor { }); } } + return schema; } @@ -309,7 +312,7 @@ export class EncounterFormProcessor extends FormProcessor { patient: patient, previousEncounter: previousDomainObjectValue, }); - return value; + return extractObsValueAndDisplay(field, value); } if (previousDomainObjectValue && field.questionOptions.enablePreviousValue) { return await adapter.getPreviousValue(field, previousDomainObjectValue, context); diff --git a/src/provider/form-factory-helper.ts b/src/provider/form-factory-helper.ts index 4568cb66b..8b9fe6208 100644 --- a/src/provider/form-factory-helper.ts +++ b/src/provider/form-factory-helper.ts @@ -13,6 +13,7 @@ export function validateForm(context: FormContextProps) { patient, sessionMode, addInvalidField, + updateFormField, methods: { getValues, trigger }, } = context; const values = getValues(); @@ -34,7 +35,7 @@ export function validateForm(context: FormContextProps) { const errors = validationResults.filter((result) => result.resultType === 'error'); if (errors.length) { field.meta.submission = { ...field.meta.submission, errors }; - trigger(field.id); + updateFormField(field); addInvalidField(field); } return errors; diff --git a/src/registry/registry.ts b/src/registry/registry.ts index f6961d94f..1d96bb8b8 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -1,7 +1,9 @@ import { type FormField, type DataSource, + type FormFieldInputProps, type FormFieldValidator, + type FormFieldValueAdapter, type FormSchemaTransformer, type PostSubmissionAction, } from '../types'; @@ -13,7 +15,6 @@ import { inbuiltDataSources } from './inbuilt-components/inbuiltDataSources'; import { getControlTemplate } from './inbuilt-components/control-templates'; import { inbuiltPostSubmissionActions } from './inbuilt-components/InbuiltPostSubmissionActions'; import { inbuiltFormTransformers } from './inbuilt-components/inbuiltTransformers'; -import { type FormFieldInputProps, type FormFieldValueAdapter } from '../types'; import { inbuiltFieldValueAdapters } from './inbuilt-components/inbuiltFieldValueAdapters'; /** diff --git a/src/utils/form-helper.ts b/src/utils/form-helper.ts index d2f778800..ebcb9e046 100644 --- a/src/utils/form-helper.ts +++ b/src/utils/form-helper.ts @@ -1,7 +1,8 @@ import { type LayoutType } from '@openmrs/esm-framework'; -import { type FormField, type FormPage, type FormSection, type SessionMode } from '../types'; +import { type OpenmrsObs, type FormField, type FormPage, type FormSection, type SessionMode } from '../types'; import { isEmpty } from '../validators/form-validator'; -import { getRegisteredControl } from '../registry/registry'; +import { parseToLocalDateTime } from './common-utils'; +import dayjs from 'dayjs'; export function shouldUseInlineLayout( renderingType: 'single-line' | 'multiline' | 'automatic', @@ -170,3 +171,25 @@ export function scrollIntoView(viewId: string, shouldFocus: boolean = false) { currentElement?.focus(); } } + +export const extractObsValueAndDisplay = (field: FormField, obs: OpenmrsObs) => { + const rendering = field.questionOptions.rendering; + + if (typeof obs.value === 'string' || typeof obs.value === 'number') { + if (rendering === 'date' || rendering === 'datetime') { + const dateObj = parseToLocalDateTime(`${obs.value}`); + return { value: dateObj, display: dayjs(dateObj).format('YYYY-MM-DD HH:mm') }; + } + return { value: obs.value, display: obs.value }; + } else if (['toggle', 'checkbox'].includes(rendering)) { + return { + value: obs.value?.uuid, + display: obs.value?.name?.name, + }; + } else { + return { + value: obs.value?.uuid, + display: field.questionOptions.answers?.find((option) => option.concept === obs.value?.uuid)?.label, + }; + } +};