From e5c6f768ad463512c7777f418d4ab0893e999888 Mon Sep 17 00:00:00 2001 From: Nethmi Rodrigo <34070216+NethmiRodrigo@users.noreply.github.com> Date: Thu, 30 Jan 2025 20:48:15 +0530 Subject: [PATCH] (refactor) O3-4145: Break question modal into individual components (#359) --- .eslintrc | 51 +- .gitignore | 2 + .prettierignore | 1 - e2e/specs/interactive-builder.spec.ts | 29 +- jest.config.js | 8 +- package.json | 2 +- .../action-buttons.component.tsx | 13 +- .../audit-details/audit-details.component.tsx | 2 +- .../dashboard/dashboard.component.tsx | 12 +- src/components/dashboard/dashboard.test.tsx | 6 +- .../form-editor/form-editor.component.tsx | 14 +- .../add-question.modal.tsx | 1040 ---------------- .../draggable-question.component.tsx | 20 +- .../{ => draggable}/draggable-question.scss | 4 +- .../droppable-container.component.tsx | 0 .../{ => droppable}/droppable-container.scss | 0 .../edit-question.modal.tsx | 1103 ----------------- .../editable-value.component.tsx | 2 +- .../{ => editable}/editable-value.scss | 6 +- .../interactive-builder.component.tsx | 76 +- .../markdown-question.component.tsx | 52 - .../markdown-wrapper.tsx | 0 .../modals/delete-form}/delete-form.modal.tsx | 0 .../modals/delete-form}/delete-form.scss | 0 .../delete-page}/delete-page.modal.tsx | 2 +- .../delete-question.modal.tsx | 2 +- .../delete-section}/delete-section.modal.tsx | 2 +- .../modals}/modals.scss | 0 .../{ => modals/new-form}/new-form.modal.tsx | 2 +- .../{ => modals/new-page}/page.modal.tsx | 2 +- .../new-section}/section.modal.tsx | 2 +- .../modals/question/form-field-context.tsx | 54 + .../concept-search.component.tsx | 154 +++ .../common/concept-search/concept-search.scss | 67 + .../concept-search/concept-search.test.tsx | 134 ++ .../required-label.component.tsx | 24 + .../common/required-label/required-label.scss | 7 + .../question-types/inputs/index.tsx | 3 + .../obs/obs-type-question.component.tsx | 116 ++ .../inputs/obs/obs-type-question.scss | 35 + .../inputs/obs/obs-type-question.test.tsx | 181 +++ ...ent-identifier-type-question.component.tsx | 72 ++ .../patient-identifier-type-question.scss | 0 .../patient-identifier-type.test.tsx | 122 ++ .../program-state-type-question.component.tsx | 213 ++++ .../program-state-type-question.scss | 0 .../program-state/program-state.test.tsx | 200 +++ .../question-type.component.tsx | 23 + .../question-types/question-type.test.tsx | 62 + .../question/question.component.tsx | 157 +++ .../question-form/question/question.scss | 9 + .../inputs/date/date.component.tsx | 41 + .../rendering-types/inputs/date/date.test.tsx | 57 + .../rendering-types/inputs/index.tsx | 8 + .../inputs/markdown/markdown.component.tsx | 44 + .../inputs/markdown/markdown.scss} | 0 .../inputs/number/number.component.tsx | 52 + .../inputs/number/number.test.tsx | 86 ++ .../select/select-answers.component.tsx | 176 +++ .../inputs/select/select-answers.scss | 25 + .../inputs/select/select-answers.test.tsx | 129 ++ .../inputs/text-area/textarea.component.tsx | 26 + .../inputs/text-area/textarea.test.tsx | 60 + .../inputs/text/text.component.tsx | 56 + .../rendering-types/inputs/text/text.test.tsx | 73 ++ .../inputs/toggle/toggle.component.tsx | 48 + .../inputs/toggle/toggle.test.tsx | 59 + .../ui-select-extended.component.tsx | 48 + .../ui-select-extended.test.tsx | 58 + .../rendering-type.component.tsx | 45 + .../rendering-types/rendering-type.test.tsx | 100 ++ .../modals/question/question.modal.tsx | 172 +++ .../question/question.scss} | 42 +- .../restore-draft-schema.modal.tsx | 2 +- .../modals/save-form}/save-form.modal.tsx | 11 +- .../modals/save-form/save-form.scss} | 0 .../unpublish-form}/unpublish-form.modal.tsx | 0 .../value-editor.component.tsx | 0 .../{ => value-editor}/value-editor.scss | 2 +- .../schema-editor/schema-editor.component.tsx | 5 +- src/config-schema.ts | 2 +- src/constants.ts | 56 + src/hooks/useClobdata.ts | 2 +- src/hooks/useConceptId.ts | 23 + src/hooks/useConceptLookup.ts | 2 +- src/hooks/useConceptName.ts | 15 - src/hooks/useEncounterTypes.ts | 2 +- src/hooks/useForm.ts | 2 +- src/hooks/useForms.ts | 2 +- src/hooks/usePatientIdentifierLookup.ts | 2 +- src/hooks/usePatientIdentifierTypes.ts | 2 +- src/hooks/usePersonAttributeLookup.ts | 2 +- src/hooks/usePersonAttributeTypes.ts | 2 +- src/hooks/useProgramStates.ts | 2 +- src/index.ts | 36 +- .../form-validator.resource.ts | 4 +- src/{ => resources}/forms.resource.ts | 2 +- src/routes.json | 4 +- src/types.ts | 21 +- .../i18next-parser.config.js | 0 {src => tools}/setup-tests.ts | 0 {src => tools}/test-helpers.tsx | 0 translations/en.json | 19 +- tsconfig.json | 8 + webpack.config.js | 7 + yarn.lock | 368 +++--- 106 files changed, 3506 insertions(+), 2592 deletions(-) delete mode 100644 src/components/interactive-builder/add-question.modal.tsx rename src/components/interactive-builder/{ => draggable}/draggable-question.component.tsx (90%) rename src/components/interactive-builder/{ => draggable}/draggable-question.scss (92%) rename src/components/interactive-builder/{ => droppable}/droppable-container.component.tsx (100%) rename src/components/interactive-builder/{ => droppable}/droppable-container.scss (100%) delete mode 100644 src/components/interactive-builder/edit-question.modal.tsx rename src/components/interactive-builder/{ => editable}/editable-value.component.tsx (94%) rename src/components/interactive-builder/{ => editable}/editable-value.scss (89%) delete mode 100644 src/components/interactive-builder/markdown-question.component.tsx rename src/components/interactive-builder/{ => markdown-wrapper}/markdown-wrapper.tsx (100%) rename src/components/{dashboard => interactive-builder/modals/delete-form}/delete-form.modal.tsx (100%) rename src/components/{dashboard => interactive-builder/modals/delete-form}/delete-form.scss (100%) rename src/components/interactive-builder/{ => modals/delete-page}/delete-page.modal.tsx (97%) rename src/components/interactive-builder/{ => modals/delete-question}/delete-question.modal.tsx (98%) rename src/components/interactive-builder/{ => modals/delete-section}/delete-section.modal.tsx (98%) rename src/components/{ => interactive-builder/modals}/modals.scss (100%) rename src/components/interactive-builder/{ => modals/new-form}/new-form.modal.tsx (98%) rename src/components/interactive-builder/{ => modals/new-page}/page.modal.tsx (98%) rename src/components/interactive-builder/{ => modals/new-section}/section.modal.tsx (98%) create mode 100644 src/components/interactive-builder/modals/question/form-field-context.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/index.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/question-type.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question-types/question-type.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question/question.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/question/question.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/markdown/markdown.component.tsx rename src/components/interactive-builder/{markdown-question.scss => modals/question/question-form/rendering-types/inputs/markdown/markdown.scss} (100%) create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.scss create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text/text.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text/text.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/toggle/toggle.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/toggle/toggle.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/ui-select-extended/ui-select-extended.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/ui-select-extended/ui-select-extended.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.component.tsx create mode 100644 src/components/interactive-builder/modals/question/question-form/rendering-types/rendering-type.test.tsx create mode 100644 src/components/interactive-builder/modals/question/question.modal.tsx rename src/components/interactive-builder/{question-modal.scss => modals/question/question.scss} (80%) rename src/components/{form-editor => interactive-builder/modals/restore-draft-schema}/restore-draft-schema.modal.tsx (97%) rename src/components/{modals => interactive-builder/modals/save-form}/save-form.modal.tsx (98%) rename src/components/{modals/save-form-modal.scss => interactive-builder/modals/save-form/save-form.scss} (100%) rename src/components/{action-buttons => interactive-builder/modals/unpublish-form}/unpublish-form.modal.tsx (100%) rename src/components/interactive-builder/{ => value-editor}/value-editor.component.tsx (100%) rename src/components/interactive-builder/{ => value-editor}/value-editor.scss (84%) create mode 100644 src/constants.ts create mode 100644 src/hooks/useConceptId.ts delete mode 100644 src/hooks/useConceptName.ts rename src/{ => resources}/form-validator.resource.ts (98%) rename src/{ => resources}/forms.resource.ts (98%) rename i18next-parser.config.js => tools/i18next-parser.config.js (100%) rename {src => tools}/setup-tests.ts (100%) rename {src => tools}/test-helpers.tsx (100%) diff --git a/.eslintrc b/.eslintrc index 90988c21..5e25b44d 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,50 +4,52 @@ }, "extends": [ "eslint:recommended", - "plugin:playwright/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": [ + { + "files": ["e2e/**"], + "excludedFiles":["src/**"], + "extends": ["plugin:playwright/recommended"] + } + ], "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", { @@ -73,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/.gitignore b/.gitignore index 59b26cb1..65caf77e 100644 --- a/.gitignore +++ b/.gitignore @@ -78,7 +78,9 @@ dist/ # EditorConfig .editorconfig +.DS_Store .idea +.vscode .turbo /test-results/ /playwright-report/ diff --git a/.prettierignore b/.prettierignore index 54103e38..73638641 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,6 +9,5 @@ yarn.lock # by file type **/*.css -**/*.scss **/*.md **/*.json \ No newline at end of file diff --git a/e2e/specs/interactive-builder.spec.ts b/e2e/specs/interactive-builder.spec.ts index cbf9fa4c..febf904d 100644 --- a/e2e/specs/interactive-builder.spec.ts +++ b/e2e/specs/interactive-builder.spec.ts @@ -36,7 +36,6 @@ test('Create a form using the interactive builder', async ({ page, context }) => }, ], }, - validators: [], }, ], }, @@ -75,6 +74,7 @@ test('Create a form using the interactive builder', async ({ page, context }) => }); await test.step('And then I click on `Create Form`', async () => { + await expect(formBuilderPage.createFormButton()).toBeEnabled(); await formBuilderPage.createFormButton().click(); await expect(formBuilderPage.page.getByText(/form created/i)).toBeVisible(); expect(JSON.parse(await formBuilderPage.schemaEditorContent().textContent())).toEqual({ @@ -92,6 +92,7 @@ test('Create a form using the interactive builder', async ({ page, context }) => }); await test.step('And then I click on `Save`', async () => { + await expect(formBuilderPage.savePageButton()).toBeEnabled(); await formBuilderPage.savePageButton().click(); await expect(formBuilderPage.page.getByText(/new page created/i)).toBeVisible(); expect(JSON.parse(await formBuilderPage.schemaEditorContent().textContent())).toEqual({ @@ -114,6 +115,7 @@ test('Create a form using the interactive builder', async ({ page, context }) => }); await test.step('And then I click on `Save`', async () => { + await expect(formBuilderPage.saveQuestionButton()).toBeEnabled(); await formBuilderPage.saveSectionButton().click(); await expect(formBuilderPage.page.getByText(/new section created/i)).toBeVisible(); expect(JSON.parse(await formBuilderPage.schemaEditorContent().textContent())).toEqual({ @@ -141,22 +143,10 @@ test('Create a form using the interactive builder', async ({ page, context }) => await formBuilderPage.addQuestionButton().click(); }); - await test.step('And then I type in the question label', async () => { - await formBuilderPage.questionLabelInput().fill(formDetails.pages[0].sections[0].questions[0].label); - }); - await test.step('And then I type in the question id', async () => { await formBuilderPage.questionIdInput().fill(formDetails.pages[0].sections[0].questions[0].id); }); - await test.step('And then I set the question type to required', async () => { - await formBuilderPage.page - .getByRole('group', { name: /Is this question a required/i }) - .locator('span') - .nth(2) - .click(); - }); - await test.step('And then I set the question type to obs', async () => { await formBuilderPage.questionTypeDropdown().selectOption('obs'); }); @@ -165,6 +155,18 @@ test('Create a form using the interactive builder', async ({ page, context }) => await formBuilderPage.renderingTypeDropdown().selectOption('radio'); }); + await test.step('And then I type in the question label', async () => { + await formBuilderPage.questionLabelInput().fill(formDetails.pages[0].sections[0].questions[0].label); + }); + + await test.step('And then I set the question type to required', async () => { + await formBuilderPage.page + .getByRole('group', { name: /Is this question a required/i }) + .locator('span') + .nth(2) + .click(); + }); + await test.step('And then I select the concept to be `Tested for COVID 19`', async () => { await formBuilderPage.conceptSearchInput().fill('Tested for COVID 19'); await formBuilderPage.conceptSearchInput().press('Enter'); @@ -178,6 +180,7 @@ test('Create a form using the interactive builder', async ({ page, context }) => }); await test.step('And then I click on `Save`', async () => { + await expect(formBuilderPage.saveQuestionButton()).toBeEnabled(); await formBuilderPage.saveQuestionButton().click(); await expect(formBuilderPage.page.getByText(/new question created/i)).toBeVisible(); }); diff --git a/jest.config.js b/jest.config.js index bba5829e..ccd4543f 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,7 +5,13 @@ module.exports = { '^.+\\.tsx?$': ['@swc/jest'], }, transformIgnorePatterns: ['/node_modules/(?!@openmrs)'], + moduleDirectories: ['node_modules', '__mocks__', 'tools', 'src', __dirname], moduleNameMapper: { + '^@resources/(.*)$': '/src/resources/$1', + '^@hooks/(.*)$': '/src/hooks/$1', + '^@types$': '/src/types.ts', + '^@tools/(.*)$': '/tools/$1', + '^@constants$': '/src/constants.ts', '^@carbon/icons-react/es/(.*)$': '@carbon/icons-react/lib/$1', '^carbon-components-react/es/(.*)$': 'carbon-components-react/lib/$1', '@openmrs/esm-framework': '@openmrs/esm-framework/mock', @@ -16,7 +22,7 @@ module.exports = { '^react-i18next$': '/__mocks__/react-i18next.js', 'react-markdown': '/__mocks__/react-markdown.tsx', }, - setupFilesAfterEnv: ['/src/setup-tests.ts'], + setupFilesAfterEnv: ['/tools/setup-tests.ts'], testEnvironment: 'jsdom', testPathIgnorePatterns: ['/e2e'], }; diff --git a/package.json b/package.json index 32f0a5e8..0d780c12 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "verify": "turbo run lint typescript coverage --color", "coverage": "yarn test --coverage --passWithNoTests", "postinstall": "husky install", - "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.modal.tsx' --config ./i18next-parser.config.js", + "extract-translations": "i18next 'src/**/*.component.tsx' 'src/**/*.modal.tsx' --config ./tools/i18next-parser.config.js", "ci:bump-form-engine-lib": "yarn up @openmrs/esm-form-engine-lib@latest" }, "files": [ diff --git a/src/components/action-buttons/action-buttons.component.tsx b/src/components/action-buttons/action-buttons.component.tsx index c6bec954..eb6cc40f 100644 --- a/src/components/action-buttons/action-buttons.component.tsx +++ b/src/components/action-buttons/action-buttons.component.tsx @@ -1,16 +1,15 @@ import React, { useCallback, useState } from 'react'; import { Button, InlineLoading } from '@carbon/react'; import { useParams } from 'react-router-dom'; +import { showModal, showSnackbar, useConfig } from '@openmrs/esm-framework'; +import SaveFormModal from '../interactive-builder/modals/save-form/save-form.modal'; +import { handleFormValidation } from '@resources/form-validator.resource'; +import { publishForm, unpublishForm } from '@resources/forms.resource'; +import { useForm } from '@hooks/useForm'; import type { IMarker } from 'react-ace'; import type { TFunction } from 'react-i18next'; -import { showModal, showSnackbar, useConfig } from '@openmrs/esm-framework'; - -import { handleFormValidation } from '../../form-validator.resource'; -import { publishForm, unpublishForm } from '../../forms.resource'; -import { useForm } from '../../hooks/useForm'; -import SaveFormModal from '../modals/save-form.modal'; import type { ConfigObject } from '../../config-schema'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from './action-buttons.scss'; interface ActionButtonsProps { diff --git a/src/components/audit-details/audit-details.component.tsx b/src/components/audit-details/audit-details.component.tsx index 4dc4acc8..238af341 100644 --- a/src/components/audit-details/audit-details.component.tsx +++ b/src/components/audit-details/audit-details.component.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { formatDatetime, parseDate } from '@openmrs/esm-framework'; import { StructuredListWrapper, StructuredListRow, StructuredListCell, StructuredListBody } from '@carbon/react'; -import type { EncounterType } from '../../types'; +import type { EncounterType } from '@types'; interface AuditDetailsProps { form: FormGroupData; diff --git a/src/components/dashboard/dashboard.component.tsx b/src/components/dashboard/dashboard.component.tsx index a0c95519..e16361c3 100644 --- a/src/components/dashboard/dashboard.component.tsx +++ b/src/components/dashboard/dashboard.component.tsx @@ -34,15 +34,15 @@ import { useLayoutType, usePagination, } from '@openmrs/esm-framework'; -import type { ConfigObject } from '../../config-schema'; -import type { Form as TypedForm } from '../../types'; -import { deleteForm } from '../../forms.resource'; -import { FormBuilderPagination } from '../pagination'; -import { useClobdata } from '../../hooks/useClobdata'; -import { useForms } from '../../hooks/useForms'; import EmptyState from '../empty-state/empty-state.component'; import ErrorState from '../error-state/error-state.component'; import Header from '../header/header.component'; +import { deleteForm } from '@resources/forms.resource'; +import { FormBuilderPagination } from '../pagination'; +import { useClobdata } from '@hooks/useClobdata'; +import { useForms } from '@hooks/useForms'; +import type { ConfigObject } from '../../config-schema'; +import type { Form as TypedForm } from '@types'; import styles from './dashboard.scss'; type Mutator = KeyedMutator<{ diff --git a/src/components/dashboard/dashboard.test.tsx b/src/components/dashboard/dashboard.test.tsx index 871eaa78..b3ad9d8e 100644 --- a/src/components/dashboard/dashboard.test.tsx +++ b/src/components/dashboard/dashboard.test.tsx @@ -2,9 +2,9 @@ import React from 'react'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { type FetchResponse, navigate, openmrsFetch, showModal } from '@openmrs/esm-framework'; -import { renderWithSwr, waitForLoadingToFinish } from '../../test-helpers'; -import { deleteForm } from '../../forms.resource'; import Dashboard from './dashboard.component'; +import { renderWithSwr, waitForLoadingToFinish } from '@tools/test-helpers'; +import { deleteForm } from '@resources/forms.resource'; type OpenmrsFetchResponse = Promise< FetchResponse<{ @@ -38,7 +38,7 @@ const formsResponse = [ }, ]; -jest.mock('../../forms.resource', () => ({ +jest.mock('@resources/forms.resource', () => ({ deleteForm: jest.fn(), })); diff --git a/src/components/form-editor/form-editor.component.tsx b/src/components/form-editor/form-editor.component.tsx index 90162c13..1813c6b4 100644 --- a/src/components/form-editor/form-editor.component.tsx +++ b/src/components/form-editor/form-editor.component.tsx @@ -19,13 +19,6 @@ import { ArrowLeft, Maximize, Minimize, Download } from '@carbon/react/icons'; import { useParams } from 'react-router-dom'; import { type TFunction, useTranslation } from 'react-i18next'; import { ConfigurableLink, showModal, useConfig } from '@openmrs/esm-framework'; -import type { IMarker } from 'react-ace'; -import type { FormSchema } from '@openmrs/esm-form-engine-lib'; -import type { Schema } from '../../types'; -import { useClobdata } from '../../hooks/useClobdata'; -import { useForm } from '../../hooks/useForm'; -import { handleFormValidation } from '../../form-validator.resource'; -import { type ConfigObject } from '../../config-schema'; import ActionButtons from '../action-buttons/action-buttons.component'; import AuditDetails from '../audit-details/audit-details.component'; import FormRenderer from '../form-renderer/form-renderer.component'; @@ -33,6 +26,13 @@ import Header from '../header/header.component'; import InteractiveBuilder from '../interactive-builder/interactive-builder.component'; import SchemaEditor from '../schema-editor/schema-editor.component'; import ValidationMessage from '../validation-info/validation-info.component'; +import { handleFormValidation } from '@resources/form-validator.resource'; +import { useClobdata } from '@hooks/useClobdata'; +import { useForm } from '@hooks/useForm'; +import type { IMarker } from 'react-ace'; +import type { FormSchema } from '@openmrs/esm-form-engine-lib'; +import type { Schema } from '@types'; +import type { ConfigObject } from '../../config-schema'; import styles from './form-editor.scss'; interface ErrorProps { diff --git a/src/components/interactive-builder/add-question.modal.tsx b/src/components/interactive-builder/add-question.modal.tsx deleted file mode 100644 index a7e48705..00000000 --- a/src/components/interactive-builder/add-question.modal.tsx +++ /dev/null @@ -1,1040 +0,0 @@ -import React, { useState } from 'react'; -import { useTranslation, type TFunction } from 'react-i18next'; -import flattenDeep from 'lodash-es/flattenDeep'; -import { - Button, - ComboBox, - Form, - FormGroup, - FormLabel, - InlineLoading, - InlineNotification, - Layer, - ModalBody, - ModalFooter, - ModalHeader, - MultiSelect, - RadioButton, - RadioButtonGroup, - Search, - Select, - SelectItem, - SelectSkeleton, - Stack, - Tag, - TextInput, - Tile, -} from '@carbon/react'; -import { ArrowUpRight } from '@carbon/react/icons'; -import { showSnackbar, useConfig, useDebounce } from '@openmrs/esm-framework'; -import type { ProgramState, RenderType } from '@openmrs/esm-form-engine-lib'; - -import type { ConfigObject } from '../../config-schema'; -import type { - Answer, - Concept, - ConceptMapping, - PersonAttributeType, - PatientIdentifierType, - Schema, - QuestionType, - Program, - ProgramWorkflow, - DatePickerType, - DatePickerTypeOption, -} from '../../types'; -import { useConceptLookup } from '../../hooks/useConceptLookup'; -import { usePatientIdentifierTypes } from '../../hooks/usePatientIdentifierTypes'; -import { usePersonAttributeTypes } from '../../hooks/usePersonAttributeTypes'; -import { useProgramWorkStates, usePrograms } from '../../hooks/useProgramStates'; -import MarkdownQuestion from './markdown-question.component'; -import styles from './question-modal.scss'; - -interface AddQuestionModalProps { - closeModal: () => void; - onSchemaChange: (schema: Schema) => void; - pageIndex: number; - questionIndex: number; - resetIndices: () => void; - schema: Schema; - sectionIndex: number; -} - -interface Item { - text: string; -} -interface ProgramStateData { - selectedItems: Array; -} - -interface RequiredLabelProps { - isRequired: boolean; - text: string; - t: TFunction; -} - -export const getDatePickerType = (concept: Concept): DatePickerType | null => { - const conceptDataType = concept.datatype.name; - switch (conceptDataType) { - case 'Datetime': - return 'both'; - case 'Date': - return 'calendar'; - case 'Time': - return 'timer'; - default: - console.warn(`Unsupported datatype for date fields: ${conceptDataType}`); - return null; - } -}; - -const AddQuestionModal: React.FC = ({ - closeModal, - onSchemaChange, - pageIndex, - schema, - sectionIndex, -}) => { - const { t } = useTranslation(); - const { fieldTypes, questionTypes } = useConfig(); - const [answers, setAnswers] = useState>([]); - const [conceptMappings, setConceptMappings] = useState>([]); - const [conceptToLookup, setConceptToLookup] = useState(''); - const [conceptAnsToLookup, setConceptAnsToLookup] = useState(''); - const debouncedAnsConceptToLookup = useDebounce(conceptAnsToLookup); - const debouncedConceptToLookup = useDebounce(conceptToLookup); - const [datePickerType, setDatePickerType] = useState('both'); - const [renderingType, setRenderingType] = useState(null); - const [isQuestionRequired, setIsQuestionRequired] = useState(false); - const [max, setMax] = useState(''); - const [min, setMin] = useState(''); - const [questionId, setQuestionId] = useState(''); - const [questionLabel, setQuestionLabel] = useState(''); - const [questionValue, setQuestionValue] = useState(''); - const [questionType, setQuestionType] = useState(null); - const [rows, setRows] = useState(''); - const [selectedAnswers, setSelectedAnswers] = useState< - Array<{ - id: string; - text: string; - }> - >([]); - const [addedAnswers, setAddedAnswers] = useState< - Array<{ - id: string; - text: string; - }> - >([]); - const [selectedConcept, setSelectedConcept] = useState(null); - const [selectedAnsConcept, setSelectedAnsConcept] = useState(null); - const [selectedPersonAttributeType, setSelectedPersonAttributeType] = useState(null); - const { concepts, conceptLookupError, isLoadingConcepts } = useConceptLookup(debouncedConceptToLookup); - const { - concepts: ansConcepts, - conceptLookupError: conceptAnsLookupError, - isLoadingConcepts: isLoadingAnsConcepts, - } = useConceptLookup(debouncedAnsConceptToLookup); - const { personAttributeTypes, personAttributeTypeLookupError } = usePersonAttributeTypes(); - const [selectedPatientIdetifierType, setSelectedPatientIdetifierType] = useState(null); - const { patientIdentifierTypes, patientIdentifierTypeLookupError } = usePatientIdentifierTypes(); - const [addObsComment, setAddObsComment] = useState(false); - const [addInlineDate, setAddInlineDate] = useState(false); - const [selectedProgramState, setSelectedProgramState] = useState>([]); - const [selectedProgram, setSelectedProgram] = useState(null); - const [programWorkflow, setProgramWorkflow] = useState(null); - const { programs, programsLookupError, isLoadingPrograms } = usePrograms(); - const { programStates, programStatesLookupError, isLoadingProgramStates, mutateProgramStates } = useProgramWorkStates( - programWorkflow?.uuid, - ); - const [programWorkflows, setProgramWorkflows] = useState>([]); - const [toggleLabelTrue, setToggleLabelTrue] = useState(''); - const [toggleLabelFalse, setToggleLabelFalse] = useState(''); - - const renderTypeOptions = { - encounterDatetime: ['date'], - encounterLocation: ['ui-select-extended'], - encounterProvider: ['ui-select-extended'], - encounterRole: ['ui-select-extended'], - obsGroup: ['group', 'repeating'], - personAttribute: ['ui-select-extended', 'select', 'text'], - testOrder: ['group', 'repeating'], - patientIdentifier: ['text'], - programState: ['select'], - }; - - // Maps the data type of a concept to a date picker type. - const datePickerTypeOptions: Record> = { - datetime: [{ value: 'both', label: t('calendarAndTimer', 'Calendar and timer'), defaultChecked: true }], - date: [{ value: 'calendar', label: t('calendarOnly', 'Calendar only'), defaultChecked: false }], - time: [{ value: 'timer', label: t('timerOnly', 'Timer only'), defaultChecked: false }], - }; - - const handleConceptChange = (event: React.ChangeEvent) => setConceptToLookup(event.target.value); - const handleAnsConceptChange = (event: React.ChangeEvent) => - setConceptAnsToLookup(event.target.value); - - const handleConceptSelect = (concept: Concept) => { - const updatedDatePickerType = getDatePickerType(concept); - if (updatedDatePickerType) setDatePickerType(updatedDatePickerType); - setConceptToLookup(''); - setSelectedConcept(concept); - setAnswers( - concept?.answers?.map((answer) => ({ - concept: answer?.uuid, - label: answer?.display, - })), - ); - setConceptMappings( - concept?.mappings?.map((conceptMapping) => { - const data = conceptMapping.display.split(': '); - return { - relationship: conceptMapping.conceptMapType.display, - type: data[0], - value: data[1], - }; - }), - ); - }; - const handleDeleteAnswer = (id) => { - setAddedAnswers((prevAnswers) => prevAnswers.filter((answer) => answer.id !== id)); - }; - const handleSaveMoreAnswers = () => { - const newAnswers = addedAnswers.filter( - (newAnswer) => !selectedAnswers.some((prevAnswer) => prevAnswer.id === newAnswer.id), - ); - - const updatedAnswers = [...selectedAnswers, ...newAnswers]; - setSelectedAnswers(updatedAnswers); - setAddedAnswers([]); - return updatedAnswers; - }; - - const handleConceptAnsSelect = (concept: Concept) => { - setConceptAnsToLookup(''); - setSelectedAnsConcept(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]); - } - }; - - const handlePersonAttributeTypeChange = ({ selectedItem }: { selectedItem: PersonAttributeType }) => { - setSelectedPersonAttributeType(selectedItem); - }; - - const handlePatientIdentifierTypeChange = ({ selectedItem }: { selectedItem: PatientIdentifierType }) => { - setSelectedPatientIdetifierType(selectedItem); - }; - const questionIdExists = (idToTest: string) => { - const nestedIds = schema?.pages?.map((page) => { - return page?.sections?.map((section) => { - return section?.questions?.map((question) => { - return question.id; - }); - }); - }); - - const questionIds: Array = flattenDeep(nestedIds); - return questionIds.includes(idToTest); - }; - const handleCreateQuestion = () => { - const updatedAnswers = handleSaveMoreAnswers(); - createQuestion(updatedAnswers); - closeModal(); - }; - - const createQuestion = (updatedAnswers) => { - try { - const questionIndex = schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.length ?? 0; - const computedQuestionId = `question${questionIndex + 1}Section${sectionIndex + 1}Page-${pageIndex + 1}`; - - const newQuestion = { - ...(questionLabel && {label: questionLabel}), - ...((renderingType === 'markdown') && {value: questionValue}), - type: questionType ? questionType : 'control', - required: isQuestionRequired, - id: questionId ?? computedQuestionId, - ...((renderingType === 'date' || renderingType === 'datetime') && - datePickerType && { datePickerFormat: datePickerType }), - questionOptions: { - rendering: renderingType, - ...(min && { min }), - ...(max && { max }), - ...(selectedConcept && { concept: selectedConcept?.uuid }), - ...(conceptMappings.length && { conceptMappings }), - ...(updatedAnswers.length && { - answers: updatedAnswers.map((answer) => ({ - concept: answer.id, - label: answer.text, - })), - }), - ...(addObsComment && { - showComment: addObsComment, - }), - ...(addInlineDate && { - showDate: addInlineDate, - }), - ...(questionType === 'personAttribute' && { attributeType: selectedPersonAttributeType.uuid }), - ...(questionType === 'patientIdentifier' && { identifierType: selectedPatientIdetifierType.uuid }), - ...(questionType === 'obs' && - renderingType === 'number' && - selectedConcept?.allowDecimal === false && { disallowDecimals: true }), - ...(questionType === 'programState' && { - answers: selectedProgramState.map((answer) => ({ - value: answer.uuid, - label: answer.concept.display, - })), - programUuid: selectedProgram.uuid, - workflowUuid: programWorkflow.uuid, - }), - ...(renderingType === 'toggle' && { - toggleOptions: { - labelTrue: toggleLabelTrue, - labelFalse: toggleLabelFalse, - }, - }), - }, - validators: [], - }; - - schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.push(newQuestion); - - onSchemaChange({ ...schema }); - - setQuestionLabel(''); - setQuestionId(''); - setIsQuestionRequired(false); - setQuestionType(null); - setRenderingType(null); - setSelectedConcept(null); - setConceptMappings([]); - setAnswers([]); - setSelectedAnswers([]); - setAddObsComment(false); - setAddInlineDate(false); - - showSnackbar({ - title: t('success', 'Success!'), - kind: 'success', - isLowContrast: true, - subtitle: t('questionCreated', 'New question created'), - }); - } catch (error) { - if (error instanceof Error) { - showSnackbar({ - title: t('errorCreatingQuestion', 'Error creating question'), - kind: 'error', - subtitle: error?.message, - }); - } - } - }; - - const convertLabelToCamelCase = () => { - const camelCasedLabel = questionLabel - ?.toLowerCase() - ?.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => { - return index === 0 ? word.toLowerCase() : word.toUpperCase(); - }) - .replace(/\s+/g, ''); - setQuestionId(camelCasedLabel); - }; - - const handleProgramWorkflowChange = (selectedItem: ProgramWorkflow) => { - setProgramWorkflow(selectedItem); - void mutateProgramStates(); - }; - - const handleProgramChange = (selectedItem: Program) => { - setSelectedProgram(selectedItem); - setProgramWorkflows(selectedItem?.allWorkflows); - }; - - return ( - <> - -
event.preventDefault()}> - - - - {renderingType === 'markdown' ? : ( - } - placeholder={t('labelPlaceholder', 'e.g. Type of Anaesthesia')} - value={questionLabel} - onChange={(event: React.ChangeEvent) => setQuestionLabel(event.target.value)} - /> - )} - - - - {t( - 'questionId', - 'Question ID (prefer using camel-case for IDs). Each field should have a unique ID.', - )} - - {questionLabel ? ( - - ) : null} - - } - value={questionId} - onChange={(event: React.ChangeEvent) => { - setQuestionId(event.target.value); - }} - placeholder={t( - 'questionIdPlaceholder', - 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.', - )} - required - /> - - {renderingType !== 'markdown' && ( - <> - - setIsQuestionRequired(false)} - value="optional" - /> - setIsQuestionRequired(true)} - value="required" - /> - - - - )} - - - - {questionType === 'personAttribute' && ( -
- - {t('searchForBackingPersonAttributeType', 'Search for a backing person attribute type')} - - {personAttributeTypeLookupError ? ( - - ) : null} - item?.display} - onChange={handlePersonAttributeTypeChange} - placeholder={t('choosePersonAttributeType', 'Choose a person attribute type')} - selectedItem={selectedPersonAttributeType} - /> -
- )} - - {questionType === 'patientIdentifier' && ( -
- - {t('searchForBackingPatientIdentifierType', 'Search for a backing patient identifier type')} - - {patientIdentifierTypeLookupError ? ( - - ) : null} - item?.display} - onChange={handlePatientIdentifierTypeChange} - placeholder={t('choosePatientIdentifierType', 'Choose a patient identifier type')} - selectedItem={selectedPatientIdetifierType} - /> -
- )} - - {questionType === 'obs' ? ( - <> - {renderingType === 'number' ? ( - <> - parseFloat(max)} - invalidText={ - parseFloat(min) > parseFloat(max) - ? t('invalidMinMax', 'Min value cannot be greater than max') - : '' - } - onChange={(event: React.ChangeEvent) => setMin(event.target.value)} - required - /> - parseFloat(max)} - invalidText={ - parseFloat(min) > parseFloat(max) - ? t('invalidMinMax', 'Min value cannot be greater than max') - : '' - } - onChange={(event: React.ChangeEvent) => setMax(event.target.value)} - required - /> - - ) : renderingType === 'textarea' ? ( - ) => setRows(event.target.value)} - required - /> - ) : renderingType === 'toggle' ? ( -
- ) => - setToggleLabelTrue(event.target.value) - } - placeholder={t('on', 'On')} - required - /> - ) => - setToggleLabelFalse(event.target.value) - } - placeholder={t('off', 'Off')} - required - /> -
- ) : null} - - {renderingType !== 'ui-select-extended' && ( -
- - {t('searchForBackingConcept', 'Search for a backing concept')} - - {conceptLookupError ? ( - - ) : null} - { - setSelectedConcept(null); - setDatePickerType('both'); - setAnswers([]); - setConceptMappings([]); - }} - onChange={handleConceptChange} - placeholder={t('searchConcept', 'Search using a concept name or UUID')} - required - size="md" - value={(() => { - if (conceptToLookup) { - return conceptToLookup; - } - if (selectedConcept) { - return selectedConcept.display; - } - return ''; - })()} - /> - {(() => { - if (!conceptToLookup) return null; - if (isLoadingConcepts) - return ( - - ); - if (concepts?.length && !isLoadingConcepts) { - return ( -
    - {concepts?.map((concept, index) => ( -
  • handleConceptSelect(concept)} - > - {concept.display} -
  • - ))} -
- ); - } - return ( - - - - {t('noMatchingConcepts', 'No concepts were found that match')}{' '} - "{debouncedConceptToLookup}". - - - -
- { -

- {t('conceptSearchHelpText', "Can't find a concept?")} -

- } - - {t('searchInOCL', 'Search in OCL')} - - -
-
- ); - })()} -
- )} - - {selectedConcept?.allowDecimal === false && ( - - )} - - {conceptMappings?.length ? ( - - {t('mappings', 'Mappings')} - - - - - - - - - - {conceptMappings.map((mapping, index) => ( - - - - - - ))} - -
{t('relationship', 'Relationship')}{t('source', 'Source')}{t('code', 'Code')}
{mapping.relationship}{mapping.type}{mapping.value}
-
- ) : null} - - {answers?.length ? ( - item.text} - items={answers.map((answer) => ({ - id: answer.concept, - text: answer.label, - }))} - onChange={({ - selectedItems, - }: { - selectedItems: Array<{ - id: string; - text: string; - }>; - }) => setSelectedAnswers(selectedItems.sort())} - size="md" - titleText={t('selectAnswersToDisplay', 'Select answers to display')} - /> - ) : null} - {selectedAnswers.length ? ( -
- {selectedAnswers.map((answer) => ( - - {answer.text} - - ))} -
- ) : null} - {selectedConcept && selectedConcept.datatype?.name === 'Coded' ? ( -
- - {t('searchForAnswerConcept', 'Search for a concept to add as an answer')} - - {conceptAnsLookupError ? ( - - ) : null} - { - setSelectedAnsConcept(null); - }} - onChange={handleAnsConceptChange} - placeholder={t('searchConcept', 'Search using a concept name or UUID')} - required - size="md" - value={(() => { - if (conceptAnsToLookup) { - return conceptAnsToLookup; - } - if (selectedAnsConcept) { - return selectedAnsConcept.display; - } - return ''; - })()} - /> - {addedAnswers.length > 0 ? ( -
- {addedAnswers.map((answer) => ( - - {answer.text} - - - ))} -
- ) : null} - - {(() => { - if (!conceptAnsToLookup) return null; - if (isLoadingAnsConcepts) - return ( - - ); - if (ansConcepts?.length && !isLoadingAnsConcepts) { - return ( -
    - {ansConcepts?.map((concept, index) => ( -
  • handleConceptAnsSelect(concept)} - > - {concept.display} -
  • - ))} -
- ); - } - - return ( - - - - {t('noMatchingConcepts', 'No concepts were found that match')}{' '} - "{debouncedAnsConceptToLookup}". - - - -
- { -

- {t('conceptSearchHelpText', "Can't find a concept?")} -

- } - - {t('searchInOCL', 'Search in OCL')} - - -
-
- ); - })()} -
- ) : null} - - - - setAddObsComment(true)} - value="yes" - /> - setAddObsComment(false)} - value="no" - /> - - - - setAddInlineDate(true)} - value="yes" - /> - setAddInlineDate(false)} - value="no" - /> - - - - ) : null} - - {renderingType === 'date' || renderingType === 'datetime' ? ( - - {/** Filters out the date picker types based on the selected concept's data type. - If no concept is selected, all date picker types are shown. - */} - {selectedConcept && selectedConcept.datatype - ? datePickerTypeOptions[selectedConcept.datatype.name.toLowerCase()].map((type) => ( - setDatePickerType(type.value)} - checked={datePickerType === type.value} - value={type.value} - /> - )) - : Object.values(datePickerTypeOptions) - .flat() - .map((type) => ( - setDatePickerType(type.value)} - value={type.value} - /> - ))} - - ) : null} - - {questionType === 'programState' && ( - - {isLoadingPrograms && } - {programsLookupError ? ( - - ) : null} - {programs && ( - item?.name} - onChange={({ selectedItem }: { selectedItem: Program }) => { - handleProgramChange(selectedItem); - }} - placeholder={t('addProgram', 'Add program')} - selectedItem={selectedProgram} - titleText={t('program', 'Program')} - /> - )} - - {selectedProgram && ( - item?.concept?.display} - onChange={({ selectedItem }: { selectedItem: ProgramWorkflow }) => - handleProgramWorkflowChange(selectedItem) - } - placeholder={t('addProgramWorkflow', 'Add program workflow')} - selectedItem={programWorkflow} - titleText={t('programWorkflow', 'Program workflow')} - /> - )} - {programWorkflow && ( -
- {isLoadingProgramStates && } - {programStatesLookupError && ( - - )} - {programStates?.length > 0 && ( - (item ? item?.concept?.display : '')} - selectionFeedback="top-after-reopen" - onChange={(data: ProgramStateData) => setSelectedProgramState(data.selectedItems)} - selectedItems={selectedProgramState} - /> - )} - {selectedProgramState?.map((answer) => ( -
- - {answer?.concept?.display} - -
- ))} -
- )} -
- )} -
-
-
-
- - - - - - ); -}; - -function RequiredLabel({ isRequired, text, t }: RequiredLabelProps) { - return ( - <> - {text} - {isRequired && ( - - * - - )} - - ); -} - -export default AddQuestionModal; \ No newline at end of file diff --git a/src/components/interactive-builder/draggable-question.component.tsx b/src/components/interactive-builder/draggable/draggable-question.component.tsx similarity index 90% rename from src/components/interactive-builder/draggable-question.component.tsx rename to src/components/interactive-builder/draggable/draggable-question.component.tsx index 350b572b..7bf25f8b 100644 --- a/src/components/interactive-builder/draggable-question.component.tsx +++ b/src/components/interactive-builder/draggable/draggable-question.component.tsx @@ -6,8 +6,8 @@ import { useTranslation } from 'react-i18next'; import { CopyButton, IconButton } from '@carbon/react'; import { Draggable, Edit, TrashCan } from '@carbon/react/icons'; import { showModal } from '@openmrs/esm-framework'; -import type { Question, Schema } from '../../types'; -import MarkdownWrapper from './markdown-wrapper'; +import MarkdownWrapper from '../markdown-wrapper/markdown-wrapper'; +import type { Question, Schema } from '@types'; import styles from './draggable-question.scss'; interface DraggableQuestionProps { @@ -36,14 +36,14 @@ const DraggableQuestion: React.FC = ({ const draggableId = `question-${pageIndex}-${sectionIndex}-${questionIndex}`; const launchEditQuestionModal = useCallback(() => { - const dispose = showModal('edit-question-modal', { + const dispose = showModal('question-modal', { + formField: question, closeModal: () => dispose(), - questionToEdit: question, - pageIndex, - sectionIndex, - questionIndex, onSchemaChange, schema, + questionIndex, + pageIndex, + sectionIndex, }); }, [onSchemaChange, pageIndex, question, questionIndex, schema, sectionIndex]); @@ -91,7 +91,11 @@ const DraggableQuestion: React.FC = ({

- {question.questionOptions.rendering === 'markdown' ? : question.label} + {question.questionOptions.rendering === 'markdown' ? ( + + ) : ( + question.label + )}

diff --git a/src/components/interactive-builder/draggable-question.scss b/src/components/interactive-builder/draggable/draggable-question.scss similarity index 92% rename from src/components/interactive-builder/draggable-question.scss rename to src/components/interactive-builder/draggable/draggable-question.scss index 6e6227e2..254055d5 100644 --- a/src/components/interactive-builder/draggable-question.scss +++ b/src/components/interactive-builder/draggable/draggable-question.scss @@ -1,6 +1,6 @@ @use '@carbon/colors'; @use '@carbon/layout'; -@use "@carbon/type"; +@use '@carbon/type'; .buttonsContainer { display: flex; @@ -28,7 +28,7 @@ @extend .dragContainer; box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25); background-color: rgba(255, 255, 255, 0.552); - transform: scale(.75); + transform: scale(0.75); } .dragIcon { diff --git a/src/components/interactive-builder/droppable-container.component.tsx b/src/components/interactive-builder/droppable/droppable-container.component.tsx similarity index 100% rename from src/components/interactive-builder/droppable-container.component.tsx rename to src/components/interactive-builder/droppable/droppable-container.component.tsx diff --git a/src/components/interactive-builder/droppable-container.scss b/src/components/interactive-builder/droppable/droppable-container.scss similarity index 100% rename from src/components/interactive-builder/droppable-container.scss rename to src/components/interactive-builder/droppable/droppable-container.scss diff --git a/src/components/interactive-builder/edit-question.modal.tsx b/src/components/interactive-builder/edit-question.modal.tsx deleted file mode 100644 index 1fe3b492..00000000 --- a/src/components/interactive-builder/edit-question.modal.tsx +++ /dev/null @@ -1,1103 +0,0 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import debounce from 'lodash-es/debounce'; -import flattenDeep from 'lodash-es/flattenDeep'; -import { - Button, - ComboBox, - Form, - FormLabel, - InlineLoading, - InlineNotification, - Layer, - ModalBody, - ModalFooter, - ModalHeader, - MultiSelect, - RadioButton, - RadioButtonGroup, - Search, - Select, - SelectItem, - SelectSkeleton, - Stack, - Tag, - TextInput, - Tile, -} from '@carbon/react'; -import { ArrowUpRight } from '@carbon/react/icons'; -import { showSnackbar, useConfig } from '@openmrs/esm-framework'; -import type { ProgramState, RenderType } from '@openmrs/esm-form-engine-lib'; - -import type { ConfigObject } from '../../config-schema'; -import type { - DatePickerType, - Concept, - ConceptMapping, - PatientIdentifierType, - PersonAttributeType, - Program, - ProgramWorkflow, - Question, - QuestionType, - Schema, - DatePickerTypeOption, -} from '../../types'; -import { useConceptLookup } from '../../hooks/useConceptLookup'; -import { useConceptInfo } from '../../hooks/useConceptInfo'; -import { useConceptName } from '../../hooks/useConceptName'; -import { usePatientIdentifierLookup } from '../../hooks/usePatientIdentifierLookup'; -import { usePatientIdentifierName } from '../../hooks/usePatientIdentifierName'; -import { usePatientIdentifierTypes } from '../../hooks/usePatientIdentifierTypes'; -import { usePersonAttributeLookup } from '../../hooks/usePersonAttributeLookup'; -import { usePersonAttributeName } from '../../hooks/usePersonAttributeName'; -import { usePersonAttributeTypes } from '../../hooks/usePersonAttributeTypes'; -import { usePrograms, useProgramWorkStates } from '../../hooks/useProgramStates'; -import { getDatePickerType } from './add-question.modal'; -import MarkdownQuestion from './markdown-question.component'; -import styles from './question-modal.scss'; - -interface EditQuestionModalProps { - closeModal: () => void; - onSchemaChange: (schema: Schema) => void; - pageIndex: number; - questionIndex: number; - questionToEdit: Question; - schema: Schema; - sectionIndex: number; -} - -interface Item { - id: string; - text: string; -} - -interface ProgramStateData { - selectedItems: Array; -} - -const EditQuestionModal: React.FC = ({ - closeModal, - onSchemaChange, - pageIndex, - questionIndex, - questionToEdit, - schema, - sectionIndex, -}) => { - const { t } = useTranslation(); - const { fieldTypes, questionTypes } = useConfig(); - const [answersFromConcept, setAnswersFromConcept] = useState< - Array<{ - concept: string; - label: string; - }> - >([]); - const [conceptMappings, setConceptMappings] = useState | undefined>( - questionToEdit.questionOptions.conceptMappings, - ); - const [conceptToLookup, setConceptToLookup] = useState(''); - const [conceptAnsToLookup, setConceptAnsToLookup] = useState(''); - const [personAttributeTypeToLookup, setPersonAttributeTypeToLookup] = useState(''); - const [patientIdentifierTypeToLookup, setPatientIdentifierTypeToLookup] = useState(''); - const [fieldType, setFieldType] = useState(questionToEdit.questionOptions.rendering); - const [isQuestionRequired, setIsQuestionRequired] = useState(false); - const [max, setMax] = useState(questionToEdit.questionOptions.max ?? ''); - const [min, setMin] = useState(questionToEdit.questionOptions.min ?? ''); - const [questionId, setQuestionId] = useState(''); - const [questionLabel, setQuestionLabel] = useState(''); - const [questionValue, setQuestionValue] = useState(questionToEdit.value); - const [questionType, setQuestionType] = useState(null); - const [datePickerType, setDatePickerType] = useState( - questionToEdit.datePickerFormat ?? 'both', - ); - const [rows, setRows] = useState(''); - const [selectedAnsConcept, setSelectedAnsConcept] = useState(null); - const [selectedAnswers, setSelectedAnswers] = useState< - Array<{ - id: string; - text: string; - }> - >([]); - const [addedAnswers, setAddedAnswers] = useState< - Array<{ - id: string; - text: string; - }> - >([]); - const { - concepts: ansConcepts, - conceptLookupError: conceptAnsLookupError, - isLoadingConcepts: isLoadingAnsConcepts, - } = useConceptLookup(conceptAnsToLookup); - const [selectedConcept, setSelectedConcept] = useState(null); - const { concept: editConceptInfo } = useConceptInfo(questionToEdit.questionOptions.concept); - const { concepts, isLoadingConcepts } = useConceptLookup(conceptToLookup); - const { conceptName, conceptNameLookupError, isLoadingConceptName } = useConceptName( - questionToEdit.questionOptions.concept, - ); - const { patientIdentifierTypes } = usePatientIdentifierTypes(); - const { personAttributeTypes } = usePersonAttributeTypes(); - const { patientIdentifierType } = usePatientIdentifierLookup(patientIdentifierTypeToLookup); - const { personAttributeType } = usePersonAttributeLookup(personAttributeTypeToLookup); - const [selectedPatientIdentifierType, setSelectedPatientIdentifierType] = useState(patientIdentifierType); - const [selectedPersonAttributeType, setSelectedPersonAttributeType] = useState(personAttributeType); - const { patientIdentifierNameLookupError, isLoadingPatientidentifierName } = usePatientIdentifierName( - questionToEdit.questionOptions.identifierType, - ); - const { personAttributeNameLookupError, isLoadingPersonAttributeName } = usePersonAttributeName( - questionToEdit.questionOptions.attributeType, - ); - const [addObsComment, setAddObsComment] = useState(false); - const [selectedProgramState, setSelectedProgramState] = useState>([]); - const [selectedProgram, setSelectedProgram] = useState(null); - const [programWorkflow, setProgramWorkflow] = useState(null); - const { programs, programsLookupError, isLoadingPrograms } = usePrograms(); - const { programStates, programStatesLookupError, isLoadingProgramStates, mutateProgramStates } = useProgramWorkStates( - programWorkflow?.uuid, - ); - const [programWorkflows, setProgramWorkflows] = useState>([]); - - const hasConceptChanged = - selectedConcept && - questionToEdit?.questionOptions.concept && - questionToEdit?.questionOptions?.concept !== selectedConcept?.uuid; - const [addInlineDate, setAddInlineDate] = useState(false); - const [toggleLabelTrue, setToggleLabelTrue] = useState(questionToEdit?.questionOptions?.toggleOptions?.labelTrue); - const [toggleLabelFalse, setToggleLabelFalse] = useState(questionToEdit?.questionOptions?.toggleOptions?.labelFalse); - - // Maps the data type of a concept to a date picker type. - const datePickerTypeOptions: Record> = { - datetime: [{ value: 'both', label: t('calendarAndTimer', 'Calendar and timer'), defaultChecked: true }], - date: [{ value: 'calendar', label: t('calendarOnly', 'Calendar only'), defaultChecked: false }], - time: [{ value: 'timer', label: t('timerOnly', 'Timer only'), defaultChecked: false }], - }; - - const debouncedSearch = useMemo(() => { - return debounce((searchTerm: string) => setConceptToLookup(searchTerm), 500) as (searchTerm: string) => void; - }, []); - - const handleConceptChange = (searchTerm: string) => { - if (searchTerm) { - debouncedSearch(searchTerm); - } - }; - - const handleAnsConceptChange = (event: React.ChangeEvent) => - setConceptAnsToLookup(event.target.value); - - const handleIdentifierTypeSelect = (identifierType: PatientIdentifierType) => { - setPatientIdentifierTypeToLookup(''); - setSelectedPatientIdentifierType(identifierType); - }; - - const handleAttributeTypeSelect = (attributeType: PersonAttributeType) => { - setPersonAttributeTypeToLookup(''); - setSelectedPersonAttributeType(attributeType); - }; - - const handleConceptSelect = (concept: Concept) => { - const datePickerType = getDatePickerType(concept); - if (datePickerType) { - setDatePickerType(datePickerType); - } - setConceptToLookup(''); - setSelectedAnswers([]); - setAddedAnswers([]); - setSelectedConcept(concept); - setConceptMappings( - concept?.mappings?.map((conceptMapping) => { - const data = conceptMapping.display.split(': '); - return { - relationship: conceptMapping.conceptMapType.display, - type: data[0], - value: data[1], - }; - }), - ); - setAnswersFromConcept( - concept?.answers?.map((answer) => ({ - concept: answer?.uuid, - label: answer?.display, - })) ?? [], - ); - }; - - const handleDeleteAnswer = (id) => { - const updatedAnswers = addedAnswers.filter((answer) => answer.id !== id); - setAddedAnswers(updatedAnswers); - }; - - const handleSaveMoreAnswers = () => { - const newAnswers = addedAnswers.filter( - (newAnswer) => !selectedAnswers.some((prevAnswer) => prevAnswer.id === newAnswer.id), - ); - - const updatedAnswers = [...selectedAnswers, ...newAnswers]; - setSelectedAnswers(updatedAnswers); - setAddedAnswers([]); - return updatedAnswers; - }; - - const handleConceptAnsSelect = (concept: Concept) => { - setConceptAnsToLookup(''); - setSelectedAnsConcept(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]); - } - }; - - const questionIdExists = (idToTest: string) => { - if (questionToEdit?.id === idToTest) { - return false; - } - - const nestedIds = schema?.pages?.map((page) => { - return page?.sections?.map((section) => { - return section?.questions?.map((question) => { - return question.id; - }); - }); - }); - - const questionIds: Array = flattenDeep(nestedIds); - - return questionIds.includes(idToTest); - }; - - const handleUpdateQuestion = () => { - const updatedAnswers = handleSaveMoreAnswers(); - updateQuestion(questionIndex, updatedAnswers); - - closeModal(); - }; - - const handleProgramWorkflowChange = (selectedItem: ProgramWorkflow) => { - setProgramWorkflow(selectedItem); - void mutateProgramStates(); - }; - - const handleProgramChange = (selectedItem: Program) => { - setSelectedProgram(selectedItem); - setProgramWorkflows(selectedItem?.allWorkflows); - }; - - const updateQuestion = (questionIndex: number, updatedAnswers) => { - let mappedAnswers = []; - - // update changed concept based on details - if (!hasConceptChanged && updatedAnswers?.length) { - mappedAnswers = updatedAnswers.map((answer) => ({ - concept: answer.id, - label: answer.text, - })); - } else if (hasConceptChanged && answersFromConcept.length === 0) { - mappedAnswers = []; - } else if (hasConceptChanged && answersFromConcept?.length > 0 && updatedAnswers?.length) { - mappedAnswers = updatedAnswers?.length - ? updatedAnswers.map((answer) => ({ - concept: answer.id, - label: answer.text, - })) - : questionToEdit.questionOptions.answers; - } else { - if (questionToEdit.type === 'programState') { - mappedAnswers = selectedProgramState.map((answer) => ({ - value: answer.uuid, - label: answer.concept.display, - })); - } else { - mappedAnswers = questionToEdit.questionOptions.answers; - } - } - - try { - const data = { - ...(questionLabel && { label: questionLabel }), - ...(questionValue && { value: questionValue }), - type: questionType ? questionType : questionToEdit.type, - required: isQuestionRequired ? isQuestionRequired : /true/.test(questionToEdit?.required?.toString()), - id: questionId ? questionId : questionToEdit.id, - ...(((fieldType && (fieldType === 'date' || fieldType === 'datetime')) || - questionToEdit.questionOptions.rendering === 'date' || - questionToEdit.questionOptions.rendering === 'datetime') && { - datePickerFormat: datePickerType, - }), - questionOptions: { - rendering: fieldType ? fieldType : questionToEdit.questionOptions.rendering, - ...(min && { min }), - ...(max && { max }), - ...((selectedConcept || questionToEdit.questionOptions.concept) && { - concept: selectedConcept ? selectedConcept.uuid : questionToEdit.questionOptions.concept, - conceptMappings: conceptMappings?.length ? conceptMappings : questionToEdit.questionOptions.conceptMappings, - }), - answers: mappedAnswers, - ...(questionType === 'patientIdentifier' && { - identifierType: selectedPatientIdentifierType - ? selectedPatientIdentifierType['uuid'] - : questionToEdit.questionOptions.identifierType, - }), - ...(addObsComment && { - showComment: addObsComment - ? addObsComment - : /true/.test(questionToEdit.questionOptions.showComment.toString()), - }), - ...(addInlineDate && { - showDate: addInlineDate ? addInlineDate : /true/.test(questionToEdit.questionOptions.showDate.toString()), - }), - attributeType: selectedPersonAttributeType - ? selectedPersonAttributeType['uuid'] - : questionToEdit.questionOptions.attributeType, - ...(selectedProgram && { programUuid: selectedProgram.uuid }), - ...(programWorkflow && { workflowUuid: programWorkflow.uuid }), - ...(fieldType === 'toggle' && { - toggleOptions: { - labelTrue: toggleLabelTrue, - labelFalse: toggleLabelFalse, - }, - }), - }, - }; - - schema.pages[pageIndex].sections[sectionIndex].questions[questionIndex] = data; - - onSchemaChange({ ...schema }); - setQuestionLabel(''); - setQuestionId(''); - setIsQuestionRequired(false); - setQuestionType(null); - setFieldType(null); - setSelectedConcept(null); - setConceptMappings([]); - setSelectedAnswers([]); - setAddObsComment(false); - setAddInlineDate(false); - - showSnackbar({ - title: t('questionEdited', 'Question edited'), - kind: 'success', - isLowContrast: true, - subtitle: t('questionEditedMessage', 'The question labelled "{{- questionLabel}}" has been edited.', { - questionLabel: questionToEdit.label, - }), - }); - } catch (error) { - if (error instanceof Error) { - showSnackbar({ - title: t('errorUpdatingQuestion', 'Error updating question'), - kind: 'error', - subtitle: error?.message, - }); - } - } - - closeModal(); - }; - - useEffect(() => { - const previousPrograms = programs.find((program) => program.uuid === questionToEdit.questionOptions.programUuid); - setSelectedProgram(previousPrograms); - }, [programs, questionToEdit.questionOptions.programUuid]); - - useEffect(() => { - const previousWorkflow = selectedProgram?.allWorkflows.find( - (workflow) => workflow.uuid === questionToEdit.questionOptions.workflowUuid, - ); - setProgramWorkflow(previousWorkflow); - setProgramWorkflows(selectedProgram?.allWorkflows); - }, [questionToEdit.questionOptions.workflowUuid, selectedProgram]); - - useEffect(() => { - const previousStates = programWorkflow?.states.filter((state) => - questionToEdit.questionOptions.answers.some((answer) => answer.value === state.uuid), - ); - setSelectedProgramState(previousStates); - }, [programWorkflow, questionToEdit.questionOptions.answers]); - - useEffect(() => { - if (questionToEdit?.questionOptions?.answers) { - const initialAnswers = questionToEdit.questionOptions.answers.map((answer) => ({ - id: answer.concept, - text: answer.label, - })); - setSelectedAnswers(initialAnswers); - } - }, [questionToEdit]); - - const answerItemsForObs = useMemo(() => { - const answersArray: Array = []; - editConceptInfo?.answers?.map((answer) => { - answersArray.push({ - id: answer.uuid, - text: answer.display, - }); - }); - questionToEdit?.questionOptions?.answers?.map((answer) => { - if (!answersArray.some((existingAnswer) => existingAnswer.id === answer.concept)) { - answersArray.push({ - id: answer.concept, - text: answer.label ?? '', - }); - } - }); - return answersArray; - }, [questionToEdit, editConceptInfo]); - - return ( - <> - -
event.preventDefault()}> - - - {questionToEdit.questionOptions.rendering === 'markdown' || fieldType === 'markdown' ? ( - - ) : ( - ) => setQuestionLabel(event.target.value)} - /> - )} - ) => setQuestionId(event.target.value)} - placeholder={t( - 'questionIdPlaceholder', - 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.', - )} - required - /> - {(questionToEdit.questionOptions.rendering !== 'markdown' || fieldType !== 'markdown') && ( - <> - - setIsQuestionRequired(false)} - value="optional" - /> - setIsQuestionRequired(true)} - value="required" - /> - - - - )} - - {fieldType === 'number' ? ( - <> - parseFloat(max)} - invalidText={ - parseFloat(min) > parseFloat(max) ? t('invalidMinMax', 'Min value cannot be greater than max') : '' - } - onChange={(event: React.ChangeEvent) => setMin(event.target.value)} - required - /> - parseFloat(max)} - invalidText={ - parseFloat(min) > parseFloat(max) ? t('invalidMinMax', 'Min value cannot be greater than max') : '' - } - onChange={(event: React.ChangeEvent) => setMax(event.target.value)} - required - /> - - ) : fieldType === 'textarea' ? ( - ) => setRows(event.target.value)} - required - /> - ) : fieldType === 'toggle' ? ( -
- ) => setToggleLabelTrue(event.target.value)} - placeholder={t('on', 'On')} - required - /> - ) => setToggleLabelFalse(event.target.value)} - placeholder={t('off', 'Off')} - required - /> -
- ) : null} - - {questionToEdit.type === 'patientIdentifier' && ( -
- - {t('searchForBackingPatientIdentifierType', 'Search for a backing patient identifier type')} - - {patientIdentifierNameLookupError ? ( - - ) : null} - {isLoadingPatientidentifierName ? ( - - ) : ( - item?.display} - onChange={({ selectedItem }: { selectedItem: PatientIdentifierType }) => { - handleIdentifierTypeSelect(selectedItem); - }} - placeholder={t('choosePatientIdentifierType', 'Choose a patient identifier type')} - initialSelectedItem={patientIdentifierTypes.find( - (patientIdentifierType) => - patientIdentifierType?.uuid === questionToEdit.questionOptions?.identifierType, - )} - /> - )} -
- )} - - {questionToEdit.type === 'personAttribute' && ( -
- - {t('searchForBackingPersonAttributeType', 'Search for a backing person attribute type')} - - {personAttributeNameLookupError ? ( - - ) : null} - {isLoadingPersonAttributeName ? ( - - ) : ( - item?.display} - onChange={({ selectedItem }: { selectedItem: PersonAttributeType }) => { - handleAttributeTypeSelect(selectedItem); - }} - placeholder={t('choosePersonAttributeType', 'Choose a person attribute type')} - initialSelectedItem={personAttributeTypes.find( - (personAttributeType) => - personAttributeType?.uuid === questionToEdit.questionOptions?.attributeType, - )} - /> - )} -
- )} - - {questionToEdit.type === 'programState' && ( - - {isLoadingPrograms && } - {programsLookupError ? ( - - ) : null} - {programs && ( - item?.name} - onChange={({ selectedItem }: { selectedItem: Program }) => { - handleProgramChange(selectedItem); - }} - placeholder={t('addProgram', 'Add program')} - selectedItem={selectedProgram} - titleText={t('program', 'Program')} - /> - )} - - {selectedProgram && ( - item?.concept?.display ?? ''} - onChange={({ selectedItem }: { selectedItem: ProgramWorkflow }) => - handleProgramWorkflowChange(selectedItem) - } - placeholder={t('addProgramWorkflow', 'Add program workflow')} - selectedItem={programWorkflow} - titleText={t('programWorkflow', 'Program workflow')} - /> - )} - {programWorkflow && ( - <> - {isLoadingProgramStates && } - {programStatesLookupError && ( - - )} - {programStates?.length > 0 && ( - (item ? item?.concept?.display : '')} - selectionFeedback="top-after-reopen" - onChange={(data: ProgramStateData) => setSelectedProgramState(data.selectedItems)} - selectedItems={selectedProgramState} - /> - )} -
- {selectedProgramState?.map((answer) => ( - - {answer?.concept?.display} - - ))} -
- - )} -
- )} - - {fieldType !== 'ui-select-extended' && - questionToEdit.type !== 'encounterDatetime' && - (questionType === 'obs' || (!questionType && questionToEdit.type === 'obs')) && ( - <> -
- - {t('searchForBackingConcept', 'Search for a backing concept')} - - {conceptNameLookupError ? ( - - ) : null} - {isLoadingConceptName ? ( - - ) : ( - <> - { - setSelectedConcept(null); - setDatePickerType('both'); - }} - onChange={(e: React.ChangeEvent) => - handleConceptChange(e.target.value?.trim()) - } - placeholder={t('searchConcept', 'Search using a concept name or UUID')} - required - size="md" - value={selectedConcept?.display} - /> - {(() => { - if (!conceptToLookup) return null; - if (isLoadingConcepts) - return ( - - ); - if (concepts?.length && !isLoadingConcepts) { - return ( -
    - {concepts?.map((concept, index) => ( -
  • handleConceptSelect(concept)} - > - {concept.display} -
  • - ))} -
- ); - } - return ( - - - - {t('noMatchingConcepts', 'No concepts were found that match')}{' '} - "{conceptToLookup}". - - - -
- { -

- {t('conceptSearchHelpText', "Can't find a concept?")} -

- } - - {t('searchInOCL', 'Search in OCL')} - - -
-
- ); - })()} - - )} -
- - - {!hasConceptChanged && - questionToEdit?.questionOptions.answers?.length && - questionToEdit.type !== 'programState' ? ( - item.text} - initialSelectedItems={questionToEdit?.questionOptions?.answers?.map((answer) => ({ - id: answer.concept, - text: answer.label, - }))} - items={answerItemsForObs} - onChange={({ - selectedItems, - }: { - selectedItems: Array<{ - id: string; - text: string; - }>; - }) => { - setSelectedAnswers(selectedItems); - }} - size="md" - titleText={t('selectAnswersToDisplay', 'Select answers to display')} - /> - ) : null} - {!hasConceptChanged && selectedAnswers.length && questionToEdit.type !== 'programState' ? ( -
- {selectedAnswers?.map((answer) => ( - - {answer?.text} - - ))} -
- ) : null} - {hasConceptChanged && answersFromConcept.length ? ( - item.text} - items={answersFromConcept.map((answer) => ({ - id: answer.concept, - text: answer.label, - }))} - onChange={({ - selectedItems, - }: { - selectedItems: Array<{ - id: string; - text: string; - }>; - }) => setSelectedAnswers(selectedItems.sort())} - size="md" - titleText={t('selectAnswersToDisplay', 'Select answers to display')} - /> - ) : null} - - {hasConceptChanged && selectedAnswers.length && questionToEdit.type !== 'programState' ? ( -
- {selectedAnswers?.map((answer) => ( - - {answer?.text} - - ))} -
- ) : null} - - {(selectedConcept && selectedConcept.datatype?.display == 'Coded') || - (editConceptInfo && editConceptInfo.datatype?.display == 'Coded') ? ( -
- - {t('searchForAnswerConcept', 'Search for a concept to add as an answer')} - - {conceptAnsLookupError ? ( - - ) : null} - { - setSelectedAnsConcept(null); - }} - onChange={handleAnsConceptChange} - placeholder={t('searchConcept', 'Search using a concept name or UUID')} - required - size="md" - value={(() => { - if (conceptAnsToLookup) { - return conceptAnsToLookup; - } - if (selectedAnsConcept) { - return selectedAnsConcept.display; - } - return ''; - })()} - /> - {addedAnswers.length > 0 ? ( -
- {addedAnswers.map((answer) => ( - - {answer.text} - - - ))} -
- ) : null} - - {(() => { - if (!conceptAnsToLookup) return null; - if (isLoadingAnsConcepts) - return ( - - ); - if (ansConcepts?.length && !isLoadingAnsConcepts) { - return ( -
    - {ansConcepts?.map((concept, index) => ( -
  • handleConceptAnsSelect(concept)} - > - {concept.display} -
  • - ))} -
- ); - } - - return ( - - - - {t('noMatchingConcepts', 'No concepts were found that match')}{' '} - '{conceptAnsToLookup}'. - - - -
- { -

- {t('conceptSearchHelpText', "Can't find a concept?")} -

- } - - {t('searchInOCL', 'Search in OCL')} - - -
-
- ); - })()} -
- ) : null} - - - setAddObsComment(true)} - value="yes" - /> - setAddObsComment(false)} - value="no" - /> - - - - setAddInlineDate(true)} - value="yes" - /> - setAddInlineDate(false)} - value="no" - /> - -
- - )} - - {fieldType === 'date' || fieldType === 'datetime' ? ( - - {/** Filters out the date picker types based on the selected concept's data type. - If no concept is selected, all date picker types are shown. - */} - {selectedConcept && selectedConcept.datatype - ? datePickerTypeOptions[selectedConcept.datatype.name.toLowerCase()].map((type) => ( - setDatePickerType(type.value)} - checked={datePickerType === type.value} - value={type.value} - /> - )) - : Object.values(datePickerTypeOptions) - .flat() - .map((type) => ( - setDatePickerType(type.value)} - value={type.value} - /> - ))} - - ) : null} -
-
- - - - -
- - ); -}; - -export default EditQuestionModal; diff --git a/src/components/interactive-builder/editable-value.component.tsx b/src/components/interactive-builder/editable/editable-value.component.tsx similarity index 94% rename from src/components/interactive-builder/editable-value.component.tsx rename to src/components/interactive-builder/editable/editable-value.component.tsx index e44036ce..ba63d46f 100644 --- a/src/components/interactive-builder/editable-value.component.tsx +++ b/src/components/interactive-builder/editable/editable-value.component.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { IconButton } from '@carbon/react'; import { Edit } from '@carbon/react/icons'; -import ValueEditor from './value-editor.component'; +import ValueEditor from '../value-editor/value-editor.component'; import styles from './editable-value.scss'; interface EditableValueProps { diff --git a/src/components/interactive-builder/editable-value.scss b/src/components/interactive-builder/editable/editable-value.scss similarity index 89% rename from src/components/interactive-builder/editable-value.scss rename to src/components/interactive-builder/editable/editable-value.scss index 02eeac87..a3e11465 100644 --- a/src/components/interactive-builder/editable-value.scss +++ b/src/components/interactive-builder/editable/editable-value.scss @@ -1,4 +1,4 @@ -@use "@carbon/type"; +@use '@carbon/type'; .schemaLabel { @include type.type-style('heading-03'); @@ -9,7 +9,7 @@ margin: 0.5rem 0rem; &:after { - content: ""; + content: ''; display: block; width: 2rem; padding-top: 0.188rem; @@ -19,4 +19,4 @@ .sectionLabel { @include type.type-style('heading-02'); -} \ No newline at end of file +} diff --git a/src/components/interactive-builder/interactive-builder.component.tsx b/src/components/interactive-builder/interactive-builder.component.tsx index 68bd2e20..441f672f 100644 --- a/src/components/interactive-builder/interactive-builder.component.tsx +++ b/src/components/interactive-builder/interactive-builder.component.tsx @@ -1,16 +1,16 @@ import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import type { DragEndEvent } from '@dnd-kit/core'; import { DndContext, KeyboardSensor, MouseSensor, closestCorners, useSensor, useSensors } from '@dnd-kit/core'; import { Accordion, AccordionItem, Button, IconButton, InlineLoading } from '@carbon/react'; import { Add, TrashCan } from '@carbon/react/icons'; import { useParams } from 'react-router-dom'; import { showModal, showSnackbar } from '@openmrs/esm-framework'; +import DraggableQuestion from './draggable/draggable-question.component'; +import Droppable from './droppable/droppable-container.component'; +import EditableValue from './editable/editable-value.component'; +import type { DragEndEvent } from '@dnd-kit/core'; import type { FormSchema } from '@openmrs/esm-form-engine-lib'; -import type { Schema, Question } from '../../types'; -import DraggableQuestion from './draggable-question.component'; -import Droppable from './droppable-container.component'; -import EditableValue from './editable-value.component'; +import type { Schema, Question } from '@types'; import styles from './interactive-builder.scss'; interface ValidationError { @@ -117,7 +117,7 @@ const InteractiveBuilder: React.FC = ({ const launchAddQuestionModal = useCallback( (pageIndex: number, sectionIndex: number) => { - const dispose = showModal('add-question-modal', { + const dispose = showModal('question-modal', { closeModal: () => dispose(), onSchemaChange, schema, @@ -437,38 +437,38 @@ const InteractiveBuilder: React.FC = ({ {section.questions?.length ? ( section.questions.map((question, questionIndex) => { return ( - - - {getValidationError(question) && ( -
- {getValidationError(question)} -
- )} - {getAnswerErrors(question.questionOptions.answers)?.length ? ( -
-
Answer Errors
- {getAnswerErrors(question.questionOptions.answers)?.map((error, index) => ( -
{`${error.field.label}: ${error.errorMessage}`}
- ))} -
- ) : null} -
+ + + {getValidationError(question) && ( +
+ {getValidationError(question)} +
+ )} + {getAnswerErrors(question.questionOptions.answers)?.length ? ( +
+
Answer Errors
+ {getAnswerErrors(question.questionOptions.answers)?.map((error, index) => ( +
{`${error.field.label}: ${error.errorMessage}`}
+ ))} +
+ ) : null} +
); }) ) : ( diff --git a/src/components/interactive-builder/markdown-question.component.tsx b/src/components/interactive-builder/markdown-question.component.tsx deleted file mode 100644 index d9973b67..00000000 --- a/src/components/interactive-builder/markdown-question.component.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import ReactMde from 'react-mde'; -import ReactMarkdown from 'react-markdown'; -import styles from './markdown-question.scss'; - -interface MarkdownQuestionProps { - placeholder?: string; - onValueChange: (value: string) => void; -} - -const MarkdownQuestion: React.FC = ({ placeholder, onValueChange }) => { - const [value, setValue] = React.useState(placeholder || ""); - const [selectedTab, setSelectedTab] = React.useState<"write" | "preview">("write"); - - const handleEditorChange = (newValue: string) => { - setValue(newValue); - onValueChange(newValue); - }; - - const handleTabChange = () => { - setSelectedTab((prevTab) => (prevTab === "write" ? "preview" : "write")); - }; - - return ( -
- - Promise.resolve( - - ) - } - childProps={{ - writeButton: { - tabIndex: -1 - } - }} - loadingPreview='loading preview...' - /> -
- ); -} - -export default MarkdownQuestion; \ No newline at end of file diff --git a/src/components/interactive-builder/markdown-wrapper.tsx b/src/components/interactive-builder/markdown-wrapper/markdown-wrapper.tsx similarity index 100% rename from src/components/interactive-builder/markdown-wrapper.tsx rename to src/components/interactive-builder/markdown-wrapper/markdown-wrapper.tsx diff --git a/src/components/dashboard/delete-form.modal.tsx b/src/components/interactive-builder/modals/delete-form/delete-form.modal.tsx similarity index 100% rename from src/components/dashboard/delete-form.modal.tsx rename to src/components/interactive-builder/modals/delete-form/delete-form.modal.tsx diff --git a/src/components/dashboard/delete-form.scss b/src/components/interactive-builder/modals/delete-form/delete-form.scss similarity index 100% rename from src/components/dashboard/delete-form.scss rename to src/components/interactive-builder/modals/delete-form/delete-form.scss diff --git a/src/components/interactive-builder/delete-page.modal.tsx b/src/components/interactive-builder/modals/delete-page/delete-page.modal.tsx similarity index 97% rename from src/components/interactive-builder/delete-page.modal.tsx rename to src/components/interactive-builder/modals/delete-page/delete-page.modal.tsx index 8871404f..cee0eed6 100644 --- a/src/components/interactive-builder/delete-page.modal.tsx +++ b/src/components/interactive-builder/modals/delete-page/delete-page.modal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Button, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from '../modals.scss'; interface DeletePageModalProps { diff --git a/src/components/interactive-builder/delete-question.modal.tsx b/src/components/interactive-builder/modals/delete-question/delete-question.modal.tsx similarity index 98% rename from src/components/interactive-builder/delete-question.modal.tsx rename to src/components/interactive-builder/modals/delete-question/delete-question.modal.tsx index 9e0e97b5..741a4e7d 100644 --- a/src/components/interactive-builder/delete-question.modal.tsx +++ b/src/components/interactive-builder/modals/delete-question/delete-question.modal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Button, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Question, Schema } from '../../types'; +import type { Question, Schema } from '@types'; import styles from '../modals.scss'; interface DeleteQuestionModal { diff --git a/src/components/interactive-builder/delete-section.modal.tsx b/src/components/interactive-builder/modals/delete-section/delete-section.modal.tsx similarity index 98% rename from src/components/interactive-builder/delete-section.modal.tsx rename to src/components/interactive-builder/modals/delete-section/delete-section.modal.tsx index 56fdae0d..7ddacda7 100644 --- a/src/components/interactive-builder/delete-section.modal.tsx +++ b/src/components/interactive-builder/modals/delete-section/delete-section.modal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; import { Button, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from '../modals.scss'; interface DeleteSectionModal { diff --git a/src/components/modals.scss b/src/components/interactive-builder/modals/modals.scss similarity index 100% rename from src/components/modals.scss rename to src/components/interactive-builder/modals/modals.scss diff --git a/src/components/interactive-builder/new-form.modal.tsx b/src/components/interactive-builder/modals/new-form/new-form.modal.tsx similarity index 98% rename from src/components/interactive-builder/new-form.modal.tsx rename to src/components/interactive-builder/modals/new-form/new-form.modal.tsx index dce730a2..e6a3c4e2 100644 --- a/src/components/interactive-builder/new-form.modal.tsx +++ b/src/components/interactive-builder/modals/new-form/new-form.modal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, Stack, TextInput } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from '../modals.scss'; interface NewFormModalProps { diff --git a/src/components/interactive-builder/page.modal.tsx b/src/components/interactive-builder/modals/new-page/page.modal.tsx similarity index 98% rename from src/components/interactive-builder/page.modal.tsx rename to src/components/interactive-builder/modals/new-page/page.modal.tsx index 2027a848..6353786e 100644 --- a/src/components/interactive-builder/page.modal.tsx +++ b/src/components/interactive-builder/modals/new-page/page.modal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from '../modals.scss'; interface PageModalProps { diff --git a/src/components/interactive-builder/section.modal.tsx b/src/components/interactive-builder/modals/new-section/section.modal.tsx similarity index 98% rename from src/components/interactive-builder/section.modal.tsx rename to src/components/interactive-builder/modals/new-section/section.modal.tsx index 18e4ae04..1fcd95e3 100644 --- a/src/components/interactive-builder/section.modal.tsx +++ b/src/components/interactive-builder/modals/new-section/section.modal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react'; import { showSnackbar } from '@openmrs/esm-framework'; -import type { Schema } from '../../types'; +import type { Schema } from '@types'; import styles from '../modals.scss'; interface SectionModalProps { diff --git a/src/components/interactive-builder/modals/question/form-field-context.tsx b/src/components/interactive-builder/modals/question/form-field-context.tsx new file mode 100644 index 00000000..7ac8109a --- /dev/null +++ b/src/components/interactive-builder/modals/question/form-field-context.tsx @@ -0,0 +1,54 @@ +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); + +export const FormFieldProvider: React.FC<{ + children: ReactNode; + initialFormField: FormField; + isObsGrouped?: boolean; + selectedConcept?: Concept; +}> = ({ children, initialFormField, isObsGrouped = false, selectedConcept = null }) => { + const [formField, setFormField] = useState(initialFormField); + const [concept, setConcept] = useState(selectedConcept); + + const updateObsGroupedQuestion = useCallback( + (updatedObsGroupFormField: FormField) => { + setFormField((prevFormField) => { + const formFieldCopy = { ...prevFormField }; + if (formFieldCopy.questions.length === 1 && formFieldCopy.questions[0].id === '') { + formFieldCopy.questions[0] = updatedObsGroupFormField; + } else { + formFieldCopy.questions.pop(); + formFieldCopy.questions.push(updatedObsGroupFormField); + } + return formFieldCopy; + }); + }, + [setFormField], + ); + + return ( + + {children} + + ); +}; + +export const useFormField = (): FormFieldContextType => { + const context = useContext(FormFieldContext); + if (!context) { + throw new Error('useFormField must be used within a FormFieldProvider'); + } + return context; +}; diff --git a/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx new file mode 100644 index 00000000..5b0d6636 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/common/concept-search/concept-search.component.tsx @@ -0,0 +1,154 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { Search, InlineLoading, Layer, Tile, FormLabel, InlineNotification } from '@carbon/react'; +import { type TFunction, useTranslation } from 'react-i18next'; +import { ArrowUpRight } from '@carbon/react/icons'; +import { useConceptId } from '@hooks/useConceptId'; +import { useConceptLookup } from '@hooks/useConceptLookup'; +import { useDebounce } from '@openmrs/esm-framework'; +import type { Concept } from '@types'; +import styles from './concept-search.scss'; + +interface ConceptSearchProps { + label?: TFunction; + defaultConcept?: string; + onClearSelectedConcept?: () => void; + onSelectConcept: (concept: Concept) => void; +} + +const ConceptSearch: React.FC = ({ + label, + defaultConcept, + onClearSelectedConcept, + onSelectConcept, +}) => { + const { t } = useTranslation(); + const [conceptToLookup, setConceptToLookup] = useState(''); + const debouncedConceptToLookup = useDebounce(conceptToLookup); + const { concepts, conceptLookupError, isLoadingConcepts } = useConceptLookup(debouncedConceptToLookup); + const { + concept: initialConcept, + conceptName, + conceptNameLookupError, + isLoadingConcept, + } = useConceptId(defaultConcept ?? ''); + const [selectedConcept, setSelectedConcept] = useState(null); + + useEffect(() => { + if (initialConcept) { + setSelectedConcept(initialConcept); + onSelectConcept(initialConcept); + } + }, [initialConcept, onSelectConcept]); + + const handleConceptChange = useCallback( + (event: React.ChangeEvent) => setConceptToLookup(event.target.value), + [], + ); + + const handleConceptSelect = useCallback( + (concept: Concept) => { + setConceptToLookup(''); + setSelectedConcept(concept); + onSelectConcept(concept); + }, + [onSelectConcept], + ); + + const clearSelectedConcept = useCallback(() => { + setSelectedConcept(null); + if (onClearSelectedConcept) onClearSelectedConcept(); + }, [onClearSelectedConcept]); + + return ( + <> + + {label ?? t('searchForBackingConcept', 'Search for a backing concept')} + + {conceptLookupError || conceptNameLookupError ? ( + + ) : null} + {isLoadingConcept ? ( + + ) : ( + { + if (conceptToLookup) { + return conceptToLookup; + } + if (selectedConcept) { + return selectedConcept.display; + } + if (conceptName) { + return conceptName; + } + return ''; + })()} + /> + )} + {(() => { + if (!conceptToLookup) return null; + if (isLoadingConcepts) + return ; + if (concepts?.length && !isLoadingConcepts) { + return ( +
    + {concepts?.map((concept, index) => ( +
  • handleConceptSelect(concept)}> + {concept.display} +
  • + ))} +
+ ); + } + return ( + + + + {t('noMatchingConcepts', 'No concepts were found that match')}{' '} + "{debouncedConceptToLookup}". + + + +
+ {

{t('conceptSearchHelpText', "Can't find a concept?")}

} + + {t('searchInOCL', 'Search in OCL')} + + +
+
+ ); + })()} + + ); +}; + +export default ConceptSearch; 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..f7eacd8f --- /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: layout.$spacing-05; +} + +.loader { + padding: layout.$spacing-04 layout.$spacing-03; +} + +.concept { + padding: layout.$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/common/required-label/required-label.component.tsx b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.component.tsx new file mode 100644 index 00000000..067dcac3 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.component.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type { TFunction } from 'react-i18next'; +import styles from './required-label.scss'; + +interface RequiredLabelProps { + isRequired: boolean; + text: string; + t: TFunction; +} + +const RequiredLabel: React.FC = ({ isRequired, text, t }) => { + return ( + <> + {text} + {isRequired && ( + + * + + )} + + ); +}; + +export default RequiredLabel; diff --git a/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss new file mode 100644 index 00000000..d20c2a6c --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/common/required-label/required-label.scss @@ -0,0 +1,7 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; + +.required { + color: colors.$red-60; + margin-left: layout.$spacing-02; +} diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/index.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/index.tsx new file mode 100644 index 00000000..3ae93757 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/index.tsx @@ -0,0 +1,3 @@ +export { default as ObsTypeQuestion } from './obs/obs-type-question.component'; +export { default as ProgramStateTypeQuestion } from './program-state/program-state-type-question.component'; +export { default as PatientIdentifierTypeQuestion } from './patient-identifier/patient-identifier-type-question.component'; 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 new file mode 100644 index 00000000..adc1a72d --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.component.tsx @@ -0,0 +1,116 @@ +import React, { useCallback, useState } from 'react'; +import { FormLabel, InlineNotification, FormGroup, 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, ConceptMapping, DatePickerType } from '@types'; +import styles from './obs-type-question.scss'; + +const ObsTypeQuestion: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField, concept, setConcept } = useFormField(); + const [conceptMappings, setConceptMappings] = useState>([]); + + const getDatePickerType = useCallback((concept: Concept): DatePickerType | null => { + const conceptDataType = concept.datatype.name; + switch (conceptDataType) { + case 'Datetime': + return 'both'; + case 'Date': + return 'calendar'; + case 'Time': + return 'timer'; + default: + return null; + } + }, []); + + const handleConceptSelect = useCallback( + (selectedConcept: Concept) => { + setConcept(selectedConcept); + if (selectedConcept) { + const datePickerType = getDatePickerType(selectedConcept); + setFormField((prevField) => ({ + ...prevField, + questionOptions: { + ...prevField.questionOptions, + concept: selectedConcept.uuid, + }, + ...(datePickerType && { datePickerFormat: datePickerType }), + })); + setConceptMappings( + selectedConcept?.mappings?.map((conceptMapping) => { + const data = conceptMapping.display.split(': '); + return { + relationship: conceptMapping.conceptMapType.display, + type: data[0], + value: data[1], + }; + }), + ); + } + }, + [getDatePickerType, setFormField, setConcept], + ); + + const clearSelectedConcept = useCallback(() => { + setConcept(null); + setConceptMappings([]); + + setFormField((prevFormField) => { + const updatedFormField = { ...prevFormField }; + if (updatedFormField.questionOptions) { + delete updatedFormField.questionOptions.concept; + delete updatedFormField.questionOptions.answers; + } + if (updatedFormField.datePickerFormat) { + delete updatedFormField.datePickerFormat; + } + return updatedFormField; + }); + }, [setFormField, setConcept]); + + return ( + + + + {concept?.allowDecimal === false && ( + + )} + + {conceptMappings?.length ? ( + + {t('mappings', 'Mappings')} + + + + + + + + + + {conceptMappings.map((mapping, index) => ( + + + + + + ))} + +
{t('relationship', 'Relationship')}{t('source', 'Source')}{t('code', 'Code')}
{mapping.relationship}{mapping.type}{mapping.value}
+
+ ) : null} +
+ ); +}; + +export default ObsTypeQuestion; 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 new file mode 100644 index 00000000..fd73ec10 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.scss @@ -0,0 +1,35 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; + +.label { + margin-bottom: layout.$spacing-03; +} + +.table { + width: 100%; + + tr { + background-color: colors.$white-0; + border: 1px solid colors.$gray-20; + } + + td, + th { + padding: layout.$spacing-02; + border: 1px solid colors.$gray-20; + max-width: layout.$spacing-13; + text-align: left; + } +} + +.tableStriped { + @extend .table; + + thead { + font-weight: bold; + } + + tbody > tr:nth-child(odd) { + background-color: colors.$gray-10; + } +} 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 new file mode 100644 index 00000000..ee4d0ba4 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/obs/obs-type-question.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import ObsTypeQuestion from './obs-type-question.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import { useConceptId } from '@hooks/useConceptId'; +import { useConceptLookup } from '@hooks/useConceptLookup'; +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', + questionOptions: { + rendering: 'text', + }, +}; + +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField, setConcept }), +})); + +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(), +})); + +describe('ObsTypeQuestion', () => { + it('renders', () => { + mockUseConceptLookup.mockReturnValue({ concepts: [], conceptLookupError: null, isLoadingConcepts: false }); + mockUseConceptId.mockReturnValue({ + concept: null, + conceptName: null, + conceptNameLookupError: null, + isLoadingConcept: false, + }); + renderComponent(); + 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', 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(); + + await user.click(conceptMenuItem); + + expect( + screen.getByRole('cell', { + name: /ciel:1606/i, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('cell', { + name: /same\-as/i, + }), + ).toBeInTheDocument(); + }); + + it('sets the date picker format to the concept date picker type', 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 2'); + + const conceptMenuItem = await screen.findByRole('menuitem', { + name: /concept 2/i, + }); + await user.click(conceptMenuItem); + + // Gets all calls made to our mock function, the arguments from the first call and the first argument of the first call + const updateFn = mockSetFormField.mock.calls[0][0]; + + // Execute the update function with the previous state + const resultState = updateFn(formField); + + // Check that the result has the expected values + expect(resultState).toEqual({ + ...formField, + datePickerFormat: 'calendar', + questionOptions: { + ...formField.questionOptions, + concept: '456', + }, + }); + }); + + it('loads the selected concept details', async () => { + formField.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, + }); + renderComponent(); + + 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(); + }); +}); + +function renderComponent() { + render( + + + , + ); +} 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 new file mode 100644 index 00000000..92c17c4b --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.component.tsx @@ -0,0 +1,72 @@ +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 { useFormField } from '../../../../form-field-context'; +import type { PatientIdentifierType } from '@types'; +import styles from './patient-identifier-type-question.scss'; + +const PatientIdentifierTypeQuestion: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + const { patientIdentifierTypes, patientIdentifierTypeLookupError, isLoadingPatientIdentifierTypes } = + usePatientIdentifierTypes(); + const [selectedPatientIdetifierType, setSelectedPatientIdetifierType] = useState( + formField.questionOptions?.identifierType + ? patientIdentifierTypes.find( + (patientIdentifierType) => patientIdentifierType.uuid === formField.questionOptions.identifierType, + ) + : null, + ); + + const handlePatientIdentifierTypeChange = ({ selectedItem }: { selectedItem: PatientIdentifierType }) => { + setSelectedPatientIdetifierType(selectedItem); + setFormField({ + ...formField, + questionOptions: { + ...formField.questionOptions, + identifierType: selectedItem.uuid, + }, + }); + }; + + const convertItemsToString = useCallback((item: PatientIdentifierType) => item?.display ?? '', []); + + return ( +
+ + {t('searchForBackingPatientIdentifierType', 'Search for a backing patient identifier type')} + + {patientIdentifierTypeLookupError && ( + + )} + {isLoadingPatientIdentifierTypes ? ( + + ) : ( + patientIdentifierType?.uuid === formField.questionOptions?.identifierType, + )} + /> + )} +
+ ); +}; + +export default PatientIdentifierTypeQuestion; diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.scss b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type-question.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type.test.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type.test.tsx new file mode 100644 index 00000000..e4683ae3 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/patient-identifier/patient-identifier-type.test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import PatientIdentifierTypeQuestion from './patient-identifier-type-question.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import { usePatientIdentifierTypes } from '@hooks/usePatientIdentifierTypes'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; +import type { PatientIdentifierType } from '@types'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + id: '1', + type: 'patientIdentifier', + questionOptions: { + rendering: 'text', + }, +}; + +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +const mockUsePatientIdentifierTypes = jest.mocked(usePatientIdentifierTypes); +jest.mock('@hooks/usePatientIdentifierTypes', () => ({ + ...jest.requireActual('@hooks/usePatientIdentifierTypes'), + usePatientIdentifierTypes: jest.fn((value: string) => value), +})); + +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(); + renderComponent(); + + 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, + }); + renderComponent(); + + 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, + }); + renderComponent(); + + 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, + }); + formField.questionOptions.identifierType = patientIdentifierTypes[0].uuid; + renderComponent(); + + expect( + screen.getByRole('button', { + name: /clear selected item/i, + }), + ).toBeInTheDocument(); + expect(screen.getByRole('combobox')).toHaveDisplayValue(/type 1/i); + }); +}); + +function renderComponent() { + render( + + + , + ); +} 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 new file mode 100644 index 00000000..55dcffbe --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.component.tsx @@ -0,0 +1,213 @@ +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { SelectSkeleton, Stack, ComboBox, InlineNotification, MultiSelect, Tag, FormLabel } from '@carbon/react'; +import { usePrograms, useProgramWorkStates } from '@hooks/useProgramStates'; +import { useFormField } from '../../../../form-field-context'; +import type { ProgramWorkflow, Program } from '@types'; +import type { ProgramState } from '@openmrs/esm-form-engine-lib'; +import styles from './program-state-type-question.scss'; + +interface ProgramStateData { + selectedItems: Array; +} + +const ProgramStateTypeQuestion: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + const { programs, programsLookupError, isLoadingPrograms } = usePrograms(); + const [selectedProgram, setSelectedProgram] = useState(); + const [selectedProgramWorkflow, setSelectedProgramWorkflow] = useState(); + const [programWorkflows, setProgramWorkflows] = useState>([]); + const { programStates, programStatesLookupError, isLoadingProgramStates, mutateProgramStates } = useProgramWorkStates( + selectedProgramWorkflow?.uuid, + ); + + const selectedProgramStates = useMemo(() => { + if (!formField.questionOptions?.answers || !programStates.length) { + return []; + } + + const answerUuids = new Set(formField.questionOptions.answers.map((answer) => answer.value)); + return programStates.filter((programState) => answerUuids.has(programState.uuid)); + }, [formField.questionOptions?.answers, programStates]); + + const handleProgramChange = useCallback( + (selectedItem: Program) => { + setSelectedProgram(selectedItem); + setSelectedProgramWorkflow(null); + setProgramWorkflows(selectedItem?.allWorkflows ?? []); + setFormField((prevField) => { + if (selectedItem) { + return { + ...prevField, + questionOptions: { + ...prevField.questionOptions, + programUuid: selectedItem.uuid, + }, + }; + } + const newQuestionOptions = { ...prevField.questionOptions }; + delete newQuestionOptions.programUuid; + delete newQuestionOptions.workflowUuid; + delete newQuestionOptions.answers; + + return { + ...prevField, + questionOptions: newQuestionOptions, + }; + }); + }, + [setFormField], + ); + + const handleProgramWorkflowChange = useCallback( + (selectedItem: ProgramWorkflow | null) => { + setSelectedProgramWorkflow(selectedItem); + void mutateProgramStates(); + + setFormField((prevField) => { + if (selectedItem) { + return { + ...prevField, + questionOptions: { + ...prevField.questionOptions, + workflowUuid: selectedItem.uuid, + }, + }; + } + const newQuestionOptions = { ...prevField.questionOptions }; + delete newQuestionOptions.workflowUuid; + delete newQuestionOptions.answers; + + return { + ...prevField, + questionOptions: newQuestionOptions, + }; + }); + }, + [mutateProgramStates, setFormField], + ); + + const selectProgramStates = useCallback( + (data: ProgramStateData) => { + setFormField({ + ...formField, + questionOptions: { + ...formField.questionOptions, + answers: data.selectedItems.map((answer) => ({ + value: answer.uuid, + label: answer.concept.display, + })), + }, + }); + }, + [formField, setFormField], + ); + + // Initialize selected program and workflow from formField + useEffect(() => { + const selectedProgramResult = programs.find((program) => program.uuid === formField.questionOptions?.programUuid); + if (selectedProgramResult) { + setSelectedProgram(selectedProgramResult); + setProgramWorkflows(selectedProgramResult.allWorkflows); + + 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]); + + const convertItemsToString = useCallback((item: ProgramState) => { + return item.concept.display; + }, []); + + return ( + + {t('selectProgram', 'Select a program')} + {programsLookupError && ( + + )} + {isLoadingPrograms ? ( + + ) : ( + programs.length > 0 && ( + item?.name} + onChange={({ selectedItem }: { selectedItem: Program }) => { + handleProgramChange(selectedItem); + }} + placeholder={t('addProgram', 'Add program')} + selectedItem={selectedProgram} + titleText={t('program', 'Program')} + initialSelectedItem={programs.find((program) => program?.uuid === formField.questionOptions?.programUuid)} + /> + ) + )} + + {selectedProgram && ( + item?.concept?.display} + onChange={({ selectedItem }: { selectedItem: ProgramWorkflow }) => handleProgramWorkflowChange(selectedItem)} + placeholder={t('addProgramWorkflow', 'Add program workflow')} + selectedItem={selectedProgramWorkflow} + titleText={t('programWorkflow', 'Program workflow')} + initialSelectedItem={programWorkflows.find( + (programWorkflow) => programWorkflow?.uuid === formField.questionOptions?.workflowUuid, + )} + /> + )} + {selectedProgramWorkflow && ( +
+ {programStatesLookupError && ( + + )} + {isLoadingProgramStates ? ( + + ) : ( + programStates?.length > 0 && ( + + ) + )} + {selectedProgramStates?.map((answer) => ( +
+ + {answer?.concept?.display} + +
+ ))} +
+ )} +
+ ); +}; + +export default ProgramStateTypeQuestion; diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.scss b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state-type-question.scss new file mode 100644 index 00000000..e69de29b 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..3e2ddc4c --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/inputs/program-state/program-state.test.tsx @@ -0,0 +1,200 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { render, screen } from '@testing-library/react'; +import ProgramStateTypeQuestion from './program-state-type-question.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import { usePrograms, useProgramWorkStates } from '@hooks/useProgramStates'; +import type { FormField, ProgramState } from '@openmrs/esm-form-engine-lib'; +import type { Program } from '@types'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + id: '1', + type: 'programState', + questionOptions: { + rendering: 'text', + }, +}; + +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +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 = [ + { + uuid: '1111', + concept: { display: 'Program 1 State 1', uuid: '1111' }, + programWorkflow: { display: 'Program 1 Workflow 1', uuid: '111' }, + }, + { + uuid: '1112', + 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: [], + }, + ], + }, + { + uuid: '2', + name: 'Program 2', + allWorkflows: [ + { + uuid: '21', + concept: { display: 'Program 2 Workflow 1', uuid: '211' }, + states: [], + }, + ], + }, +]; + +describe('ProgramStateTypeQuestion', () => { + it('renders without crashing', async () => { + mockUsePrograms.mockReturnValue({ programs: programs, programsLookupError: null, isLoadingPrograms: false }); + mockUseProgramWorkflowStates.mockReturnValue({ + programStates: [], + programStatesLookupError: null, + isLoadingProgramStates: null, + mutateProgramStates: jest.fn(), + }); + renderComponent(); + + 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(), + }); + renderComponent(); + + 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(); + renderComponent(); + + expect(screen.getByRole('combobox', { name: /^program$/i })).toBeInTheDocument(); + const selectProgramsButton = screen.getByRole('button', { + name: /open/i, + }); + expect(selectProgramsButton).toBeInTheDocument(); + await user.click(selectProgramsButton); + + const programOneSelectOption = screen.getByRole('option', { + name: /program 1/i, + }); + expect(programOneSelectOption).toBeInTheDocument(); + expect( + screen.getByRole('option', { + name: /program 2/i, + }), + ).toBeInTheDocument(); + + 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); + + const programWorkflowSelectionOption = screen.getByRole('option', { name: /program 1 workflow 1/i }); + expect(programWorkflowSelectionOption).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /program 1 workflow 2/i })).toBeInTheDocument(); + + await user.click(programWorkflowSelectionOption); + expect(screen.getByRole('combobox', { name: /^program workflow$/i })).toHaveDisplayValue(/program 1 workflow 1/i); + expect(screen.getByText(/program state/i)).toBeInTheDocument(); + + const programStateMenu = screen.getByRole('combobox', { + name: /program state/i, + }); + expect(programStateMenu).toBeInTheDocument(); + await user.click(programStateMenu); + + const programStateSelection = screen.getByRole('option', { name: /program 1 state 1/i }); + expect(programStateSelection).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /program 1 state 2/i })).toBeInTheDocument(); + }); + + 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(), + }); + formField.questionOptions = { + rendering: 'select', + programUuid: programs[0].uuid, + workflowUuid: programs[0].allWorkflows[0].uuid, + answers: [{ value: programOneWorkflowOneStates[0].uuid, label: programOneWorkflowOneStates[0].concept.display }], + }; + renderComponent(); + + 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(); + }); +}); + +function renderComponent() { + render( + + + , + ); +} diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/question-type.component.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/question-type.component.tsx new file mode 100644 index 00000000..49d1d1bc --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/question-type.component.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { ObsTypeQuestion, ProgramStateTypeQuestion, PatientIdentifierTypeQuestion } from './inputs'; +import { useFormField } from '../../form-field-context'; +import type { QuestionType } from '@types'; + +const componentMap: Partial> = { + obs: ObsTypeQuestion, + programState: ProgramStateTypeQuestion, + patientIdentifier: PatientIdentifierTypeQuestion, + obsGroup: ObsTypeQuestion, +}; + +const QuestionTypeComponent: React.FC = () => { + const { formField } = useFormField(); + const Component = componentMap[formField.type]; + if (!Component) { + console.error(`No component found for questiontype: ${formField.type}`); + return null; + } + return ; +}; + +export default QuestionTypeComponent; diff --git a/src/components/interactive-builder/modals/question/question-form/question-types/question-type.test.tsx b/src/components/interactive-builder/modals/question/question-form/question-types/question-type.test.tsx new file mode 100644 index 00000000..71d2643d --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question-types/question-type.test.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FormFieldProvider } from '../../form-field-context'; +import QuestionTypeComponent from './question-type.component'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + type: 'obs', + questionOptions: { rendering: 'text' }, + id: '1', +}; + +jest.mock('../../form-field-context', () => ({ + ...jest.requireActual('../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +describe('RenderingType Component', () => { + it('renders the obs component for question type obs', () => { + renderQuestionTypeComponent(); + + const obsComponent = screen.getByRole('searchbox', { name: /search for a backing concept/i }); + + expect(obsComponent).toBeInTheDocument(); + }); + + it('renders the patient identifier component for question type patientIdentifier', () => { + formField.type = 'patientIdentifier'; + renderQuestionTypeComponent(); + + const patientIdentifierComponent = screen.getByText(/search for a backing patient identifier type/i); + + expect(patientIdentifierComponent).toBeInTheDocument(); + }); + + it('renders the program state component for question type programState', () => { + formField.type = 'programState'; + renderQuestionTypeComponent(); + + const programStateComponent = screen.getByText(/select a program/i); + + expect(programStateComponent).toBeInTheDocument(); + }); + + it('prints error to console if component cannot be found for type', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + formField.type = 'control'; + renderQuestionTypeComponent(); + + expect(consoleErrorSpy).toHaveBeenCalledWith(`No component found for questiontype: ${formField.type}`); + consoleErrorSpy.mockRestore(); + }); +}); + +function renderQuestionTypeComponent() { + render( + + + , + ); +} 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 new file mode 100644 index 00000000..0115330b --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question/question.component.tsx @@ -0,0 +1,157 @@ +import React, { useCallback } from 'react'; +import { TextInput, Button, Select, SelectItem, RadioButtonGroup, RadioButton } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import RenderTypeComponent from '../rendering-types/rendering-type.component'; +import QuestionTypeComponent from '../question-types/question-type.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'; +import styles from './question.scss'; + +interface QuestionProps { + checkIfQuestionIdExists: (idToTest: string) => boolean; +} + +const Question: React.FC = ({ checkIfQuestionIdExists }) => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + + const convertLabelToCamelCase = () => { + const camelCasedLabel = formField.label + ?.toLowerCase() + ?.replace(/(?:^\w|[A-Z]|\b\w)/g, (word, index) => { + return index === 0 ? word.toLowerCase() : word.toUpperCase(); + }) + .replace(/\s+/g, ''); + setFormField({ ...formField, id: camelCasedLabel }); + }; + + const isQuestionIdValid = useCallback(() => { + return checkIfQuestionIdExists(formField.id); + }, [formField.id, checkIfQuestionIdExists]); + + return ( + <> + + + {t('questionId', 'Question ID (prefer using camel-case for IDs). Each field should have a unique ID.')} + + {formField?.label && ( + + )} +
+ } + value={formField?.id} + onChange={(event: React.ChangeEvent) => { + setFormField({ ...formField, id: event.target.value }); + }} + placeholder={t( + 'questionIdPlaceholder', + 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.', + )} + required + /> + + + {formField.questionOptions && formField.questionOptions.rendering !== 'markdown' && ( + <> + + } + placeholder={t('labelPlaceholder', 'e.g. Type of Anaesthesia')} + value={formField?.label} + onChange={(event: React.ChangeEvent) => + setFormField({ ...formField, label: event.target.value }) + } + required + /> + + setFormField({ ...formField, required: false })} + value="optional" + /> + setFormField({ ...formField, required: true })} + value="required" + /> + + + )} + {formField.type && } + {formField.questionOptions?.rendering && } + + ); +}; + +export default Question; diff --git a/src/components/interactive-builder/modals/question/question-form/question/question.scss b/src/components/interactive-builder/modals/question/question-form/question/question.scss new file mode 100644 index 00000000..242156e0 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/question/question.scss @@ -0,0 +1,9 @@ +.questionIdLabel { + > span { + display: inline-block; + margin-right: 1rem; + } + + display: flex; + align-items: center; +} diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.component.tsx new file mode 100644 index 00000000..b49c1d55 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.component.tsx @@ -0,0 +1,41 @@ +import React, { useCallback } from 'react'; +import { RadioButtonGroup, RadioButton } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { useFormField } from '../../../../form-field-context'; +import type { DatePickerTypeOption } from '@types'; + +const Date: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + + const datePickerTypeOptions: Record> = { + datetime: [{ value: 'both', label: t('calendarAndTimer', 'Calendar and timer'), defaultChecked: true }], + date: [{ value: 'calendar', label: t('calendarOnly', 'Calendar only'), defaultChecked: false }], + time: [{ value: 'timer', label: t('timerOnly', 'Timer only'), defaultChecked: false }], + }; + + const handleDatePickerTypeChange = useCallback( + (type: DatePickerTypeOption) => { + setFormField({ ...formField, datePickerFormat: type.value }); + }, + [formField, setFormField], + ); + return ( + + {Object.values(datePickerTypeOptions) + .flat() + .map((type) => ( + + ))} + + ); +}; + +export default Date; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.test.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.test.tsx new file mode 100644 index 00000000..90d4d1e2 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/date/date.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Date from './date.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + datePickerFormat: 'both', + type: 'obs', + questionOptions: { rendering: 'date' }, + id: '1', +}; +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +describe('Date Component', () => { + it('renders', () => { + renderDateComponent(); + + expect(screen.getByText(/the type of date picker to show/i)).toBeInTheDocument(); + }); + + it('checks the default radio button based on date picker format', () => { + renderDateComponent(); + + const calendarAndTimerRadioButton = screen.getByRole('radio', { + name: /calendar and timer/i, + }); + + expect(calendarAndTimerRadioButton).toBeChecked(); + }); + + it('updates the form field when the date picker type is edited', async () => { + renderDateComponent(); + + const user = userEvent.setup(); + const calendarRadioButton = screen.getByRole('radio', { name: /calendar only/i }); + await user.click(calendarRadioButton); + + expect(mockSetFormField).toHaveBeenCalledWith({ + ...formField, + datePickerFormat: 'calendar', + }); + }); +}); + +function renderDateComponent() { + render( + + + , + ); +} 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 new file mode 100644 index 00000000..f0fdc123 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/index.tsx @@ -0,0 +1,8 @@ +export { default as Date } from './date/date.component'; +export { default as Number } from './number/number.component'; +export { default as Text } from './text/text.component'; +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/markdown/markdown.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/markdown/markdown.component.tsx new file mode 100644 index 00000000..d28de93a --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/markdown/markdown.component.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactMde from 'react-mde'; +import ReactMarkdown from 'react-markdown'; +import { useFormField } from '../../../../form-field-context'; +import styles from './markdown.scss'; + +const MarkdownQuestion: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + const [selectedTab, setSelectedTab] = React.useState<'write' | 'preview'>('write'); + + const handleEditorChange = (newValue: string) => { + const updatedFormField = { ...formField, value: newValue }; + setFormField(updatedFormField); + }; + + const handleTabChange = () => { + setSelectedTab((prevTab) => (prevTab === 'write' ? 'preview' : 'write')); + }; + + return ( +
+ + Promise.resolve() + } + childProps={{ + writeButton: { + tabIndex: -1, + }, + }} + loadingPreview={t('loadingPreview', 'Loading preview...')} + /> +
+ ); +}; + +export default MarkdownQuestion; diff --git a/src/components/interactive-builder/markdown-question.scss b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/markdown/markdown.scss similarity index 100% rename from src/components/interactive-builder/markdown-question.scss rename to src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/markdown/markdown.scss diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.component.tsx new file mode 100644 index 00000000..181d86e0 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.component.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { TextInput } from '@carbon/react'; +import { useFormField } from '../../../../form-field-context'; + +const Number: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + + return ( + <> + parseFloat(formField.questionOptions?.max ?? '')} + invalidText={ + parseFloat(formField.questionOptions?.min ?? '') > parseFloat(formField.questionOptions?.max ?? '') + ? t('invalidMinMax', 'Min value cannot be greater than max') + : '' + } + onChange={(event: React.ChangeEvent) => { + const updatedQuestion = { + ...formField, + questionOptions: { ...formField.questionOptions, min: event.target.value }, + }; + setFormField(updatedQuestion); + }} + /> + parseFloat(formField.questionOptions?.max ?? '')} + invalidText={ + parseFloat(formField.questionOptions?.min ?? '') > parseFloat(formField.questionOptions?.max ?? '') + ? t('invalidMinMax', 'Min value cannot be greater than max') + : '' + } + onChange={(event: React.ChangeEvent) => { + const updatedQuestion = { + ...formField, + questionOptions: { ...formField.questionOptions, max: event.target.value }, + }; + setFormField(updatedQuestion); + }} + /> + + ); +}; + +export default Number; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.test.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.test.tsx new file mode 100644 index 00000000..10269681 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/number/number.test.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Number from './number.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + type: 'obs', + questionOptions: { rendering: 'number', min: '1', max: '10' }, + id: '1', +}; + +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +describe('Number Component', () => { + it('renders', () => { + renderNumberComponent(); + + expect(screen.getByText(/min value that can be entered/i)).toBeInTheDocument(); + expect(screen.getByText(/max value that can be entered/i)).toBeInTheDocument(); + }); + + it('shows the default min and max values', () => { + renderNumberComponent(); + + const minInput = screen.getByRole('textbox', { + name: /min/i, + }); + const maxInput = screen.getByRole('textbox', { + name: /max/i, + }); + + expect(minInput).toHaveValue('1'); + expect(maxInput).toHaveValue('10'); + }); + + it('updates the form field when the min or max value is edited', async () => { + formField.questionOptions.min = ''; + formField.questionOptions.max = ''; + renderNumberComponent(); + const user = userEvent.setup(); + + const minInput = screen.getByRole('textbox', { name: /min value that can be entered/i }); + await user.type(minInput, '5'); + expect(mockSetFormField).toHaveBeenCalledWith({ + ...formField, + questionOptions: { ...formField.questionOptions, min: '5' }, + }); + + const maxInput = screen.getByRole('textbox', { name: /max value that can be entered/i }); + await user.type(maxInput, '6'); + expect(mockSetFormField).toHaveBeenLastCalledWith({ + ...formField, + questionOptions: { ...formField.questionOptions, max: '6' }, + }); + }); + + it('shows the invalid min and max values', () => { + formField.questionOptions.min = '4'; + formField.questionOptions.max = '3'; + renderNumberComponent(); + + const minInput = screen.getByRole('textbox', { + name: /min value that can be entered/i, + }); + const maxInput = screen.getByRole('textbox', { + name: /max value that can be entered/i, + }); + + expect(minInput).toHaveClass('cds--text-input--invalid'); + expect(maxInput).toHaveClass('cds--text-input--invalid'); + }); +}); + +function renderNumberComponent() { + render( + + + , + ); +} 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..6bd17549 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.component.tsx @@ -0,0 +1,176 @@ +import React, { useCallback, useMemo, 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-answers.scss'; + +interface AnswerItem { + id: string; + text: string; +} + +const SelectAnswers: React.FC = () => { + const { t } = useTranslation(); + const { formField, concept, setFormField } = useFormField(); + const [addedAnswers, setAddedAnswers] = useState([]); + + const selectedAnswers = useMemo( + () => + formField.questionOptions?.answers?.map((answer) => ({ + id: answer.concept, + text: answer.label, + })) ?? [], + [formField.questionOptions?.answers], + ); + + const handleSelectAnswers = useCallback( + ({ selectedItems }: { selectedItems: Array }) => { + const mappedAnswers = selectedItems.map((answer) => ({ + concept: answer.id, + label: answer.text, + })); + + 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..8c783916 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/select/select-answers.test.tsx @@ -0,0 +1,129 @@ +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: '457', 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 2'); + expect( + screen.getByRole('searchbox', { + name: /search for a backing concept/i, + }), + ).toHaveDisplayValue(/concept 2/i); + const additionalAnswerOption1 = screen.getByRole('menuitem', { + name: /concept 2/i, + }); + expect(additionalAnswerOption1).toBeInTheDocument(); + await user.click(additionalAnswerOption1); + + expect(screen.getByTitle(/concept 2/i)).toBeInTheDocument(); + expect( + screen.getByText(/total items selected: 1,to clear selection, press delete or backspace/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/inputs/text-area/textarea.component.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.component.tsx new file mode 100644 index 00000000..48e0df8c --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.component.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { TextInput } from '@carbon/react'; +import { useFormField } from '../../../../form-field-context'; + +const TextArea: React.FC = () => { + const { t } = useTranslation(); + const { formField, setFormField } = useFormField(); + + return ( + ) => { + const updatedQuestion = { + ...formField, + questionOptions: { ...formField.questionOptions, rows: parseInt(event.target.value) }, + }; + setFormField(updatedQuestion); + }} + /> + ); +}; + +export default TextArea; diff --git a/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.test.tsx b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.test.tsx new file mode 100644 index 00000000..b7e21a47 --- /dev/null +++ b/src/components/interactive-builder/modals/question/question-form/rendering-types/inputs/text-area/textarea.test.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import TextArea from './textarea.component'; +import { FormFieldProvider } from '../../../../form-field-context'; +import type { FormField } from '@openmrs/esm-form-engine-lib'; + +const mockSetFormField = jest.fn(); +const formField: FormField = { + datePickerFormat: 'both', + type: 'obs', + questionOptions: { rendering: 'textarea', rows: 5 }, + id: '1', +}; + +jest.mock('../../../../form-field-context', () => ({ + ...jest.requireActual('../../../../form-field-context'), + useFormField: () => ({ formField, setFormField: mockSetFormField }), +})); + +describe('Text Component', () => { + it('renders', () => { + renderTextComponent(); + + expect(screen.getByText(/rows/i)).toBeInTheDocument(); + }); + + it('shows the default rows value', () => { + renderTextComponent(); + + const rowsInput = screen.getByRole('textbox', { + name: /rows/i, + }); + + expect(rowsInput).toHaveValue('5'); + }); + + it('updates the form field when the rows value is edited', async () => { + formField.questionOptions.rows = 0; + renderTextComponent(); + const user = userEvent.setup(); + const rowsInput = screen.getByRole('textbox', { + name: /rows/i, + }); + await user.type(rowsInput, '8'); + + expect(mockSetFormField).toHaveBeenCalledWith({ + ...formField, + questionOptions: { ...formField.questionOptions, rows: 8 }, + }); + }); +}); + +function renderTextComponent() { + render( + +