diff --git a/src/components/interactive-builder/modals/question/form-field-context.tsx b/src/components/interactive-builder/modals/question/form-field-context.tsx index ca4c2acc..7ac8109a 100644 --- a/src/components/interactive-builder/modals/question/form-field-context.tsx +++ b/src/components/interactive-builder/modals/question/form-field-context.tsx @@ -1,9 +1,12 @@ import React, { createContext, useCallback, useContext, useState, type ReactNode } from 'react'; import type { FormField } from '@openmrs/esm-form-engine-lib'; +import type { Concept } from '@types'; interface FormFieldContextType { formField: FormField; setFormField: React.Dispatch>; + concept: Concept; + setConcept: React.Dispatch>; } const FormFieldContext = createContext(undefined); @@ -12,8 +15,10 @@ export const FormFieldProvider: React.FC<{ children: ReactNode; initialFormField: FormField; isObsGrouped?: boolean; -}> = ({ children, initialFormField, isObsGrouped = false }) => { + selectedConcept?: Concept; +}> = ({ children, initialFormField, isObsGrouped = false, selectedConcept = null }) => { const [formField, setFormField] = useState(initialFormField); + const [concept, setConcept] = useState(selectedConcept); const updateObsGroupedQuestion = useCallback( (updatedObsGroupFormField: FormField) => { @@ -33,7 +38,7 @@ export const FormFieldProvider: React.FC<{ return ( {children} diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/concept-search.component.tsx b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx similarity index 100% rename from src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/concept-search.component.tsx rename to src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx diff --git a/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.scss b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.scss new file mode 100644 index 00000000..112704eb --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.scss @@ -0,0 +1,67 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; +@use '@carbon/type'; + +.label { + margin-bottom: layout.$spacing-02; +} + +.error { + width: 100%; + max-width: unset; + padding: '0rem'; + margin-bottom: $spacing-05; +} + +.loader { + padding: layout.$spacing-04 $spacing-03; +} + +.concept { + padding: $spacing-04; + border-bottom: 1px solid colors.$gray-20; + + &:last-of-type { + border-bottom: none; + } +} + +.conceptList { + background: colors.$white-0; + max-height: 14rem; + overflow-y: auto; + border: 1px solid colors.$gray-20; + border-top: none; +} + +.conceptList li:hover { + background-color: colors.$gray-20; +} + +.emptyResults { + @include type.type-style('body-compact-01'); + color: colors.$gray-70; + min-height: layout.$spacing-05; +} + +.oclLauncherBanner { + padding: layout.$spacing-03 layout.$spacing-05; + display: flex; + justify-content: space-between; + align-items: center; + background-color: colors.$gray-50; + + p { + color: colors.$white-0; + } +} + +.oclLink { + color: colors.$blue-10; + display: flex; + align-items: center; + + svg { + margin-left: 0.25rem; + } +} diff --git a/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.test.tsx b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.test.tsx new file mode 100644 index 00000000..2bbcd026 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import { useConceptId } from '@hooks/useConceptId'; +import { useConceptLookup } from '@hooks/useConceptLookup'; +import ConceptSearch from './concept-search.component'; +import type { Concept } from '@types'; + +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 mockUseConceptLookup = jest.mocked(useConceptLookup); +jest.mock('@hooks/useConceptLookup', () => ({ + ...jest.requireActual('@hooks/useConceptLookup'), + useConceptLookup: jest.fn(), +})); +const mockUseConceptId = jest.mocked(useConceptId); +jest.mock('@hooks/useConceptId', () => ({ + ...jest.requireActual('@hooks/useConceptId'), + useConceptId: jest.fn(), +})); +const onSelectConcept = jest.fn(); + +describe('Concept search component', () => { + beforeEach(() => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + }); + it('renders', () => { + renderComponent(); + expect(screen.getByRole('searchbox', { name: /search for a backing concept/i })).toBeInTheDocument(); + }); + + it('allows user to search and select a concept', async () => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + const user = userEvent.setup(); + renderComponent(); + + 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(); + expect(searchInput).toHaveDisplayValue(/concept 1/i); + }); + + 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(); + renderComponent(); + + 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, + }); + renderComponent(); + + expect(screen.getByText(/error fetching concepts/i)).toBeInTheDocument(); + expect(screen.getByText(/please try again\./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, + }); + const user = userEvent.setup(); + renderComponent(); + + 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(); + }); +}); + +function renderComponent() { + render(); +} diff --git a/src/components/interactive-builder/modals/question/question-form/required-label/required-label.component.tsx b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.component.tsx similarity index 100% rename from src/components/interactive-builder/modals/question/question-form/required-label/required-label.component.tsx rename to src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.component.tsx diff --git a/src/components/interactive-builder/modals/question/question-form/required-label/required-label.scss b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss similarity index 55% rename from src/components/interactive-builder/modals/question/question-form/required-label/required-label.scss rename to src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss index 19e5da34..a73d5ee7 100644 --- a/src/components/interactive-builder/modals/question/question-form/required-label/required-label.scss +++ b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss @@ -1,6 +1,7 @@ @use '@carbon/colors'; +@use '@carbon/layout'; .required { color: colors.$red-60; - margin-left: 0.125rem; + margin-left: $spacing-02; } diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx index b4c365d9..340f1b70 100644 --- a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx @@ -1,7 +1,7 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; import { FormLabel, InlineNotification, MultiSelect, FormGroup, Tag, Stack } from '@carbon/react'; import { useTranslation } from 'react-i18next'; -import ConceptSearch from './concept-search.component'; +import ConceptSearch from '../../../common/concept-search/concept-search.component'; import { useFormField } from '../../../../form-field-context'; import type { Concept, ConceptMapping, DatePickerType } from '@types'; import styles from './obs-type-question.scss'; @@ -13,24 +13,8 @@ interface AnswerItem { const ObsTypeQuestion: React.FC = () => { const { t } = useTranslation(); - const { formField, setFormField } = useFormField(); - const [selectedConcept, setSelectedConcept] = useState(null); - const [selectedAnswers, setSelectedAnswers] = useState>( - formField.questionOptions?.answers - ? formField.questionOptions.answers.map((answer) => ({ - id: answer.concept, - text: answer.label, - })) - : [], - ); - const [addedAnswers, setAddedAnswers] = useState([]); + const { formField, setFormField, concept, setConcept } = useFormField(); const [conceptMappings, setConceptMappings] = useState>([]); - const initiallySelectedAnswers = useRef( - formField?.questionOptions?.answers?.map((answer) => ({ - id: answer.concept, - text: answer.label, - })), - ); const getDatePickerType = useCallback((concept: Concept): DatePickerType | null => { const conceptDataType = concept.datatype.name; @@ -47,20 +31,20 @@ const ObsTypeQuestion: React.FC = () => { }, []); const handleConceptSelect = useCallback( - (concept: Concept) => { - setSelectedConcept(concept); - if (concept) { - const datePickerType = getDatePickerType(concept); + (selectedConcept: Concept) => { + setConcept(selectedConcept); + if (selectedConcept) { + const datePickerType = getDatePickerType(selectedConcept); setFormField((prevField) => ({ ...prevField, questionOptions: { ...prevField.questionOptions, - concept: concept.uuid, + concept: selectedConcept.uuid, }, ...(datePickerType && { datePickerFormat: datePickerType }), })); setConceptMappings( - concept?.mappings?.map((conceptMapping) => { + selectedConcept?.mappings?.map((conceptMapping) => { const data = conceptMapping.display.split(': '); return { relationship: conceptMapping.conceptMapType.display, @@ -71,11 +55,11 @@ const ObsTypeQuestion: React.FC = () => { ); } }, - [getDatePickerType, setFormField], + [getDatePickerType, setFormField, setConcept], ); const clearSelectedConcept = useCallback(() => { - setSelectedConcept(null); + setConcept(null); setConceptMappings([]); setFormField((prevFormField) => { @@ -89,104 +73,7 @@ const ObsTypeQuestion: React.FC = () => { } return updatedFormField; }); - }, [setFormField]); - - const selectAnswers = useCallback( - ({ selectedItems }: { selectedItems: Array }) => { - const mappedAnswers = selectedItems.map((answer) => ({ - concept: answer.id, - label: answer.text, - })); - - setSelectedAnswers(selectedItems); - - setFormField((prevField) => { - const currentAnswers = prevField.questionOptions?.answers || []; - if (JSON.stringify(currentAnswers) === JSON.stringify(mappedAnswers)) { - return prevField; - } - return { - ...prevField, - questionOptions: { - ...prevField.questionOptions, - answers: mappedAnswers, - }, - }; - }); - }, - [setFormField], - ); - - const handleDeleteAdditionalAnswer = useCallback( - (id: string) => { - setAddedAnswers((prevAnswers) => prevAnswers.filter((answer) => answer.id !== id)); - setFormField((prevFormField) => { - const selectedAnswers = prevFormField.questionOptions?.answers ?? []; - return { - ...prevFormField, - questionOptions: { - ...prevFormField.questionOptions, - answers: selectedAnswers.filter((answer) => answer.concept !== id), - }, - }; - }); - }, - [setFormField], - ); - - const handleSelectAdditionalAnswer = useCallback( - (concept: Concept) => { - const newAnswer = { id: concept.uuid, text: concept.display }; - const answerExistsInSelected = selectedAnswers.some((answer) => answer.id === newAnswer.id); - const answerExistsInAdded = addedAnswers.some((answer) => answer.id === newAnswer.id); - if (!answerExistsInSelected && !answerExistsInAdded) { - setAddedAnswers((prevAnswers) => [...prevAnswers, newAnswer]); - setFormField((prevFormField) => { - const existingAnswers = prevFormField.questionOptions?.answers ?? []; - existingAnswers.push({ concept: concept.uuid, label: concept.display }); - return { - ...prevFormField, - questionOptions: { - ...prevFormField.questionOptions, - answers: existingAnswers, - }, - }; - }); - } - }, - [selectedAnswers, addedAnswers, setFormField], - ); - - const answerItems = useMemo(() => { - // Convert answers from the concept to items format - const conceptAnswerItems = - selectedConcept?.answers?.map((answer) => ({ - id: answer.uuid, - text: answer.display, - })) ?? []; - - const formFieldAnswers = formField.questionOptions?.answers ?? []; - - // If no answers from concept but we have form field answers, use those - if (conceptAnswerItems.length === 0 && formFieldAnswers.length > 0) { - return formFieldAnswers.map((answer) => ({ - id: answer.concept, - text: answer.label, - })); - } - - // Merge concept answers with any additional form field answers - const additionalAnswers = formFieldAnswers - .filter((answer) => !conceptAnswerItems.some((item) => item.id === answer.concept)) - .map((answer) => ({ - id: answer.concept, - text: answer.label, - })); - - return [...conceptAnswerItems, ...additionalAnswers]; - }, [selectedConcept?.answers, formField.questionOptions?.answers]); - - const convertAnswerItemsToString = useCallback((item: AnswerItem) => item.text, []); + }, [setFormField, setConcept]); return ( @@ -196,7 +83,7 @@ const ObsTypeQuestion: React.FC = () => { onSelectConcept={handleConceptSelect} /> - {selectedConcept?.allowDecimal === false && ( + {concept?.allowDecimal === false && ( { ) : null} - - {answerItems.length > 0 && ( - - )} - - {selectedAnswers.length > 0 && ( -
- {selectedAnswers.map((answer) => ( - - {answer.text} - - ))} -
- )} - - {selectedConcept && selectedConcept.datatype?.name === 'Coded' && ( - <> - - {addedAnswers.length > 0 ? ( -
- {addedAnswers.map((answer) => ( - - {answer.text} - - - ))} -
- ) : null}{' '} - - )}
); }; diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss index bfacc9b9..fd73ec10 100644 --- a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss @@ -1,69 +1,8 @@ @use '@carbon/colors'; @use '@carbon/layout'; -@use '@carbon/type'; .label { - margin-bottom: 0.5rem; -} - -.error { - width: 100%; - max-width: unset; - padding: '0rem'; - margin-bottom: 1rem; -} - -.loader { - padding: 0.75rem 0.5rem; -} - -.conceptList { - background: colors.$white-0; - max-height: 14rem; - overflow-y: auto; - border: 1px solid colors.$gray-20; - border-top: none; -} - -.conceptList li:hover { - background-color: colors.$gray-20; -} - -.concept { - padding: 0.75rem; - border-bottom: 1px solid colors.$gray-20; - - &:last-of-type { - border-bottom: none; - } -} - -.emptyResults { - @include type.type-style('body-compact-01'); - color: colors.$gray-70; - min-height: 1rem; -} - -.oclLauncherBanner { - padding: 0.5rem 1rem; - display: flex; - justify-content: space-between; - align-items: center; - background-color: colors.$gray-50; - - p { - color: colors.$white-0; - } -} - -.oclLink { - color: colors.$blue-10; - display: flex; - align-items: center; - - svg { - margin-left: 0.25rem; - } + margin-bottom: layout.$spacing-03; } .table { @@ -76,9 +15,9 @@ td, th { - padding: 0.25rem; + padding: layout.$spacing-02; border: 1px solid colors.$gray-20; - max-width: 10rem; + max-width: layout.$spacing-13; text-align: left; } } @@ -94,26 +33,3 @@ background-color: colors.$gray-10; } } - -.multiSelect { - :global(.cds--list-box__field--wrapper) { - align-items: center; - display: flex; - height: 2.5rem; - justify-content: space-between; - margin-left: 0.5rem; - width: 100%; - } -} - -.tag { - margin-right: 0.5rem; -} - -.conceptAnswerButton { - margin-left: 5px; - color: black; - background: none; - border: none; - padding: 0; -} diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx index efef32e5..ee4d0ba4 100644 --- a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx @@ -9,6 +9,7 @@ import type { FormField } from '@openmrs/esm-form-engine-lib'; import type { Concept } from '@types'; const mockSetFormField = jest.fn(); +const setConcept = jest.fn(); const formField: FormField = { id: '1', type: 'obs', @@ -19,7 +20,7 @@ const formField: FormField = { jest.mock('../../../../form-field-context', () => ({ ...jest.requireActual('../../../../form-field-context'), - useFormField: () => ({ formField, setFormField: mockSetFormField }), + useFormField: () => ({ formField, setFormField: mockSetFormField, setConcept }), })); const concepts: Array = [ @@ -66,7 +67,7 @@ describe('ObsTypeQuestion', () => { expect(screen.getByRole('searchbox', { name: /search for a backing concept/i })).toBeInTheDocument(); }); - it('renders the concept details after searching for a concept and displays the concept mappings and answers', async () => { + it('renders the concept details after searching for a concept and displays the concept mappings', async () => { mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); mockUseConceptId.mockReturnValue({ concept: null, @@ -98,70 +99,6 @@ describe('ObsTypeQuestion', () => { 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.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, - }); - const user = userEvent.setup(); - renderComponent(); - - 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(); - renderComponent(); - - 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, - }); - renderComponent(); - - 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 () => { @@ -201,7 +138,7 @@ describe('ObsTypeQuestion', () => { }); }); - it('loads the concept details along with the selected answer when editing a question', async () => { + it('loads the selected concept details', async () => { formField.questionOptions = { rendering: 'select', concept: concepts[0].uuid, @@ -214,7 +151,6 @@ describe('ObsTypeQuestion', () => { isLoadingConcept: false, conceptNameLookupError: null, }); - const user = userEvent.setup(); renderComponent(); expect( @@ -233,66 +169,6 @@ describe('ObsTypeQuestion', () => { 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(); - renderComponent(); - - 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, - }); - formField.questionOptions = { - rendering: 'select', - concept: concepts[0].uuid, - answers: [{ label: 'Answer 1', concept: '1' }], - }; - renderComponent(); - - 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/question.component.tsx b/src/components/interactive-builder/modals/question/question-form/question/question.component.tsx index a8bb16e6..0115330b 100644 --- a/src/components/interactive-builder/modals/question/question-form/question/question.component.tsx +++ b/src/components/interactive-builder/modals/question/question-form/question/question.component.tsx @@ -3,7 +3,7 @@ import { TextInput, Button, Select, SelectItem, RadioButtonGroup, RadioButton } import { useTranslation } from 'react-i18next'; import RenderTypeComponent from '../rendering-types/rendering-type.component'; import QuestionTypeComponent from '../question-types/question-type.component'; -import RequiredLabel from '../required-label/required-label.component'; +import RequiredLabel from '../common/required-label/required-label.component'; import { useFormField } from '../../form-field-context'; import type { FormField, RenderType } from '@openmrs/esm-form-engine-lib'; import { questionTypes, renderTypeOptions, renderingTypes } from '@constants'; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx index 4c3131ed..f0fdc123 100644 --- a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx @@ -5,3 +5,4 @@ export { default as TextArea } from './text-area/textarea.component'; export { default as Toggle } from './toggle/toggle.component'; export { default as UiSelectExtended } from './ui-select-extended/ui-select-extended.component'; export { default as Markdown } from './markdown/markdown.component'; +export { default as SelectAnswers } from './select/select-answers.component'; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.component.tsx new file mode 100644 index 00000000..c2a4cffb --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.component.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { Tag, MultiSelect, Stack } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import ConceptSearch from '../../../common/concept-search/concept-search.component'; +import { useFormField } from '../../../../form-field-context'; +import type { Concept } from '@types'; +import styles from './select.scss'; + +interface AnswerItem { + id: string; + text: string; +} + +const SelectAnswers: React.FC = () => { + const { t } = useTranslation(); + const { formField, concept, setFormField } = useFormField(); + const [selectedAnswers, setSelectedAnswers] = useState>( + formField.questionOptions?.answers + ? formField.questionOptions.answers.map((answer) => ({ + id: answer.concept, + text: answer.label, + })) + : [], + ); + const [addedAnswers, setAddedAnswers] = useState([]); + const initiallySelectedAnswers = useRef( + formField?.questionOptions?.answers?.map((answer) => ({ + id: answer.concept, + text: answer.label, + })), + ); + + const selectAnswers = useCallback( + ({ selectedItems }: { selectedItems: Array }) => { + const mappedAnswers = selectedItems.map((answer) => ({ + concept: answer.id, + label: answer.text, + })); + + setSelectedAnswers(selectedItems); + + setFormField((prevField) => { + const currentAnswers = prevField.questionOptions?.answers || []; + if (JSON.stringify(currentAnswers) === JSON.stringify(mappedAnswers)) { + return prevField; + } + return { + ...prevField, + questionOptions: { + ...prevField.questionOptions, + answers: mappedAnswers, + }, + }; + }); + }, + [setFormField], + ); + + const handleSelectAdditionalAnswer = useCallback( + (concept: Concept) => { + const newAnswer = { id: concept.uuid, text: concept.display }; + const answerExistsInSelected = selectedAnswers.some((answer) => answer.id === newAnswer.id); + const answerExistsInAdded = addedAnswers.some((answer) => answer.id === newAnswer.id); + if (!answerExistsInSelected && !answerExistsInAdded) { + setAddedAnswers((prevAnswers) => [...prevAnswers, newAnswer]); + setFormField((prevFormField) => { + const existingAnswers = prevFormField.questionOptions?.answers ?? []; + existingAnswers.push({ concept: concept.uuid, label: concept.display }); + return { + ...prevFormField, + questionOptions: { + ...prevFormField.questionOptions, + answers: existingAnswers, + }, + }; + }); + } + }, + [selectedAnswers, addedAnswers, setFormField], + ); + + const handleDeleteAdditionalAnswer = useCallback( + (id: string) => { + setAddedAnswers((prevAnswers) => prevAnswers.filter((answer) => answer.id !== id)); + setFormField((prevFormField) => { + const selectedAnswers = prevFormField.questionOptions?.answers ?? []; + return { + ...prevFormField, + questionOptions: { + ...prevFormField.questionOptions, + answers: selectedAnswers.filter((answer) => answer.concept !== id), + }, + }; + }); + }, + [setFormField], + ); + + const answerItems = useMemo(() => { + // Convert answers from the concept to items format + const conceptAnswerItems = + concept?.answers?.map((answer) => ({ + id: answer.uuid, + text: answer.display, + })) ?? []; + + const formFieldAnswers = formField.questionOptions?.answers ?? []; + + // If no answers from concept but we have form field answers, use those + if (conceptAnswerItems.length === 0 && formFieldAnswers.length > 0) { + return formFieldAnswers.map((answer) => ({ + id: answer.concept, + text: answer.label, + })); + } + + // Merge concept answers with any additional form field answers + const additionalAnswers = formFieldAnswers + .filter((answer) => !conceptAnswerItems.some((item) => item.id === answer.concept)) + .map((answer) => ({ + id: answer.concept, + text: answer.label, + })); + + return [...conceptAnswerItems, ...additionalAnswers]; + }, [concept?.answers, formField.questionOptions?.answers]); + + const convertAnswerItemsToString = useCallback((item: AnswerItem) => item.text, []); + + return ( + + {answerItems.length > 0 && ( + + )} + + {selectedAnswers.length > 0 && ( +
+ {selectedAnswers.map((answer) => ( + + {answer.text} + + ))} +
+ )} + + {concept && concept.datatype?.name === 'Coded' && ( + <> + + {addedAnswers.length > 0 ? ( +
+ {addedAnswers.map((answer) => ( + + {answer.text} + + + ))} +
+ ) : null}{' '} + + )} +
+ ); +}; + +export default SelectAnswers; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.scss b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.scss new file mode 100644 index 00000000..dd82eaa5 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.scss @@ -0,0 +1,25 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; + +.multiSelect { + :global(.cds--list-box__field--wrapper) { + align-items: center; + display: flex; + height: layout.$spacing-08; + justify-content: space-between; + margin-left: layout.$spacing-03; + width: 100%; + } +} + +.tag { + margin-right: layout.$spacing-03; +} + +.conceptAnswerButton { + margin-left: 5px; + color: colors.$black; + background: none; + border: none; + padding: 0; +} diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.test.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.test.tsx new file mode 100644 index 00000000..53c12bcf --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.test.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import SelectAnswers from './select-answers.component'; +import { useConceptId } from '@hooks/useConceptId'; +import { useConceptLookup } from '@hooks/useConceptLookup'; +import { FormFieldProvider } from '../../../../form-field-context'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; +import type { Concept } from '@types'; +import userEvent from '@testing-library/user-event'; + +const formField: FormField = { + id: '1', + type: 'obs', + questionOptions: { + rendering: 'select', + }, +}; +const concept: Concept = { + 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' }, + ], +}; + +const concepts: Array = [ + concept, + { + uuid: '456', + display: 'Concept 2', + datatype: { uuid: '456', name: 'Date' }, + mappings: [{ display: 'CIEL:1656', conceptMapType: { display: 'SAME-AS' } }], + }, +]; + +const mockUseConceptLookup = jest.mocked(useConceptLookup); +jest.mock('@hooks/useConceptLookup', () => ({ + ...jest.requireActual('@hooks/useConceptLookup'), + useConceptLookup: jest.fn(), +})); +const mockUseConceptId = jest.mocked(useConceptId); +jest.mock('@hooks/useConceptId', () => ({ + ...jest.requireActual('@hooks/useConceptId'), + useConceptId: jest.fn(), +})); + +describe('Select answers component', () => { + beforeEach(() => { + mockUseConceptLookup.mockReturnValue({ concepts: concepts, conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + }); + it('renders', () => { + renderComponent(); + expect(screen.getByText(/select answers to display/i)).toBeInTheDocument(); + expect(screen.getByText(/search for a concept to add as an answer/i)).toBeInTheDocument(); + expect( + screen.getByRole('searchbox', { + name: /search for a backing concept/i, + }), + ).toBeInTheDocument(); + }); + + it('lets user select answers provided by concept', async () => { + const user = userEvent.setup(); + renderComponent(); + const answersMenu = screen.getByRole('combobox', { + name: /select answers to display/i, + }); + expect(answersMenu).toBeInTheDocument(); + + await user.click(answersMenu); + const answerOption1 = screen.getByRole('option', { name: /answer 1/i }); + expect(answerOption1).toBeInTheDocument(); + expect(screen.getByText(/answer 2/i)).toBeInTheDocument(); + await user.click(answerOption1); + + expect(screen.getByTitle(/answer 1/i)).toBeInTheDocument(); + expect( + screen.getByRole('combobox', { + name: /select answers to display total items selected: 1,to clear selection, press delete or backspace/i, + }), + ).toBeInTheDocument(); + }); + + it('lets users add additional answers if concept is of datatype coded', async () => { + const user = userEvent.setup(); + renderComponent(); + + const searchInput = screen.getByRole('searchbox', { name: /search for a backing concept/i }); + await user.click(searchInput); + await user.type(searchInput, 'Concept 1'); + expect( + screen.getByRole('searchbox', { + name: /search for a backing concept/i, + }), + ).toHaveDisplayValue(/concept 1/i); + const additionalAnswerOption1 = screen.getByRole('menuitem', { + name: /concept 1/i, + }); + expect(additionalAnswerOption1).toBeInTheDocument(); + await user.click(additionalAnswerOption1); + expect(screen.getByText(/concept 1/i)).toBeInTheDocument(); + expect( + screen.getByRole('button', { + name: /x/i, + }), + ).toBeInTheDocument(); + }); +}); + +function renderComponent() { + render( + + + , + ); +} diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.component.tsx index 0337beed..ba026f04 100644 --- a/src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.component.tsx +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.component.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Date, Markdown, Number, Text, TextArea, Toggle, UiSelectExtended } from './inputs'; +import { Date, Markdown, Number, SelectAnswers, Text, TextArea, Toggle, UiSelectExtended } from './inputs'; import { useFormField } from '../../form-field-context'; import type { RenderType } from '@openmrs/esm-form-engine-lib'; import { renderTypeOptions, renderingTypes } from '@constants'; @@ -13,6 +13,9 @@ const componentMap: Partial> = { date: Date, datetime: Date, markdown: Markdown, + select: SelectAnswers, + radio: SelectAnswers, + checkbox: SelectAnswers, }; const RenderTypeComponent: React.FC = () => {