diff --git a/.eslintrc b/.eslintrc index 0d3c7959..5e25b44d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -5,14 +5,11 @@ "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", - "plugin:@typescript-eslint/recommended-requiring-type-checking" + "plugin:jest-dom/recommended", + "plugin:testing-library/react" ], "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": true, - "tsconfigRootDir": "__dirname" - }, - "plugins": ["@typescript-eslint", "import", "react-hooks", "testing-library"], + "plugins": ["@typescript-eslint", "import", "jest-dom", "react-hooks", "testing-library"], "root": true, "overrides": [ { @@ -22,36 +19,37 @@ } ], "rules": { - "import/no-duplicates": "error", - "react-hooks/exhaustive-deps": "warn", - "react-hooks/rules-of-hooks": "error", + // Disabling these rules for now just to keep the diff small. We'll enable them one by one as we go. + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/ban-types": "off", "@typescript-eslint/no-explicit-any": "off", - // not hugely concerned about accidental implicit type coercions for now https://typescript-eslint.io/rules/no-base-to-string - "@typescript-eslint/no-base-to-string": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-var-requires": "off", + "@typescript-eslint/triple-slash-reference": "off", // The following rules need `noImplicitAny` to be set to `true` in our tsconfig. They are too restrictive for now, but should be reconsidered in future "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/unbound-method": "off", - // Nitpicky. Prefer `interface T` over type T - "@typescript-eslint/consistent-type-definitions": "error", - "@typescript-eslint/consistent-type-exports": "error", - "@typescript-eslint/no-floating-promises": "off", - // Use `import type` instead of `import` for type imports + // Use `import type` instead of `import` for type imports https://typescript-eslint.io/blog/consistent-type-imports-and-exports-why-and-how "@typescript-eslint/consistent-type-imports": [ "error", { "fixStyle": "inline-type-imports" } ], - // Use Array instead of T[] consistently - "@typescript-eslint/array-type": [ + "import/no-duplicates": "error", + "no-console": [ "error", { - "default": "generic" + "allow": ["warn", "error"] } ], - "no-console": ["error", { "allow": ["warn", "error"] }], + "no-unsafe-optional-chaining": "off", + "no-explicit-any": "off", + "no-extra-boolean-cast": "off", + "no-prototype-builtins": "off", + "no-useless-escape": "off", "no-restricted-imports": [ "error", { @@ -77,6 +75,9 @@ } ] } - ] + ], + "prefer-const": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn" } -} +} \ No newline at end of file diff --git a/src/components/interactive-builder/add-question.modal.tsx b/src/components/interactive-builder/add-question.modal.tsx index 7b79f2c3..cf09828b 100644 --- a/src/components/interactive-builder/add-question.modal.tsx +++ b/src/components/interactive-builder/add-question.modal.tsx @@ -48,7 +48,6 @@ import { useConceptLookup } from '../../hooks/useConceptLookup'; import { usePatientIdentifierTypes } from '../../hooks/usePatientIdentifierTypes'; import { usePersonAttributeTypes } from '../../hooks/usePersonAttributeTypes'; import { useProgramWorkStates, usePrograms } from '../../hooks/useProgramStates'; -import MarkdownQuestion from './modals/question/question-form/rendering-types/inputs/markdown/markdown.component'; import styles from './question-modal.scss'; interface AddQuestionModalProps { @@ -364,17 +363,13 @@ const AddQuestionModal: React.FC = ({ - {renderingType === 'markdown' ? ( - - ) : ( - } - placeholder={t('labelPlaceholder', 'e.g. Type of Anaesthesia')} - value={questionLabel} - onChange={(event: React.ChangeEvent) => setQuestionLabel(event.target.value)} - /> - )} + } + placeholder={t('labelPlaceholder', 'e.g. Type of Anaesthesia')} + value={questionLabel} + onChange={(event: React.ChangeEvent) => setQuestionLabel(event.target.value)} + /> ({ - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - useDebounce: jest.fn((value) => value), -})); +const concepts: Array = [ + { + uuid: '123', + display: 'Concept 1', + datatype: { uuid: '456', name: 'Coded' }, + mappings: [{ display: 'CIEL:1606', conceptMapType: { display: 'SAME-AS' } }], + answers: [ + { uuid: '1', display: 'Answer 1' }, + { uuid: '2', display: 'Answer 2' }, + ], + }, + { + uuid: '456', + display: 'Concept 2', + datatype: { uuid: '456', name: 'Date' }, + mappings: [{ display: 'CIEL:1656', conceptMapType: { display: 'SAME-AS' } }], + }, +]; + +const initialFormField: FormField = { + id: '1', + type: 'obs', + questionOptions: { + rendering: 'text', + }, +}; -// eslint-disable-next-line @typescript-eslint/no-unsafe-return +const mockUseConceptLookup = jest.mocked(useConceptLookup); jest.mock('@hooks/useConceptLookup', () => ({ ...jest.requireActual('@hooks/useConceptLookup'), - useConceptLookup: jest.fn(() => ({ - concepts: [ - { - uuid: '123', - display: 'Concept 1', - datatype: { uuid: '456', name: 'Coded' }, - mappings: [{ display: 'CIEL:1606', conceptMapType: { display: 'SAME-AS' } }], - answers: [{ uuid: '1', display: 'Answer 1' }], - }, - { - uuid: '456', - display: 'Concept 2', - datatype: { uuid: '456', name: 'Date' }, - mappings: [{ display: 'CIEL:1656', conceptMapType: { display: 'SAME-AS' } }], - }, - ], - conceptLookupError: null, - isLoadingConcepts: false, - })), + useConceptLookup: jest.fn(), })); +const mockUseConceptId = jest.mocked(useConceptId); jest.mock('@hooks/useConceptId', () => ({ - useConceptId: jest.fn(() => ({ - concept: '123', - conceptName: 'Concept 1', - conceptNameLookupError: null, - isLoadingConcept: false, - })), + ...jest.requireActual('@hooks/useConceptId'), + useConceptId: jest.fn(), })); -const formField: FormField = { - type: 'obs', - questionOptions: { - rendering: 'select', - concept: '123', - answers: [ - { id: '111', text: 'Answer 1' }, - { id: '222', text: 'Answer 2' }, - ], - }, - id: '1', -}; - describe('ObsTypeQuestion', () => { it('renders without crashing', () => { - render(); + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + render(); expect(screen.getByRole('searchbox', { name: /search for a backing concept/i })).toBeInTheDocument(); }); - it('allows user to search for and select a concept and displays the concept mappings', async () => { - render(); + it('renders the concept details after searching for a concept and displays the concept mappings and answers', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); const user = userEvent.setup(); + render(); + const searchInput = screen.getByRole('searchbox', { name: /search for a backing concept/i }); await user.click(searchInput); await user.type(searchInput, 'Concept 1'); + const conceptMenuItem = await screen.findByRole('menuitem', { name: /concept 1/i, }); expect(conceptMenuItem).toBeInTheDocument(); + await user.click(conceptMenuItem); + expect(mockSetFormField).toHaveBeenCalledWith({ - ...formField, - questionOptions: { ...formField.questionOptions, concept: '123' }, + ...initialFormField, + questionOptions: { ...initialFormField.questionOptions, concept: '123' }, }); expect( screen.getByRole('cell', { name: /ciel:1606/i, }), ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { + name: /same\-as/i, + }), + ).toBeInTheDocument(); + const answersMenu = screen.getByRole('combobox', { name: /select answers to display/i, }); expect(answersMenu).toBeInTheDocument(); + await user.click(answersMenu); - const answerMenuItem = await screen.findByRole('menuitem', { - name: /answer 1/i, + expect(screen.getByText(/answer 1/i)).toBeInTheDocument(); + expect(screen.getByText(/answer 2/i)).toBeInTheDocument(); + }); + + it('displays an error with a link to ocl when concept query gives empty results', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, }); - await user.click(answerMenuItem); - expect(mockSetFormField).toHaveBeenCalledWith({ - ...formField, - questionOptions: { ...formField.questionOptions, answers: [{ id: '111', text: 'Answer 1' }] }, + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByRole('searchbox', { name: /search for a backing concept/i }); + await user.click(searchInput); + await user.type(searchInput, 'Does not exist'); + + expect(screen.getByText(/no concepts were found that match/i)).toBeInTheDocument(); + expect(screen.getByText(/can't find a concept\?/i)).toBeInTheDocument(); + expect( + screen.getByRole('link', { + name: /search in ocl/i, + }), + ).toBeInTheDocument(); + }); + + it('shows loading spinner when concept is loading', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: true }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + const user = userEvent.setup(); + render(); + + const searchInput = screen.getByRole('searchbox', { name: /search for a backing concept/i }); + await user.click(searchInput); + await user.type(searchInput, 'Does not exist'); + expect(screen.getByText(/searching\.\.\./i)).toBeInTheDocument(); + }); + + it('displays an error message if the searched concept cannot be found', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: Error(), isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, }); + render(); + + expect(screen.getByText(/error fetching concepts/i)).toBeInTheDocument(); + expect(screen.getByText(/please try again\./i)).toBeInTheDocument(); }); it('sets the date picker format to the concept date picker type', async () => { - render(); + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); const user = userEvent.setup(); + render(); + const searchInput = screen.getByRole('searchbox', { name: /search for a backing concept/i }); await user.click(searchInput); await user.type(searchInput, 'Concept 2'); const conceptMenuItem = await screen.findByRole('menuitem', { name: /concept 2/i, }); + expect(conceptMenuItem).toBeInTheDocument(); + await user.click(conceptMenuItem); expect(mockSetFormField).toHaveBeenCalledWith({ - ...formField, + ...initialFormField, datePickerFormat: 'calendar', - questionOptions: { ...formField.questionOptions, concept: '456' }, + questionOptions: { ...initialFormField.questionOptions, concept: '456' }, }); }); - it('shows the selected concept when editing a question', () => { - render(); + it('loads the concept details along with the selected answer when editing a question', async () => { + initialFormField.questionOptions = { + rendering: 'select', + concept: concepts[0].uuid, + answers: [{ label: 'Answer 1', concept: '1' }], + }; + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: concepts[0], + conceptName: concepts[0].display, + isLoadingConcept: false, + conceptNameLookupError: null, + }); + const user = userEvent.setup(); + render(); + expect( screen.getByRole('searchbox', { name: /search for a backing concept/i, }), ).toHaveValue('Concept 1'); + expect(screen.getByText(/mappings/i)).toBeInTheDocument(); + expect( + screen.getByRole('cell', { + name: /ciel:1606/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { + name: /same\-as/i, + }), + ).toBeInTheDocument(); + + const answersMenu = screen.getByRole('combobox', { + name: /select answers to display/i, + }); + expect(answersMenu).toBeInTheDocument(); + + await user.click(answersMenu); + expect( + screen.getByRole('option', { + name: /answer 1/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: /answer 2/i, + }), + ).toBeInTheDocument(); + + expect(screen.getByTitle(/answer 1/i)).toBeInTheDocument(); + }); + + it('shows loading spinner when loading the concept when editing the question', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: true }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: true, + }); + const user = userEvent.setup(); + render(); + expect( + screen.queryByRole('searchbox', { + name: /search for a backing concept/i, + }), + ).not.toBeInTheDocument(); + expect(screen.getByText(/loading\.\.\./i)).toBeInTheDocument(); + }); + + it('shows error if concept in question cannot be not when trying to edit', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: Error(), + isLoadingConcept: false, + }); + initialFormField.questionOptions = { + rendering: 'select', + concept: concepts[0].uuid, + answers: [{ label: 'Answer 1', concept: '1' }], + }; + render(); + + expect(screen.getByText(/couldn't resolve concept name/i)).toBeInTheDocument(); + expect( + screen.getByText(/the linked concept '\{\{conceptname\}\}' does not exist in your dictionary/i), + ).toBeInTheDocument(); + expect(screen.getByText(/answer 1/i)).toBeInTheDocument(); }); }); diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx index 8a648be6..5dd492b9 100644 --- a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx @@ -2,16 +2,13 @@ import React, { useState, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { FormLabel, InlineNotification, ComboBox, InlineLoading } from '@carbon/react'; import { usePatientIdentifierTypes } from '@hooks/usePatientIdentifierTypes'; -import { usePatientIdentifierName } from '@hooks/usePatientIdentifierName'; import type { ComponentProps, PatientIdentifierType } from '@types'; import styles from './patient-identifier-type-question.scss'; const PatientIdentifierTypeQuestion: React.FC = ({ formField, setFormField }) => { const { t } = useTranslation(); - const { patientIdentifierTypes, patientIdentifierTypeLookupError } = usePatientIdentifierTypes(); - const { patientIdentifierNameLookupError, isLoadingPatientidentifierName } = usePatientIdentifierName( - formField.questionOptions?.identifierType ?? '', - ); + const { patientIdentifierTypes, patientIdentifierTypeLookupError, isLoadingPatientIdentifierTypes } = + usePatientIdentifierTypes(); const [selectedPatientIdetifierType, setSelectedPatientIdetifierType] = useState( formField.questionOptions?.identifierType ? patientIdentifierTypes.find( @@ -38,7 +35,7 @@ const PatientIdentifierTypeQuestion: React.FC = ({ formField, se {t('searchForBackingPatientIdentifierType', 'Search for a backing patient identifier type')} - {patientIdentifierTypeLookupError || patientIdentifierNameLookupError ? ( + {patientIdentifierTypeLookupError && ( = ({ formField, se title={t('errorFetchingPatientIdentifierTypes', 'Error fetching patient identifier types')} subtitle={t('pleaseTryAgain', 'Please try again.')} /> - ) : null} - {isLoadingPatientidentifierName ? ( + )} + {isLoadingPatientIdentifierTypes ? ( ) : ( ({ + ...jest.requireActual('@hooks/usePatientIdentifierTypes'), + usePatientIdentifierTypes: jest.fn((value: string) => value), +})); + +const initialFormField: FormField = { + id: '1', + type: 'patientIdentifier', + questionOptions: { + rendering: 'text', + }, +}; + +const patientIdentifierTypes: Array = [ + { display: 'Type 1', description: 'Example description', name: 'Type 1', uuid: '1' }, + { display: 'Type 2', description: 'Another example description', name: 'Type 2', uuid: '2' }, +]; + +describe('PatientIdentifierTypeQuestion', () => { + it('renders without crashing and displays the patient idenitifier types', async () => { + mockUsePatientIdentifierTypes.mockReturnValue({ + patientIdentifierTypes: patientIdentifierTypes, + patientIdentifierTypeLookupError: null, + isLoadingPatientIdentifierTypes: false, + }); + const user = userEvent.setup(); + render(); + + expect(screen.getByText(/search for a backing patient identifier type/i)).toBeInTheDocument(); + expect( + screen.getByText(/patient identifier type fields must be linked to a patient identifier type/i), + ).toBeInTheDocument(); + const menuBox = screen.getByRole('combobox'); + expect(menuBox).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: /open/i, + }), + ).toBeInTheDocument(); + await user.click(menuBox); + expect( + screen.getByRole('listbox', { + name: /choose an item/i, + }), + ).toBeInTheDocument(); + expect(screen.getByText(/type 1/i)).toBeInTheDocument(); + expect(screen.getByText(/type 2/i)).toBeInTheDocument(); + }); + + it('shows spinner when loading the patient identifier types', () => { + mockUsePatientIdentifierTypes.mockReturnValue({ + patientIdentifierTypes: [], + patientIdentifierTypeLookupError: null, + isLoadingPatientIdentifierTypes: true, + }); + render(); + + expect(screen.getByText(/loading\.\.\./i)).toBeInTheDocument(); + expect(screen.queryByRole('combobox')).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { + name: /open/i, + }), + ).not.toBeInTheDocument(); + expect( + screen.queryByText(/patient identifier type fields must be linked to a patient identifier type/i), + ).not.toBeInTheDocument(); + }); + + it('displays an error if patient idenitifier types cannot be loaded', () => { + mockUsePatientIdentifierTypes.mockReturnValue({ + patientIdentifierTypes: [], + patientIdentifierTypeLookupError: Error(), + isLoadingPatientIdentifierTypes: false, + }); + render(); + + expect(screen.getByText(/error fetching patient identifier types/i)).toBeInTheDocument(); + expect(screen.getByText(/please try again\./i)).toBeInTheDocument(); + }); + + it('shows the selected identifier type', async () => { + mockUsePatientIdentifierTypes.mockReturnValue({ + patientIdentifierTypes: patientIdentifierTypes, + patientIdentifierTypeLookupError: null, + isLoadingPatientIdentifierTypes: false, + }); + initialFormField.questionOptions.identifierType = patientIdentifierTypes[0].uuid; + render(); + expect( + screen.getByRole('button', { + name: /clear selected item/i, + }), + ).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveDisplayValue(/type 1/i); + }); +}); diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx index 0a024e54..0c0deab5 100644 --- a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx @@ -12,12 +12,12 @@ interface ProgramStateData { const ProgramStateTypeQuestion: React.FC = ({ formField, setFormField }) => { const { t } = useTranslation(); - const [selectedProgramState, setSelectedProgramState] = useState>(); - const [selectedProgram, setSelectedProgram] = useState(null); - const [programWorkflow, setProgramWorkflow] = useState(null); const { programs, programsLookupError, isLoadingPrograms } = usePrograms(); + const [selectedProgram, setSelectedProgram] = useState(); + const [selectedProgramWorkflow, setSelectedProgramWorkflow] = useState(); + const [selectedProgramStates, setSelectedProgramStates] = useState>(); const { programStates, programStatesLookupError, isLoadingProgramStates, mutateProgramStates } = useProgramWorkStates( - programWorkflow?.uuid, + selectedProgramWorkflow?.uuid, ); const [programWorkflows, setProgramWorkflows] = useState>([]); @@ -32,7 +32,7 @@ const ProgramStateTypeQuestion: React.FC = ({ formField, setForm const handleProgramWorkflowChange = useCallback( (selectedItem: ProgramWorkflow) => { - setProgramWorkflow(selectedItem); + setSelectedProgramWorkflow(selectedItem); void mutateProgramStates(); setFormField({ ...formField, @@ -44,7 +44,7 @@ const ProgramStateTypeQuestion: React.FC = ({ formField, setForm const selectProgramStates = useCallback( (data: ProgramStateData) => { - setSelectedProgramState(data.selectedItems); + setSelectedProgramStates(data.selectedItems); setFormField({ ...formField, questionOptions: { @@ -64,11 +64,25 @@ const ProgramStateTypeQuestion: React.FC = ({ formField, setForm }, []); useEffect(() => { - setSelectedProgramState( - programStates.filter((programState) => - formField.questionOptions?.answers?.some((answer) => answer.value === programState.uuid), - ), + const selectedProgramResult = programs.find((program) => program.uuid === formField.questionOptions?.programUuid); + if (selectedProgramResult) { + setSelectedProgram(selectedProgramResult); + if (formField.questionOptions.workflowUuid) { + const selectedProgramWorkflowResult = selectedProgramResult.allWorkflows.find( + (workflow) => workflow.uuid === formField.questionOptions?.workflowUuid, + ); + if (selectedProgramWorkflowResult) { + setSelectedProgramWorkflow(selectedProgramWorkflowResult); + } + } + } + }, [formField.questionOptions?.programUuid, formField.questionOptions?.workflowUuid, programs]); + + useEffect(() => { + const selectedProgramStatesResults = programStates.filter((programState) => + formField.questionOptions?.answers?.some((answer) => answer.concept === programState.concept.uuid), ); + setSelectedProgramStates(selectedProgramStatesResults); }, [formField.questionOptions?.answers, programStates]); return ( @@ -83,7 +97,7 @@ const ProgramStateTypeQuestion: React.FC = ({ formField, setForm subtitle={t('pleaseTryAgain', 'Please try again.')} /> )} - {programs && ( + {programs.length > 0 && ( = ({ formField, setForm itemToString={(item: ProgramWorkflow) => item?.concept?.display} onChange={({ selectedItem }: { selectedItem: ProgramWorkflow }) => handleProgramWorkflowChange(selectedItem)} placeholder={t('addProgramWorkflow', 'Add program workflow')} - selectedItem={programWorkflow} + selectedItem={selectedProgramWorkflow} titleText={t('programWorkflow', 'Program workflow')} initialSelectedItem={programWorkflows.find( (programWorkflow) => programWorkflow?.uuid === formField.questionOptions?.workflowUuid, )} /> )} - {programWorkflow && ( + {selectedProgramWorkflow && (
{isLoadingProgramStates && } {programStatesLookupError && ( @@ -132,10 +146,10 @@ const ProgramStateTypeQuestion: React.FC = ({ formField, setForm itemToString={convertItemsToString} selectionFeedback="top-after-reopen" onChange={selectProgramStates} - selectedItems={selectedProgramState} + selectedItems={selectedProgramStates} /> )} - {selectedProgramState?.map((answer) => ( + {selectedProgramStates?.map((answer) => (
{answer?.concept?.display} diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state.test.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state.test.tsx new file mode 100644 index 00000000..d5c0d2e7 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state.test.tsx @@ -0,0 +1,211 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import ProgramStateTypeQuestion from './program-state-type-question.component'; +import userEvent from '@testing-library/user-event'; +import type { FormField, ProgramState } from '@openmrs/esm-form-engine-lib'; +import { usePrograms, useProgramWorkStates } from '@hooks/useProgramStates'; +import type { Program } from '@types'; + +const mockSetFormField = jest.fn(); +const initialFormField: FormField = { + id: '1', + type: 'programState', + questionOptions: { + rendering: 'select', + answers: [], + }, +}; + +const mockUsePrograms = jest.mocked(usePrograms); +const mockUseProgramWorkflowStates = jest.mocked(useProgramWorkStates); +jest.mock('@hooks/useProgramStates', () => ({ + ...jest.requireActual('@hooks/useProgramStates'), + usePrograms: jest.fn(), + useProgramWorkStates: jest.fn(), +})); + +const programOneWorkflowOneStates: Array = [ + { + concept: { display: 'Program 1 State 1', uuid: '1111' }, + programWorkflow: { display: 'Program 1 Workflow 1', uuid: '111' }, + }, + { + concept: { display: 'Program 1 State 2', uuid: '1112' }, + programWorkflow: { display: 'Program 1 Workflow 1', uuid: '111' }, + }, +]; + +const programs: Array = [ + { + uuid: '1', + name: 'Program 1', + allWorkflows: [ + { + uuid: '11', + concept: { display: 'Program 1 Workflow 1', uuid: '111' }, + states: programOneWorkflowOneStates, + }, + { + uuid: '12', + concept: { display: 'Program 1 Workflow 2', uuid: '112' }, + states: [ + { + concept: { display: 'Program 1 State 3', uuid: '1121' }, + programWorkflow: { display: 'Program 1 Workflow 2', uuid: '112' }, + }, + { + concept: { display: 'Program 1 State 4', uuid: '1122' }, + programWorkflow: { display: 'Program 1 Workflow 2', uuid: '112' }, + }, + ], + }, + ], + }, + { + uuid: '2', + name: 'Program 2', + allWorkflows: [ + { + uuid: '21', + concept: { display: 'Program 2 Workflow 1', uuid: '211' }, + states: [ + { + concept: { display: 'Program 2 State 1', uuid: '2111' }, + programWorkflow: { display: 'Program 2 Workflow 1', uuid: '211' }, + }, + { + concept: { display: 'Program 2 State 2', uuid: '2112' }, + programWorkflow: { display: 'Program 2 Workflow 1', uuid: '211' }, + }, + ], + }, + { + uuid: '22', + concept: { display: 'Program 2 Workflow 2', uuid: '212' }, + states: [ + { + concept: { display: 'Program 2 State 3', uuid: '2121' }, + programWorkflow: { display: 'Program 2 Workflow 2', uuid: '212' }, + }, + { + concept: { display: 'Program 2 State 4', uuid: '2122' }, + programWorkflow: { display: 'Program 2 Workflow 2', uuid: '212' }, + }, + ], + }, + ], + }, +]; + +describe('ProgramStateTypeQuestion', () => { + it('renders without crashing', async () => { + mockUsePrograms.mockReturnValue({ programs: [], programsLookupError: null, isLoadingPrograms: false }); + mockUseProgramWorkflowStates.mockReturnValue({ + programStates: [], + programStatesLookupError: null, + isLoadingProgramStates: null, + mutateProgramStates: jest.fn(), + }); + render(); + + expect(screen.getByRole('combobox', { name: /^program$/i })).toBeInTheDocument(); + }); + + it('displays spinner when programs are loading', () => { + mockUsePrograms.mockReturnValue({ programs: [], programsLookupError: null, isLoadingPrograms: true }); + mockUseProgramWorkflowStates.mockReturnValue({ + programStates: [], + programStatesLookupError: null, + isLoadingProgramStates: null, + mutateProgramStates: jest.fn(), + }); + render(); + + expect(screen.queryByRole('combobox', { name: /^program$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('combobox', { name: /^program workflow$/i })).not.toBeInTheDocument(); + }); + + it('lets user select a program and displays the workflows based on selected program, and states based on selected workflow', async () => { + mockUsePrograms.mockReturnValue({ programs: programs, programsLookupError: null, isLoadingPrograms: false }); + mockUseProgramWorkflowStates.mockReturnValue({ + programStates: programOneWorkflowOneStates, + programStatesLookupError: null, + isLoadingProgramStates: null, + mutateProgramStates: jest.fn(), + }); + const user = userEvent.setup(); + render(); + + expect(screen.getByRole('combobox', { name: /^program$/i })).toBeInTheDocument(); + const selectProgramsButton = screen.getByRole('button', { + name: /open/i, + }); + expect(selectProgramsButton).toBeInTheDocument(); + await user.click(selectProgramsButton); + expect( + screen.getByRole('option', { + name: /program 1/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: /program 2/i, + }), + ).toBeInTheDocument(); + + const programOneSelectOption = screen.getByRole('option', { + name: /program 1/i, + }); + await user.click(programOneSelectOption); + expect(screen.getByRole('combobox', { name: /^program$/i })).toHaveDisplayValue(/program 1/i); + + const programWorkflowInput = screen.getByRole('combobox', { + name: /program workflow/i, + }); + expect(programWorkflowInput).toBeInTheDocument(); + + const menuButtons = screen.getAllByRole('button', { name: /open/i }); + expect(menuButtons).toHaveLength(2); + + const programWorkflowMenuButton = menuButtons[1]; + expect(programWorkflowMenuButton).toHaveRole('button'); + await user.click(programWorkflowMenuButton); + expect(screen.getByRole('option', { name: /program 1 workflow 1/i })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /program 1 workflow 2/i })).toBeInTheDocument(); + + const programWorkflowSelectionOption = screen.getByRole('option', { name: /program 1 workflow 1/i }); + await user.click(programWorkflowSelectionOption); + screen.logTestingPlaygroundURL(); + }); + + it('renders the selected program, workflow and state in edit mode', async () => { + mockUsePrograms.mockReturnValue({ programs: programs, programsLookupError: null, isLoadingPrograms: false }); + mockUseProgramWorkflowStates.mockReturnValue({ + programStates: programOneWorkflowOneStates, + programStatesLookupError: null, + isLoadingProgramStates: null, + mutateProgramStates: jest.fn(), + }); + initialFormField.questionOptions = { + rendering: 'select', + programUuid: programs[0].uuid, + workflowUuid: programs[0].allWorkflows[0].uuid, + answers: [ + { concept: programOneWorkflowOneStates[0].concept.uuid, label: programOneWorkflowOneStates[0].concept.display }, + ], + }; + + render(); + + expect(screen.getByRole('combobox', { name: /^program$/i })).toHaveDisplayValue(/program 1/i); + expect( + screen.getByRole('combobox', { + name: /program workflow/i, + }), + ).toHaveDisplayValue(/program 1 workflow 1/i); + expect( + screen.getByText(/total items selected: 1,to clear selection, press delete or backspace/i), + ).toBeInTheDocument(); + expect(screen.getByText(/program 1 state 1/i)).toBeInTheDocument(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 42aac033..c66c5746 100644 --- a/src/types.ts +++ b/src/types.ts @@ -178,7 +178,7 @@ export interface Concept { display: string; mappings: Array; datatype: OpenmrsResource; - answers: Array; + answers?: Array; allowDecimal?: boolean; }