From cd4e3064fcec23cbf1a16c8afdf12d66bb598f6b Mon Sep 17 00:00:00 2001 From: Dennis Kigen Date: Wed, 20 Sep 2023 11:42:20 +0300 Subject: [PATCH] (chore) Dependency upgrades and improvements to linting and types (#190) --- .eslintignore | 5 +- .eslintrc | 44 +- .husky/pre-commit | 3 +- .prettierrc | 8 + README.md | 60 +- __mocks__/react-i18next.js | 23 +- e2e/commands/form-operations.ts | 52 +- e2e/core/global-setup.ts | 16 +- e2e/core/index.ts | 2 +- e2e/core/test.ts | 7 +- e2e/fixtures/api.ts | 11 +- e2e/fixtures/index.ts | 2 +- e2e/pages/form-builder-page.ts | 152 +- e2e/pages/index.ts | 2 +- .../create-form-using-custom-schema.spec.ts | 36 +- .../create-form-using-dummy-schema.spec.ts | 32 +- e2e/specs/edit-existing-form.spec.ts | 36 +- e2e/specs/forms-dashboard.spec.ts | 52 +- e2e/specs/interactive-builder.spec.ts | 19 +- e2e/specs/publish-form.spec.ts | 37 +- e2e/specs/unpublish-form.spec.ts | 36 +- e2e/support/github/docker-compose.yml | 4 +- i18next-parser.config.js | 38 +- jest.config.js | 24 +- package.json | 65 +- playwright.config.ts | 25 +- .../action-buttons.component.tsx | 166 +- .../dashboard/dashboard.component.tsx | 272 +- src/components/dashboard/dashboard.test.tsx | 132 +- .../empty-data-illustration.component.tsx | 20 +- .../empty-state/empty-state.component.tsx | 26 +- .../error-state/error-state.component.tsx | 24 +- .../form-editor/form-editor.component.tsx | 225 +- .../form-renderer/form-renderer.component.tsx | 71 +- .../add-question-modal.component.tsx | 296 +- .../delete-page-modal.component.tsx | 61 +- .../delete-question-modal.component.tsx | 72 +- .../delete-section-modal.component.tsx | 61 +- .../draggable-question.component.tsx | 55 +- .../droppable-container.component.tsx | 10 +- .../edit-question-modal.component.tsx | 424 +- .../editable-value.component.tsx | 29 +- .../interactive-builder.component.tsx | 318 +- .../new-form-modal.component.tsx | 84 +- .../page-modal.component.tsx | 74 +- .../interactive-builder/question-modal.scss | 7 + .../section-modal.component.tsx | 69 +- .../value-editor.component.tsx | 27 +- .../modals/save-form-modal.component.tsx | 277 +- src/components/pagination/index.ts | 4 +- .../pagination/pagination.component.tsx | 21 +- .../pagination/usePaginationInfo.ts | 13 +- .../schema-editor/schema-editor.component.tsx | 28 +- src/config-schema.ts | 58 +- src/constants.ts | 3 - src/declarations.d.ts | 7 +- ...form-builder-admin-card-link.component.tsx | 18 +- src/forms.resource.ts | 153 +- src/hooks/useClobdata.ts | 22 +- src/hooks/useConceptLookup.ts | 13 +- src/hooks/useConceptName.ts | 15 +- src/hooks/useEncounterTypes.ts | 16 +- src/hooks/useForm.ts | 14 +- src/hooks/useForms.ts | 13 +- src/index.ts | 34 +- src/root.component.tsx | 8 +- src/setup-tests.ts | 10 +- src/test-helpers.tsx | 23 +- src/types.ts | 24 +- translations/am.json | 11 +- translations/en.json | 11 +- translations/es.json | 11 +- translations/fr.json | 11 +- translations/he.json | 11 +- translations/km.json | 11 +- tsconfig.json | 14 +- webpack.config.js | 9 +- yarn.lock | 4232 ++++++++++++----- 78 files changed, 4875 insertions(+), 3534 deletions(-) create mode 100644 .prettierrc delete mode 100644 src/constants.ts diff --git a/.eslintignore b/.eslintignore index d85985e..e9f169c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,5 @@ +**/dist/* +**/node_modules/* +**/*.d.tsx src/**/*.test.tsx -**/*.d.tsx \ No newline at end of file +__mocks__/* diff --git a/.eslintrc b/.eslintrc index dfbfb23..3992aea 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,20 +1,51 @@ { - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint" - ], - "root": true, + "env": { + "node": true + }, "extends": [ "eslint:recommended", + "plugin:playwright/recommended", "plugin:prettier/recommended", "plugin:@typescript-eslint/recommended", - "ts-react-important-stuff" + "plugin:@typescript-eslint/recommended-requiring-type-checking", + "prettier" ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": true, + "tsconfigRootDir": "__dirname" + }, + "plugins": ["@typescript-eslint"], + "root": true, "rules": { + // 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", + // Use `import type` instead of `import` for type imports + "@typescript-eslint/consistent-type-imports": [ + "error", + { + "prefer": "type-imports", + "disallowTypeAnnotations": true + } + ], + // Use Array instead of T[] consistently + "@typescript-eslint/array-type": [ + "error", + { + "default": "generic" + } + ], "no-restricted-imports": [ "error", { "paths": [ + // These two rules ensure that we're importing lodash and lodash-es correctly. Not doing so can bloat our bundle size significantly. { "name": "lodash", "message": "Import specific methods from `lodash`. e.g. `import map from 'lodash/map'`" @@ -24,6 +55,7 @@ "importNames": ["default"], "message": "Import specific methods from `lodash-es`. e.g. `import { map } from 'lodash-es'`" }, + // These two rules ensure that we're importing Carbon components and icons from the correct packages (after v10). May be removed in the future. { "name": "carbon-components-react", "message": "Import from `@carbon/react` directly. e.g. `import { Toggle } from '@carbon/react'`" diff --git a/.husky/pre-commit b/.husky/pre-commit index 27e677d..a972bbc 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,5 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -yarn pretty-quick --staged \ No newline at end of file +yarn prettier && npx lint-staged +yarn turbo extract-translations diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..5d8c496 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "bracketSpacing": true, + "printWidth": 120, + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/README.md b/README.md index a062938..6f42ba4 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,20 @@ The Form Builder is a widget used to create OpenMRS form schemas. It enables use ## Form Builder User Guide -* See the thorough User Guide for the Form Builder here: https://ampath-forms.vercel.app/docs/quickstart -* Prerequisites & dependencies are covered here: https://ampath-forms.vercel.app/docs/developer-guide/run-form-engine-in-openmrs3#prerequisites +* See the thorough User Guide for the Form Builder here: +* Prerequisites & dependencies are covered here: ## Running this code + Under the hood, the Form Builder uses the [OHRI form engine](https://www.npmjs.com/package/@openmrs/openmrs-form-engine-lib) to render a visual representation of your schema. This visual preview gets progressively updated as you build your schema. When done building, you can save your schema to an OpenMRS server. You can also publish your schema to make it available to your frontend. To set up environment variables for the project, follow these steps: 1. Create a copy of the .env.example file by running the following command: - ```bash - cp example.env .env - ``` + ```bash + cp example.env .env + ``` 2. Open the newly created .env file in the root of the project. @@ -35,17 +36,49 @@ yarn start # Launches a dev server Once the dev server launches, log in and select a location. You will get redirected to the home page. Once there, you can either: -- Click the App Switcher icon in the top right corner and then click `Form Builder` to launch the app. -- Manually navigate to the `/openmrs/spa/form-builder` URL. +* Click the App Switcher icon in the top right corner and then click the `System Administration` link to go the Admin page. Click on the `Form Builder` tile to launch the app. +* Manually navigate to the `/openmrs/spa/form-builder` URL. ## Running tests ### Unit tests -To run unit tests, use: +To run tests for all packages, run: -```sh -yarn test +```bash +yarn turbo test +``` + +To run tests in `watch` mode, run: + +```bash +yarn turbo test:watch +``` + +To run a specific test file, run: + +```bash +yarn turbo test -- dashboard +``` + +The above command will only run tests in the file or files that match the provided string. + +You can also run the matching tests from above in watch mode by running: + +```bash +yarn turbo test:watch -- dashboard +``` + +To generate a `coverage` report, run: + +```bash +yarn turbo coverage +``` + +By default, `turbo` will cache test runs. This means that re-running tests wihout changing any of the related files will return the cached logs from the last run. To bypass the cache, run tests with the `force` flag, as follows: + +```bash +yarn turbo test --force ``` ### E2E tests @@ -64,15 +97,8 @@ yarn test-e2e --headed Please read [our e2e docs](e2e/README.md) for more information about E2E testing. - ## Building ```sh yarn build ``` - -## Running tests - -```sh -yarn test -``` diff --git a/__mocks__/react-i18next.js b/__mocks__/react-i18next.js index a181e83..14fb198 100644 --- a/__mocks__/react-i18next.js +++ b/__mocks__/react-i18next.js @@ -1,16 +1,14 @@ /** At present, this entire mock is boilerplate. */ -const React = require("react"); -const reactI18next = require("react-i18next"); +const React = require('react'); +const reactI18next = require('react-i18next'); -const hasChildren = (node) => - node && (node.children || (node.props && node.props.children)); +const hasChildren = (node) => node && (node.children || (node.props && node.props.children)); -const getChildren = (node) => - node && node.children ? node.children : node.props && node.props.children; +const getChildren = (node) => (node && node.children ? node.children : node.props && node.props.children); const renderNodes = (reactNodes) => { - if (typeof reactNodes === "string") { + if (typeof reactNodes === 'string') { return reactNodes; } @@ -18,18 +16,15 @@ const renderNodes = (reactNodes) => { const child = reactNodes[key]; const isElement = React.isValidElement(child); - if (typeof child === "string") { + if (typeof child === 'string') { return child; } if (hasChildren(child)) { const inner = renderNodes(getChildren(child)); return React.cloneElement(child, { ...child.props, key: i }, inner); } - if (typeof child === "object" && !isElement) { - return Object.keys(child).reduce( - (str, childKey) => `${str}${child[childKey]}`, - "" - ); + if (typeof child === 'object' && !isElement) { + return Object.keys(child).reduce((str, childKey) => `${str}${child[childKey]}`, ''); } return child; @@ -37,7 +32,7 @@ const renderNodes = (reactNodes) => { }; const useMock = [(k) => k, {}]; -useMock.t = (k, o) => (o && o.defaultValue) || (typeof o === "string" ? o : k); +useMock.t = (k, o) => (o && o.defaultValue) || (typeof o === 'string' ? o : k); useMock.i18n = {}; module.exports = { diff --git a/e2e/commands/form-operations.ts b/e2e/commands/form-operations.ts index 7b91016..1fcb5a8 100644 --- a/e2e/commands/form-operations.ts +++ b/e2e/commands/form-operations.ts @@ -1,66 +1,60 @@ -import { APIRequestContext, expect } from "@playwright/test"; -import customSchema from "../support/custom-schema.json"; +import type { APIRequestContext } from '@playwright/test'; +import { expect } from '@playwright/test'; +import customSchema from '../support/custom-schema.json'; +import type { Form } from '../../src/types'; -export const createForm = async ( - api: APIRequestContext, - isFormPublished: boolean -) => { - const formResponse = await api.post("form", { +export const createForm = async (api: APIRequestContext, isFormPublished: boolean) => { + const formResponse = await api.post('form', { data: { name: `A sample test form ${Math.floor(Math.random() * 10000)}`, - version: "1.0", + version: '1.0', published: isFormPublished, - description: "This is the form description", + description: 'This is the form description', encounterType: { - uuid: "e22e39fd-7db2-45e7-80f1-60fa0d5a4378", + uuid: 'e22e39fd-7db2-45e7-80f1-60fa0d5a4378', }, }, }); - await expect(formResponse.ok()).toBeTruthy(); + expect(formResponse.ok()).toBeTruthy(); const form = await formResponse.json(); - return form; + return form as Form; }; -export const addFormResources = async ( - api: APIRequestContext, - valueReference: string, - formUuid: string -) => { +export const addFormResources = async (api: APIRequestContext, valueReference: string, formUuid: string) => { const formResourcesRes = await api.post(`form/${formUuid}/resource`, { data: { - name: "JSON schema", - dataType: "AmpathJsonSchema", + name: 'JSON schema', + dataType: 'AmpathJsonSchema', valueReference: valueReference, }, }); - await expect(formResourcesRes.ok()).toBeTruthy(); + expect(formResourcesRes.ok()).toBeTruthy(); }; export const createValueReference = async (api: APIRequestContext) => { - const boundary = - "--------------------------" + Math.floor(Math.random() * 1e16); - const delimiter = "\r\n--" + boundary + "\r\n"; - const closeDelimiter = "\r\n--" + boundary + "--"; + const boundary = '--------------------------' + Math.floor(Math.random() * 1e16); + const delimiter = '\r\n--' + boundary + '\r\n'; + const closeDelimiter = '\r\n--' + boundary + '--'; const body = delimiter + 'Content-Disposition: form-data; name="file"; filename="schema.json"\r\n' + - "Content-Type: application/json\r\n\r\n" + + 'Content-Type: application/json\r\n\r\n' + JSON.stringify(customSchema) + closeDelimiter; - const valueReference = await api.post("clobdata", { + const valueReference = await api.post('clobdata', { data: body, headers: { - "Content-Type": "multipart/form-data; boundary=" + boundary, + 'Content-Type': 'multipart/form-data; boundary=' + boundary, }, }); - await expect(valueReference.ok()).toBeTruthy(); + expect(valueReference.ok()).toBeTruthy(); return await valueReference.text(); }; export async function deleteForm(api: APIRequestContext, uuid: string) { const formDeleteRes = await api.delete(`form/${uuid}`, { data: {} }); - await expect(formDeleteRes.ok()).toBeTruthy(); + expect(formDeleteRes.ok()).toBeTruthy(); } diff --git a/e2e/core/global-setup.ts b/e2e/core/global-setup.ts index ed4f81b..4238f7b 100644 --- a/e2e/core/global-setup.ts +++ b/e2e/core/global-setup.ts @@ -1,5 +1,5 @@ -import { request } from "@playwright/test"; -import * as dotenv from "dotenv"; +import { request } from '@playwright/test'; +import * as dotenv from 'dotenv'; dotenv.config(); @@ -12,20 +12,20 @@ dotenv.config(); async function globalSetup() { const requestContext = await request.newContext(); - const token = Buffer.from( - `${process.env.E2E_USER_ADMIN_USERNAME}:${process.env.E2E_USER_ADMIN_PASSWORD}` - ).toString("base64"); + const token = Buffer.from(`${process.env.E2E_USER_ADMIN_USERNAME}:${process.env.E2E_USER_ADMIN_PASSWORD}`).toString( + 'base64', + ); await requestContext.post(`${process.env.E2E_BASE_URL}/ws/rest/v1/session`, { data: { sessionLocation: process.env.E2E_LOGIN_DEFAULT_LOCATION_UUID, - locale: "en", + locale: 'en', }, headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', Authorization: `Basic ${token}`, }, }); - await requestContext.storageState({ path: "e2e/storageState.json" }); + await requestContext.storageState({ path: 'e2e/storageState.json' }); await requestContext.dispose(); } diff --git a/e2e/core/index.ts b/e2e/core/index.ts index 481faba..607718c 100644 --- a/e2e/core/index.ts +++ b/e2e/core/index.ts @@ -1 +1 @@ -export * from "./test"; +export * from './test'; diff --git a/e2e/core/test.ts b/e2e/core/test.ts index 9617014..2a2ce62 100644 --- a/e2e/core/test.ts +++ b/e2e/core/test.ts @@ -1,5 +1,6 @@ -import { APIRequestContext, Page, test as base } from "@playwright/test"; -import { api } from "../fixtures"; +import type { APIRequestContext, Page } from '@playwright/test'; +import { test as base } from '@playwright/test'; +import { api } from '../fixtures'; // This file sets up our custom test harness using the custom fixtures. // See https://playwright.dev/docs/test-fixtures#creating-a-fixture for details. @@ -16,5 +17,5 @@ export interface CustomWorkerFixtures { } export const test = base.extend({ - api: [api, { scope: "worker" }], + api: [api, { scope: 'worker' }], }); diff --git a/e2e/fixtures/api.ts b/e2e/fixtures/api.ts index 5b6055d..7223701 100644 --- a/e2e/fixtures/api.ts +++ b/e2e/fixtures/api.ts @@ -1,8 +1,4 @@ -import { - APIRequestContext, - PlaywrightWorkerArgs, - WorkerFixture, -} from "@playwright/test"; +import type { APIRequestContext, PlaywrightWorkerArgs, WorkerFixture } from '@playwright/test'; /** * A fixture which initializes an [`APIRequestContext`](https://playwright.dev/docs/api/class-apirequestcontext) @@ -17,10 +13,7 @@ import { * }); * ``` */ -export const api: WorkerFixture< - APIRequestContext, - PlaywrightWorkerArgs -> = async ({ playwright }, use) => { +export const api: WorkerFixture = async ({ playwright }, use) => { const ctx = await playwright.request.newContext({ baseURL: `${process.env.E2E_BASE_URL}/ws/rest/v1/`, httpCredentials: { diff --git a/e2e/fixtures/index.ts b/e2e/fixtures/index.ts index d158c57..b1c13e7 100644 --- a/e2e/fixtures/index.ts +++ b/e2e/fixtures/index.ts @@ -1 +1 @@ -export * from "./api"; +export * from './api'; diff --git a/e2e/pages/form-builder-page.ts b/e2e/pages/form-builder-page.ts index 713a72a..ae4aaa2 100644 --- a/e2e/pages/form-builder-page.ts +++ b/e2e/pages/form-builder-page.ts @@ -1,128 +1,94 @@ -import { expect, Page } from "@playwright/test"; +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; export class FormBuilderPage { constructor(readonly page: Page) {} - readonly createNewFormButton = () => - this.page.getByRole("button", { name: "Create a new form" }); - readonly schemaInput = () => this.page.locator(".ace_text-input"); - readonly renderChangesButton = () => - this.page.getByRole("button", { name: "Render changes" }); - readonly inputDummySchemaButton = () => - this.page.getByRole("button", { name: "Input dummy schema" }); - readonly saveFormButton = () => - this.page.getByRole("button", { name: "Save Form" }); - readonly publishFormButton = () => - this.page.getByRole("button", { name: "Publish Form" }); - readonly unpublishFormButton = () => - this.page.getByRole("button", { name: "Unpublish Form" }); + readonly createNewFormButton = () => this.page.getByRole('button', { name: /create a new form/i }); + readonly schemaInput = () => this.page.locator('.ace_text-input'); + readonly renderChangesButton = () => this.page.getByRole('button', { name: /render changes/i }); + readonly inputDummySchemaButton = () => this.page.getByRole('button', { name: /input dummy schema/i }); + readonly saveFormButton = () => this.page.getByRole('button', { name: /save form/i }); + readonly publishFormButton = () => this.page.getByRole('button', { name: /^publish form$/i }); + readonly unpublishFormButton = () => this.page.getByRole('button', { name: /unpublish form/i }); readonly unpublishFormConfirmationButton = () => - this.page - .getByRole("dialog") - .getByRole("button", { name: "Unpublish Form" }); - readonly updateExistingFormButton = () => - this.page.getByRole("button", { name: "Update existing version" }); - readonly formNameInput = () => this.page.getByLabel("Form name"); - readonly formVersionInput = () => this.page.getByLabel("Version"); - readonly formDescriptionInput = () => this.page.getByLabel("Description"); - readonly formEncounterType = () => - this.page.getByRole("combobox", { name: "Encounter Type" }); - readonly formSaveButton = () => - this.page.getByRole("dialog").getByRole("button", { name: "Save" }); + this.page.getByRole('dialog').getByRole('button', { name: /confirm/i }); + readonly updateExistingFormButton = () => this.page.getByRole('button', { name: /update existing version/i }); + readonly formNameInput = () => this.page.getByLabel(/form name/i); + readonly formVersionInput = () => this.page.getByLabel(/version/i); + readonly formDescriptionInput = () => this.page.getByLabel(/description/i); + readonly formEncounterType = () => this.page.getByRole('combobox', { name: /encounter type/i }); + readonly formSaveButton = () => this.page.getByRole('dialog').getByRole('button', { name: /save/i }); - readonly previewTab = () => this.page.getByRole("tab", { name: /preview/i }); - readonly interactiveBuilderTab = () => - this.page.getByRole("tab", { name: /interactive builder/i }); - readonly startBuildingButton = () => - this.page.getByRole("button", { name: /start building/i }); - readonly interactiveFormNameInput = () => - this.page.getByRole("textbox", { name: /form name/i }); - readonly interactiveFormDescriptionInput = () => - this.page.getByRole("textbox", { name: /form description/i }); - readonly createFormButton = () => - this.page.getByRole("button", { name: /create form/i }); - readonly addPageButton = () => - this.page.getByRole("button", { name: /add page/i }); + readonly previewTab = () => this.page.getByRole('tab', { name: /preview/i }); + readonly interactiveBuilderTab = () => this.page.getByRole('tab', { name: /interactive builder/i }); + readonly startBuildingButton = () => this.page.getByRole('button', { name: /start building/i }); + readonly interactiveFormNameInput = () => this.page.getByRole('textbox', { name: /form name/i }); + readonly interactiveFormDescriptionInput = () => this.page.getByRole('textbox', { name: /form description/i }); + readonly createFormButton = () => this.page.getByRole('button', { name: /create form/i }); + readonly addPageButton = () => this.page.getByRole('button', { name: /add page/i }); readonly pageNameInput = () => - this.page.getByRole("textbox", { + this.page.getByRole('textbox', { name: /enter a title for your new page/i, }); - readonly savePageButton = () => - this.page.getByRole("button", { name: /^save$/i, exact: true }); + readonly savePageButton = () => this.page.getByRole('button', { name: /^save$/i, exact: true }); readonly pageCreatedMessage = () => this.page.getByText(/new page created/i); - readonly addSectionButton = () => - this.page.getByRole("button", { name: /add section/i }); - readonly sectionNameInput = () => - this.page.getByRole("textbox", { name: /enter a section title/i }); - readonly saveSectionButton = () => - this.page.getByRole("button", { name: /^save$/i, exact: true }); - readonly sectionCreatedMessage = () => - this.page.getByText(/new section created/i); - readonly addQuestionButton = () => - this.page.getByRole("button", { name: /add question/i }); - readonly questionLabelInput = () => - this.page.getByRole("textbox", { name: /label/i }); + readonly addSectionButton = () => this.page.getByRole('button', { name: /add section/i }); + readonly sectionNameInput = () => this.page.getByRole('textbox', { name: /enter a section title/i }); + readonly saveSectionButton = () => this.page.getByRole('button', { name: /^save$/i, exact: true }); + readonly sectionCreatedMessage = () => this.page.getByText(/new section created/i); + readonly addQuestionButton = () => this.page.getByRole('button', { name: /add question/i }); + readonly questionLabelInput = () => this.page.getByRole('textbox', { name: /label/i }); readonly questionTypeDropdown = () => - this.page.getByRole("combobox", { + this.page.getByRole('combobox', { name: /question type/i, }); readonly fieldTypeDropdown = () => - this.page.getByRole("combobox", { + this.page.getByRole('combobox', { name: /field type/i, }); - readonly conceptSearchInput = () => - this.page.getByPlaceholder(/search using a concept name or uuid/i); - readonly selectAnswersDropdown = () => - this.page.getByRole("button", { - name: "Select answers to display Open menu", - }); - readonly answer = () => - this.page.getByRole("menuitem", { name: "Tested for COVID 19" }); - readonly questionIdInput = () => - this.page.getByRole("textbox", { name: /question id/i }); - readonly questionCreatedMessage = () => - this.page.getByText(/new question created/i); - readonly saveQuestionButton = () => - this.page.getByRole("button", { name: /^save$/i, exact: true }); + readonly conceptSearchInput = () => this.page.getByPlaceholder(/search using a concept name or uuid/i); + readonly selectAnswersDropdown = () => this.page.getByText(/select answers to display/i); + readonly answer = () => this.page.getByRole('menuitem', { name: /tested for covid 19/i }); + readonly questionIdInput = () => this.page.getByRole('textbox', { name: /question id/i }); + readonly questionCreatedMessage = () => this.page.getByText(/new question created/i); + readonly saveQuestionButton = () => this.page.getByRole('button', { name: /^save$/i, exact: true }); async gotoFormBuilder() { - await this.page.goto("form-builder"); + await this.page.goto('form-builder'); } async buildFormInteractively() { await this.interactiveBuilderTab().click(); await this.startBuildingButton().click(); - await this.interactiveFormNameInput().fill("Covid-19 Screening"); - await this.interactiveFormDescriptionInput().fill( - "A test form for recording COVID-19 screening information" - ); + await this.interactiveFormNameInput().fill('Covid-19 Screening'); + await this.interactiveFormDescriptionInput().fill('A test form for recording COVID-19 screening information'); await this.createFormButton().click(); await expect(this.page.getByText(/form created/i)).toBeVisible(); await this.addPageButton().click(); - await this.pageNameInput().fill("Screening"); + await this.pageNameInput().fill('Screening'); await this.savePageButton().click(); await expect(this.page.getByText(/new page created/i)).toBeVisible(); await this.addSectionButton().click(); - await this.sectionNameInput().fill("Testing history"); + await this.sectionNameInput().fill('Testing history'); await this.saveSectionButton().click(); await expect(this.page.getByText(/new section created/i)).toBeVisible(); - await this.page.getByRole("button", { name: /^testing history$/i }).click(); + await this.page.getByRole('button', { name: /^testing history$/i }).click(); await this.addQuestionButton().click(); - await this.questionLabelInput().fill( - "Have you been ever been tested for COVID-19?" - ); - await this.questionTypeDropdown().selectOption("obs"); - await this.fieldTypeDropdown().selectOption("radio"); - await this.conceptSearchInput().fill("Tested for COVID 19"); - await this.conceptSearchInput().press("Enter"); + await this.questionLabelInput().fill('Have you been ever been tested for COVID-19?'); + await this.questionIdInput().fill('everTestedForCovid19'); + await this.questionTypeDropdown().selectOption('obs'); + await this.fieldTypeDropdown().selectOption('radio'); + await this.conceptSearchInput().fill('Tested for COVID 19'); + await this.conceptSearchInput().press('Enter'); await this.answer().click(); await this.selectAnswersDropdown().click(); - await this.page.getByRole("option", { name: "No" }).click(); - await this.page.getByRole("option", { name: "Yes" }).click(); - await this.questionIdInput().fill("everTestedForCovid19"); + await this.page.getByRole('option', { name: 'No' }).click(); + await this.page.getByRole('option', { name: 'Yes' }).click(); await this.saveQuestionButton().click(); } @@ -133,13 +99,13 @@ export class FormBuilderPage { await this.formNameInput().click(); await this.formNameInput().fill(formName); await this.formVersionInput().click(); - await this.formVersionInput().fill("1.0"); - await this.formDescriptionInput().fill("This is a test form"); - await this.formEncounterType().selectOption("Admission"); + await this.formVersionInput().fill('1.0'); + await this.formDescriptionInput().fill('This is a test form'); + await this.formEncounterType().selectOption('Admission'); await this.formSaveButton().click(); } - async searchForm(formName: string) { - await this.page.getByRole("searchbox").fill(formName); + async searchForForm(formName: string) { + await this.page.getByRole('searchbox').fill(formName); } } diff --git a/e2e/pages/index.ts b/e2e/pages/index.ts index 82caeb0..8334275 100644 --- a/e2e/pages/index.ts +++ b/e2e/pages/index.ts @@ -1 +1 @@ -export * from "./form-builder-page"; +export * from './form-builder-page'; diff --git a/e2e/specs/create-form-using-custom-schema.spec.ts b/e2e/specs/create-form-using-custom-schema.spec.ts index 4faf94f..84a4b39 100644 --- a/e2e/specs/create-form-using-custom-schema.spec.ts +++ b/e2e/specs/create-form-using-custom-schema.spec.ts @@ -1,40 +1,38 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { deleteForm } from "../commands/form-operations"; -import { FormBuilderPage } from "../pages"; -import customSchema from "../support/custom-schema.json"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { deleteForm } from '../commands/form-operations'; +import { FormBuilderPage } from '../pages'; +import customSchema from '../support/custom-schema.json'; -let formUuid = null; +let formUuid = ''; -test("Create a form using a custom JSON schema", async ({ page }) => { +test('Create a form using a custom JSON schema', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And I click the `Create New Form` button", async () => { + await test.step('And I click the `Create New Form` button', async () => { await formBuilderPage.createNewFormButton().click(); }); - await test.step("Then I paste in a custom JSON schema into the schema editor and click the `Render Changes` button", async () => { - await formBuilderPage - .schemaInput() - .fill(JSON.stringify(customSchema, null, 2)); + await test.step('Then I paste in a custom JSON schema into the schema editor and click the `Render Changes` button', async () => { + await formBuilderPage.schemaInput().fill(JSON.stringify(customSchema, null, 2)); await formBuilderPage.renderChangesButton().click(); }); - await test.step("Then I click the `Save Form` button", async () => { + await test.step('Then I click the `Save Form` button', async () => { await formBuilderPage.saveForm(); }); - await test.step("And I should get a success message and be redirected to the edit page for the new form", async () => { + await test.step('And I should get a success message and be redirected to the edit page for the new form', async () => { // Checks whether the user has been redirected to the edit page - const editFormPageURLRegex = new RegExp("/edit/"); - await expect(page.getByText("Form created")).toBeVisible(); + const editFormPageURLRegex = new RegExp('/edit/'); + await expect(page.getByText('Form created')).toBeVisible(); await page.waitForURL(editFormPageURLRegex); - const editFormPageURL = await page.url(); - formUuid = editFormPageURL.split("/").slice(-1)[0]; + const editFormPageURL = page.url(); + formUuid = editFormPageURL.split('/').slice(-1)[0]; }); }); diff --git a/e2e/specs/create-form-using-dummy-schema.spec.ts b/e2e/specs/create-form-using-dummy-schema.spec.ts index 9214e78..4a4baac 100644 --- a/e2e/specs/create-form-using-dummy-schema.spec.ts +++ b/e2e/specs/create-form-using-dummy-schema.spec.ts @@ -1,38 +1,36 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { deleteForm } from "../commands/form-operations"; -import { FormBuilderPage } from "../pages"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { deleteForm } from '../commands/form-operations'; +import { FormBuilderPage } from '../pages'; -let formUuid = null; +let formUuid = ''; -test("Create a form using the `Input Dummy Schema` feature", async ({ - page, -}) => { +test('Create a form using the `Input Dummy Schema` feature', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And click the `Create New Form` button", async () => { + await test.step('And click the `Create New Form` button', async () => { await formBuilderPage.createNewFormButton().click(); }); - await test.step("Then I click the `Input Dummy Schema` button", async () => { + await test.step('Then I click the `Input Dummy Schema` button', async () => { await formBuilderPage.inputDummySchemaButton().click(); }); - await test.step("Then I click the `Save Form` button", async () => { + await test.step('Then I click the `Save Form` button', async () => { await formBuilderPage.saveForm(); }); - await test.step("Then should get a success message and be redirected to the edit page for the new form", async () => { + await test.step('Then should get a success message and be redirected to the edit page for the new form', async () => { // Checks whether the user has been redirected to the edit page - const editFormPageURLRegex = new RegExp("/edit/"); - await expect(page.getByText("Form created")).toBeVisible(); + const editFormPageURLRegex = new RegExp('/edit/'); + await expect(page.getByText('Form created')).toBeVisible(); await page.waitForURL(editFormPageURLRegex); - const editFormPageURL = await page.url(); - formUuid = editFormPageURL.split("/").slice(-1)[0]; + const editFormPageURL = page.url(); + formUuid = editFormPageURL.split('/').slice(-1)[0]; }); }); diff --git a/e2e/specs/edit-existing-form.spec.ts b/e2e/specs/edit-existing-form.spec.ts index a048528..431a849 100644 --- a/e2e/specs/edit-existing-form.spec.ts +++ b/e2e/specs/edit-existing-form.spec.ts @@ -1,13 +1,8 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { - createForm, - createValueReference, - addFormResources, - deleteForm, -} from "../commands/form-operations"; -import { FormBuilderPage } from "../pages"; -import type { Form } from "../../src/types"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { createForm, createValueReference, addFormResources, deleteForm } from '../commands/form-operations'; +import { FormBuilderPage } from '../pages'; +import type { Form } from '../../src/types'; let form: Form = null; test.beforeEach(async ({ api }) => { @@ -16,32 +11,29 @@ test.beforeEach(async ({ api }) => { await addFormResources(api, valueReference, form.uuid); }); -test("Editing an existing form", async ({ page }) => { +test('Editing an existing form', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And I search for the form I need to edit", async () => { - await formBuilderPage.searchForm(form.name); + await test.step('And I search for the form I need to edit', async () => { + await formBuilderPage.searchForForm(form.name); }); - await test.step("And I click the `Edit` button on the form I need to edit", async () => { - await page - .getByRole("row", { name: form.name }) - .getByLabel("Edit Schema") - .click(); + await test.step('And I click the `Edit` button on the form I need to edit', async () => { + await page.getByRole('row', { name: form.name }).getByLabel('Edit Schema').click(); }); - await test.step("Then I click the `Save Form` button then the `Update existing version` button, and finally the `Save` button", async () => { + await test.step('Then I click the `Save Form` button then the `Update existing version` button, and finally the `Save` button', async () => { await formBuilderPage.saveFormButton().click(); await formBuilderPage.updateExistingFormButton().click(); await formBuilderPage.formSaveButton().click(); }); - await test.step("Then I should see a success message", async () => { - await expect(page.getByText("Success!")).toBeVisible(); + await test.step('Then I should see a success message', async () => { + await expect(page.getByText('Success!')).toBeVisible(); }); }); diff --git a/e2e/specs/forms-dashboard.spec.ts b/e2e/specs/forms-dashboard.spec.ts index eb6671b..ac521f4 100644 --- a/e2e/specs/forms-dashboard.spec.ts +++ b/e2e/specs/forms-dashboard.spec.ts @@ -1,13 +1,8 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { FormBuilderPage } from "../pages"; -import { - createForm, - createValueReference, - addFormResources, - deleteForm, -} from "../commands/form-operations"; -import type { Form } from "../../src/types"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { FormBuilderPage } from '../pages'; +import { createForm, createValueReference, addFormResources, deleteForm } from '../commands/form-operations'; +import type { Form } from '../../src/types'; let form: Form = null; test.beforeEach(async ({ api }) => { @@ -16,24 +11,19 @@ test.beforeEach(async ({ api }) => { await addFormResources(api, valueReference, form.uuid); }); -test("Filter forms based on publish status", async ({ page }) => { +test('Filter forms based on publish status', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); // Test the filter functionality - await test.step("Then I click the publish filter dropdown", async () => { - await page - .getByRole("combobox", { - name: "Filter by publish status: All Open menu", - }) - .click(); + await test.step('Then I click the publish filter dropdown', async () => { + await page.getByText('Filter by publish status:').click(); }); - await test.step("And I click the Unpublished option", async () => - await page.getByText("Unpublished").click()); + await test.step('And I click the Unpublished option', async () => await page.getByText('Unpublished').click()); // Expect the publish status to be "No" const tagElements = await page.$$('div[data-testid="no-tag"]'); @@ -41,29 +31,29 @@ test("Filter forms based on publish status", async ({ page }) => { // Get the inner text of the tag element const innerText = await firstTagElement.innerText(); - await test.step("Then the forms list should only show unpublished forms", () => { - expect(innerText).toBe("No"); + await test.step('Then the forms list should only show unpublished forms', () => { + expect(innerText).toBe('No'); }); }); -test("Search forms by name", async ({ page }) => { +test('Search forms by name', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("Then I click the `Search` button", async () => { - await page.getByPlaceholder("Search this list").click(); + await test.step('Then I click the `Search` button', async () => { + await page.getByPlaceholder('Search this list').click(); }); - await test.step("And I type `A sample test form` into it", async () => - await page.getByPlaceholder("Search this list").fill("a sample test form")); + await test.step('And I type `A sample test form` into it', async () => + await page.getByPlaceholder('Search this list').fill('a sample test form')); - const formNameElement = await page.locator("tr:nth-child(1) > td").nth(0); + const formNameElement = page.locator('tr:nth-child(1) > td').nth(0); const innerNameText = await formNameElement.innerText(); - await test.step("Then the forms list should show only the forms with the text `a sample test form` in their name", () => { - expect(innerNameText).toContain("A sample test form"); + await test.step('Then the forms list should show only the forms with the text `a sample test form` in their name', () => { + expect(innerNameText).toContain('A sample test form'); }); }); diff --git a/e2e/specs/interactive-builder.spec.ts b/e2e/specs/interactive-builder.spec.ts index 21a90de..68a6b0c 100644 --- a/e2e/specs/interactive-builder.spec.ts +++ b/e2e/specs/interactive-builder.spec.ts @@ -1,28 +1,27 @@ -import { test, expect } from "@playwright/test"; -import { FormBuilderPage } from "../pages"; +import { test, expect } from '@playwright/test'; +import { FormBuilderPage } from '../pages'; -test("Create a schema using the interactive builder", async ({ page }) => { +test('Create a schema using the interactive builder', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And click the `Create New Form` button", async () => { + await test.step('And click the `Create New Form` button', async () => { await formBuilderPage.createNewFormButton().click(); }); - await test.step("Then I create a new form using the interactive builder", async () => { + await test.step('Then I create a new form using the interactive builder', async () => { await formBuilderPage.buildFormInteractively(); }); - await test.step("Then I should see the new schema in the schema editor and a success notification", async () => { + await test.step('Then I should see the new schema in the schema editor and a success notification', async () => { await expect(page.getByText(/new question created/i)).toBeVisible(); await page - .locator("#schemaEditor div") + .locator('#schemaEditor div') .filter({ - hasText: - '{ "name": "Covid-19 Screening", "pages": [ { "label": "Screening", "sections": [', + hasText: '{ "name": "Covid-19 Screening", "pages": [ { "label": "Screening", "sections": [', }) .nth(1) .click(); diff --git a/e2e/specs/publish-form.spec.ts b/e2e/specs/publish-form.spec.ts index 2bd368b..d001572 100644 --- a/e2e/specs/publish-form.spec.ts +++ b/e2e/specs/publish-form.spec.ts @@ -1,42 +1,41 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { - createForm, - createValueReference, - addFormResources, - deleteForm, -} from "../commands/form-operations"; -import { FormBuilderPage } from "../pages"; -import type { Form } from "../../src/types"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { createForm, createValueReference, addFormResources, deleteForm } from '../commands/form-operations'; +import { FormBuilderPage } from '../pages'; +import type { Form } from '../../src/types'; let form: Form = null; + test.beforeEach(async ({ api }) => { form = await createForm(api, false); const valueReference = await createValueReference(api); await addFormResources(api, valueReference, form.uuid); }); -test("Publish a form", async ({ page }) => { +test('Publish a form', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And I search for the form I need to publish", async () => { - await formBuilderPage.searchForm(form.name); + await test.step('And I search for the form I need to publish', async () => { + await formBuilderPage.searchForForm(form.name); }); - await test.step("And I click on a form I need to publish", async () => { - await page.getByRole('row', { name: form.name }).getByLabel('Edit Schema').click(); + await test.step('And I click on a form I need to publish', async () => { + await page + .getByRole('row', { name: form.name }) + .getByLabel(/edit schema/i) + .click(); }); - await test.step("Then I click on the publish form button", async () => { + await test.step('Then I click on the publish form button', async () => { await formBuilderPage.publishFormButton().click(); }); - await test.step("Then I should see the form published notification and the unpublish form button", async () => { - await expect(page.getByText("Form published")).toBeVisible(); + await test.step('Then I should see the form published notification and the unpublish form button', async () => { + await expect(page.getByText(/form published/i)).toBeVisible(); await expect(formBuilderPage.unpublishFormButton()).toBeVisible(); }); }); diff --git a/e2e/specs/unpublish-form.spec.ts b/e2e/specs/unpublish-form.spec.ts index f3d9bd7..f26e86c 100644 --- a/e2e/specs/unpublish-form.spec.ts +++ b/e2e/specs/unpublish-form.spec.ts @@ -1,13 +1,8 @@ -import { test } from "../core"; -import { expect } from "@playwright/test"; -import { - createForm, - createValueReference, - addFormResources, - deleteForm, -} from "../commands/form-operations"; -import { FormBuilderPage } from "../pages"; -import type { Form } from "../../src/types"; +import { test } from '../core'; +import { expect } from '@playwright/test'; +import { createForm, createValueReference, addFormResources, deleteForm } from '../commands/form-operations'; +import { FormBuilderPage } from '../pages'; +import type { Form } from '../../src/types'; let form: Form = null; test.beforeEach(async ({ api }) => { @@ -16,28 +11,31 @@ test.beforeEach(async ({ api }) => { await addFormResources(api, valueReference, form.uuid); }); -test("Unpublish a form", async ({ page }) => { +test('Unpublish a form', async ({ page }) => { const formBuilderPage = new FormBuilderPage(page); - await test.step("When I visit the form builder", async () => { + await test.step('When I visit the form builder', async () => { await formBuilderPage.gotoFormBuilder(); }); - await test.step("And I search for the form I need to unpublish", async () => { - await formBuilderPage.searchForm(form.name); + await test.step('And I search for the form I need to unpublish', async () => { + await formBuilderPage.searchForForm(form.name); }); - await test.step("And I click on a form I need to unpublish", async () => { - await page.getByRole('row', { name: form.name }).getByLabel('Edit Schema').click(); + await test.step('And I click on a form I need to unpublish', async () => { + await page + .getByRole('row', { name: form.name }) + .getByLabel(/edit schema/i) + .click(); }); - await test.step("Then I click on the unpublish form button and confirms the unpublication", async () => { + await test.step('Then I click on the unpublish form button and confirms the unpublication', async () => { await formBuilderPage.unpublishFormButton().click(); await formBuilderPage.unpublishFormConfirmationButton().click(); }); - await test.step("Then I should see the form unpublished notification and the publish form button", async () => { - await expect(page.getByText("Form unpublished")).toBeVisible(); + await test.step('Then I should see the form unpublished notification and the publish form button', async () => { + await expect(page.getByText('Form unpublished')).toBeVisible(); await expect(formBuilderPage.publishFormButton()).toBeVisible(); }); }); diff --git a/e2e/support/github/docker-compose.yml b/e2e/support/github/docker-compose.yml index f7fc4d7..372d380 100644 --- a/e2e/support/github/docker-compose.yml +++ b/e2e/support/github/docker-compose.yml @@ -1,12 +1,12 @@ # This docker compose file is used to create a backend environment for the e2e.yml workflow. # The images are pre-filled with data so that the backend environment can be started within a short time. -version: "3.7" +version: '3.7' services: gateway: image: openmrs/openmrs-reference-application-3-gateway:${TAG:-nightly} ports: - - "8080:80" + - '8080:80' frontend: build: diff --git a/i18next-parser.config.js b/i18next-parser.config.js index 20715da..a46a32f 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -1,14 +1,14 @@ module.exports = { - contextSeparator: "_", + contextSeparator: '_', // Key separator used in your translation keys createOldCatalogs: false, // Save the \_old files - defaultNamespace: "translations", + defaultNamespace: 'translations', // Default namespace used in your i18next config - defaultValue: "", + defaultValue: '', // Default value to give to empty keys // You may also specify a function accepting the locale, namespace, and key as arguments @@ -18,43 +18,43 @@ module.exports = { keepRemoved: false, // Keep keys from the catalog that are no longer in code - keySeparator: ".", + keySeparator: '.', // Key separator used in your translation keys // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. // see below for more details lexers: { - hbs: ["HandlebarsLexer"], - handlebars: ["HandlebarsLexer"], + hbs: ['HandlebarsLexer'], + handlebars: ['HandlebarsLexer'], - htm: ["HTMLLexer"], - html: ["HTMLLexer"], + htm: ['HTMLLexer'], + html: ['HTMLLexer'], - mjs: ["JavascriptLexer"], - js: ["JavascriptLexer"], // if you're writing jsx inside .js files, change this to JsxLexer - ts: ["JavascriptLexer"], - jsx: ["JsxLexer"], - tsx: ["JsxLexer"], + mjs: ['JavascriptLexer'], + js: ['JavascriptLexer'], // if you're writing jsx inside .js files, change this to JsxLexer + ts: ['JavascriptLexer'], + jsx: ['JsxLexer'], + tsx: ['JsxLexer'], - default: ["JavascriptLexer"], + default: ['JavascriptLexer'], }, - lineEnding: "auto", + lineEnding: 'auto', // Control the line ending. See options at https://github.com/ryanve/eol - locales: ["en", "am", "es", "fr", "km", "he"], + locales: ['en', 'am', 'es', 'fr', 'km', 'he'], // An array of the locales in your applications - namespaceSeparator: ":", + namespaceSeparator: ':', // Namespace separator used in your translation keys // If you want to use plain english keys, separators such as `.` and `:` will conflict. You might want to set `keySeparator: false` and `namespaceSeparator: false`. That way, `t('Status: Loading...')` will not think that there are a namespace and three separator dots for instance. - output: "$NAMESPACE/$LOCALE.json", + output: '$NAMESPACE/$LOCALE.json', // Supports $LOCALE and $NAMESPACE injection // Supports JSON (.json) and YAML (.yml) file formats // Where to write the locale files relative to process.cwd() - pluralSeparator: "_", + pluralSeparator: '_', // Plural separator used in your translation keys // If you want to use plain english keys, separators such as `_` might conflict. You might want to set `pluralSeparator` to a different string that does not occur in your keys. diff --git a/jest.config.js b/jest.config.js index 6f47d0c..ddb8cff 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,17 +1,19 @@ +/** @type {import('jest').Config} */ module.exports = { transform: { - "^.+\\.tsx?$": ["@swc/jest"], + '^.+\\.tsx?$': ['@swc/jest'], }, - transformIgnorePatterns: ["/node_modules/(?!@openmrs)"], + transformIgnorePatterns: ['/node_modules/(?!@openmrs)'], moduleNameMapper: { - "^@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", - "\\.(s?css)$": "identity-obj-proxy", - "^lodash-es/(.*)$": "lodash/$1", - "^dexie$": require.resolve("dexie"), + '^@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', + '\\.(s?css)$': 'identity-obj-proxy', + '^lodash-es/(.*)$': 'lodash/$1', + '^dexie$': '/node_modules/dexie', + '^react-i18next$': '/__mocks__/react-i18next.js', }, - setupFilesAfterEnv: ["/src/setup-tests.ts"], - testEnvironment: "jsdom", - testPathIgnorePatterns: ["/e2e"], + setupFilesAfterEnv: ['/src/setup-tests.ts'], + testEnvironment: 'jsdom', + testPathIgnorePatterns: ['/e2e'], }; diff --git a/package.json b/package.json index 50212b7..a07b25e 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "browser": "dist/openmrs-esm-form-builder-app.js", "main": "src/index.ts", "source": true, + "lint-staged": { + "*.{js,jsx,ts,tsx}": "eslint --cache --fix" + }, "scripts": { "start": "openmrs develop", "serve": "webpack serve --mode=development", "build": "webpack --mode production", "analyze": "webpack --mode=production --env.analyze=true", - "lint": "TIMING=1 eslint src --ext js,jsx,ts,tsx", + "lint": "TIMING=1 eslint src --ext js,jsx,ts,tsx --max-warnings=0", "prettier": "prettier --write \"src/**/*.{ts,tsx}\"", "typescript": "tsc", "test": "jest --config jest.config.js", @@ -32,7 +35,6 @@ "keywords": [ "openmrs", "microfrontends", - "formbuilder", "form builder" ], "repository": { @@ -47,13 +49,13 @@ "url": "https://github.com/openmrs/openmrs-esm-form-builder/issues" }, "dependencies": { - "@carbon/react": "^1.31.0", + "@carbon/react": "^1.38.0", "@openmrs/openmrs-form-engine-lib": "next", - "dotenv": "^16.1.4", + "dotenv": "^16.3.1", "file-loader": "^6.2.0", "lodash-es": "^4.17.21", "react-ace": "^10.1.0", - "sass": "^1.63.3" + "sass": "^1.67.0" }, "peerDependencies": { "@openmrs/esm-framework": "*", @@ -69,44 +71,45 @@ "@dnd-kit/utilities": "^3.2.1", "@openmrs/esm-framework": "next", "@openmrs/esm-styleguide": "next", - "@playwright/test": "^1.35.0", + "@playwright/test": "^1.38.0", "@swc/cli": "^0.1.62", - "@swc/core": "^1.3.62", - "@swc/jest": "^0.2.26", - "@testing-library/dom": "^8.20.0", - "@testing-library/jest-dom": "^5.16.5", - "@testing-library/react": "^13.4.0", - "@testing-library/user-event": "^14.4.3", - "@types/jest": "^29.5.2", - "@types/react": "^18.2.12", - "@types/react-dom": "^18.2.5", + "@swc/core": "^1.3.85", + "@swc/jest": "^0.2.29", + "@testing-library/dom": "^9.3.1", + "@testing-library/jest-dom": "^6.1.3", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.5.1", + "@types/jest": "^29.5.5", + "@types/react": "^18.2.21", + "@types/react-dom": "^18.2.7", "@types/webpack-env": "^1.18.1", - "@typescript-eslint/eslint-plugin": "^5.59.11", - "@typescript-eslint/parser": "^5.59.11", + "@typescript-eslint/eslint-plugin": "^6.7.0", + "@typescript-eslint/parser": "^6.7.0", "css-loader": "^6.8.1", - "eslint": "^8.42.0", - "eslint-config-prettier": "^8.8.0", + "eslint": "^8.49.0", + "eslint-config-prettier": "^9.0.0", "eslint-config-ts-react-important-stuff": "^3.0.0", - "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-playwright": "^0.16.0", + "eslint-plugin-prettier": "^5.0.0", "husky": "^8.0.3", - "i18next": "^19.9.2", - "i18next-parser": "^5.4.0", + "i18next": "^23.5.1", + "i18next-parser": "^8.7.0", "identity-obj-proxy": "^3.0.0", - "jest": "^29.5.0", - "jest-cli": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", + "jest": "^29.7.0", + "jest-cli": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "lint-staged": "^14.0.1", "openmrs": "next", - "prettier": "^2.8.8", - "pretty-quick": "^3.1.3", + "prettier": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-error-boundary": "^4.0.10", - "react-i18next": "^11.18.6", + "react-error-boundary": "^4.0.11", + "react-i18next": "^13.2.2", "rxjs": "^7.8.1", "swc-loader": "^0.2.3", - "turbo": "^1.10.3", + "turbo": "^1.10.14", "typescript": "^4.9.5", - "webpack": "^5.86.0", + "webpack": "^5.88.2", "webpack-cli": "^5.1.4" }, "packageManager": "yarn@3.6.0" diff --git a/playwright.config.ts b/playwright.config.ts index 215e136..2b9fb81 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,33 +1,34 @@ -import { devices, PlaywrightTestConfig } from "@playwright/test"; -import * as dotenv from "dotenv"; +import type { PlaywrightTestConfig } from '@playwright/test'; +import { devices } from '@playwright/test'; +import * as dotenv from 'dotenv'; dotenv.config(); // See https://playwright.dev/docs/test-configuration. const config: PlaywrightTestConfig = { - testDir: "./e2e/specs", + testDir: './e2e/specs', timeout: 3 * 60 * 1000, expect: { timeout: 20 * 1000, }, fullyParallel: true, - outputDir: "../test-results/results", + outputDir: '../test-results/results', forbidOnly: !!process.env.CI, retries: 0, reporter: process.env.CI - ? [["junit", { outputFile: "results.xml" }], ["html"]] - : [["html", { outputFolder: "../test-results/report" }]], - globalSetup: require.resolve("./e2e/core/global-setup"), + ? [['junit', { outputFile: 'results.xml' }], ['html']] + : [['html', { outputFolder: '../test-results/report' }]], + globalSetup: require.resolve('./e2e/core/global-setup'), use: { baseURL: `${process.env.E2E_BASE_URL}/spa/`, - locale: "en-US", - storageState: "e2e/storageState.json", - video: "retain-on-failure", + locale: 'en-US', + storageState: 'e2e/storageState.json', + video: 'retain-on-failure', }, projects: [ { - name: "chromium", + name: 'chromium', use: { - ...devices["Desktop Chrome"], + ...devices['Desktop Chrome'], }, }, ], diff --git a/src/components/action-buttons/action-buttons.component.tsx b/src/components/action-buttons/action-buttons.component.tsx index 57ac4f9..22bbdc8 100644 --- a/src/components/action-buttons/action-buttons.component.tsx +++ b/src/components/action-buttons/action-buttons.component.tsx @@ -1,39 +1,26 @@ -import React, { useState } from "react"; -import { - Button, - ComposedModal, - InlineLoading, - ModalBody, - ModalFooter, - ModalHeader, -} from "@carbon/react"; -import { useParams } from "react-router-dom"; -import { showToast, showNotification } from "@openmrs/esm-framework"; - -import type { TFunction } from "react-i18next"; -import type { RouteParams, Schema } from "../../types"; -import { publishForm, unpublishForm } from "../../forms.resource"; -import { useForm } from "../../hooks/useForm"; -import SaveFormModal from "../modals/save-form-modal.component"; -import styles from "./action-buttons.scss"; - -type ActionButtonsProps = { +import React, { useState } from 'react'; +import { Button, ComposedModal, InlineLoading, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import type { TFunction } from 'i18next'; +import { useParams } from 'react-router-dom'; +import { showToast, showNotification } from '@openmrs/esm-framework'; + +import type { Schema } from '../../types'; +import { publishForm, unpublishForm } from '../../forms.resource'; +import { useForm } from '../../hooks/useForm'; +import SaveFormModal from '../modals/save-form-modal.component'; +import styles from './action-buttons.scss'; + +interface ActionButtonsProps { schema: Schema; t: TFunction; -}; +} -type Status = - | "idle" - | "publishing" - | "published" - | "unpublishing" - | "unpublished" - | "error"; +type Status = 'idle' | 'publishing' | 'published' | 'unpublishing' | 'unpublished' | 'error'; function ActionButtons({ schema, t }: ActionButtonsProps) { - const { formUuid } = useParams(); + const { formUuid } = useParams<{ formUuid?: string }>(); const { form, mutate } = useForm(formUuid); - const [status, setStatus] = useState("idle"); + const [status, setStatus] = useState('idle'); const [showUnpublishModal, setShowUnpublishModal] = useState(false); const launchUnpublishModal = () => { @@ -41,56 +28,57 @@ function ActionButtons({ schema, t }: ActionButtonsProps) { }; async function handlePublish() { - setStatus("publishing"); + setStatus('publishing'); try { await publishForm(form.uuid); showToast({ - title: t("formPublished", "Form published"), - kind: "success", + title: t('formPublished', 'Form published'), + kind: 'success', critical: true, - description: - `${form.name} ` + - t("formPublishedSuccessfully", "form was published successfully"), + description: `${form.name} ` + t('formPublishedSuccessfully', 'form was published successfully'), }); - setStatus("published"); - mutate(); + setStatus('published'); + await mutate(); } catch (error) { - showNotification({ - title: t("errorPublishingForm", "Error publishing form"), - kind: "error", - critical: true, - description: error?.message, - }); - setStatus("error"); + if (error instanceof Error) { + showNotification({ + title: t('errorPublishingForm', 'Error publishing form'), + kind: 'error', + critical: true, + description: error?.message, + }); + setStatus('error'); + } } } async function handleUnpublish() { - setStatus("unpublishing"); + setStatus('unpublishing'); + try { await unpublishForm(form.uuid); showToast({ - title: t("formUnpublished", "Form unpublished"), - kind: "success", + title: t('formUnpublished', 'Form unpublished'), + kind: 'success', critical: true, - description: - `${form.name} ` + - t("formUnpublishedSuccessfully", "form was unpublished successfully"), + description: `${form.name} ` + t('formUnpublishedSuccessfully', 'form was unpublished successfully'), }); - setStatus("unpublished"); - mutate(); + setStatus('unpublished'); + await mutate(); } catch (error) { - showNotification({ - title: t("errorUnpublishingForm", "Error unpublishing form"), - kind: "error", - critical: true, - description: error?.message, - }); - setStatus("error"); + if (error instanceof Error) { + showNotification({ + title: t('errorUnpublishingForm', 'Error unpublishing form'), + kind: 'error', + critical: true, + description: error?.message, + }); + setStatus('error'); + } } setShowUnpublishModal(false); } @@ -101,71 +89,47 @@ function ActionButtons({ schema, t }: ActionButtonsProps) { <> {form && !form.published ? ( - ) : null} {form && form.published ? ( - ) : null} {showUnpublishModal ? ( - setShowUnpublishModal(false)} - preventCloseOnClickOutside - > + setShowUnpublishModal(false)} preventCloseOnClickOutside>

{t( - "unpublishExplainerText", - "Unpublishing a form means you can no longer access it from your frontend. Unpublishing forms does not delete their associated schemas, it only affects whether or not you can access them in your frontend." + 'unpublishExplainerText', + 'Unpublishing a form means you can no longer access it from your frontend. Unpublishing forms does not delete their associated schemas, it only affects whether or not you can access them in your frontend.', )}

- diff --git a/src/components/dashboard/dashboard.component.tsx b/src/components/dashboard/dashboard.component.tsx index d57e738..69cc692 100644 --- a/src/components/dashboard/dashboard.component.tsx +++ b/src/components/dashboard/dashboard.component.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useMemo, useState } from "react"; -import { TFunction, useTranslation } from "react-i18next"; +import React, { useCallback, useMemo, useState } from 'react'; +import type { TFunction } from 'i18next'; +import { useTranslation } from 'react-i18next'; import { Button, ComposedModal, @@ -24,33 +25,27 @@ import { TableToolbarSearch, Tag, Tile, -} from "@carbon/react"; +} from '@carbon/react'; +import { Add, DocumentImport, Download, Edit, TrashCan } from '@carbon/react/icons'; import { - Add, - DocumentImport, - Download, - Edit, - TrashCan, -} from "@carbon/react/icons"; -import { - FetchResponse, + type FetchResponse, navigate, showNotification, showToast, useConfig, useLayoutType, usePagination, -} from "@openmrs/esm-framework"; -import type { KeyedMutator } from "swr"; - -import type { Form as FormType } 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 styles from "./dashboard.scss"; +} from '@openmrs/esm-framework'; +import type { KeyedMutator } from 'swr'; + +import type { Form as FormType } 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 styles from './dashboard.scss'; type Mutator = KeyedMutator<{ data: { @@ -58,44 +53,39 @@ type Mutator = KeyedMutator<{ }; }>; -type ActionButtonsProps = { +interface ActionButtonsProps { form: FormType; mutate: Mutator; responsiveSize: string; t: TFunction; -}; +} -type FormsListProps = { +interface FormsListProps { forms: Array; isValidating: boolean; mutate: Mutator; t: TFunction; -}; +} -function CustomTag({ condition }: { condition: boolean }) { +function CustomTag({ condition }: { condition?: boolean }) { const { t } = useTranslation(); if (condition) { return ( - {t("yes", "Yes")} + {t('yes', 'Yes')} ); } return ( - {t("no", "No")} + {t('no', 'No')} ); } -function ActionButtons({ - form, - mutate, - responsiveSize, - t, -}: ActionButtonsProps) { +function ActionButtons({ form, mutate, responsiveSize, t }: ActionButtonsProps) { const { clobdata } = useClobdata(form); const formResources = form?.resources; const [showDeleteFormModal, setShowDeleteFormModal] = useState(false); @@ -104,51 +94,47 @@ function ActionButtons({ const downloadableSchema = useMemo( () => new Blob([JSON.stringify(clobdata, null, 2)], { - type: "application/json", + type: 'application/json', }), - [clobdata] + [clobdata], ); const handleDeleteForm = useCallback( (formUuid: string) => { deleteForm(formUuid) - .then((res: FetchResponse) => { + .then(async (res: FetchResponse) => { if (res.status === 204) { showToast({ - title: t("formDeleted", "Form deleted"), - kind: "success", + title: t('formDeleted', 'Form deleted'), + kind: 'success', critical: true, - description: - `${form.name} ` + - t("formDeletedSuccessfully", "deleted successfully"), + description: `${form.name} ` + t('formDeletedSuccessfully', 'deleted successfully'), }); - mutate(); + await mutate(); setShowDeleteFormModal(false); } }) .catch((e: Error) => showNotification({ - title: t("errorDeletingForm", "Error deleting form"), - kind: "error", + title: t('errorDeletingForm', 'Error deleting form'), + kind: 'error', critical: true, description: e?.message, - }) + }), ) .finally(() => setIsDeleting(false)); }, - [form.name, mutate, t] + [form.name, mutate, t], ); const ImportButton = () => { return ( @@ -267,17 +239,17 @@ function ActionButtons({ )} - {setShowDeleteFormModal && } + {showDeleteFormModal && } ); } function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { const config = useConfig(); - const isTablet = useLayoutType() === "tablet"; - const responsiveSize = isTablet ? "lg" : "sm"; - const [filter, setFilter] = useState(""); - const [searchString, setSearchString] = useState(""); + const isTablet = useLayoutType() === 'tablet'; + const responsiveSize = isTablet ? 'lg' : 'sm'; + const [filter, setFilter] = useState(''); + const [searchString, setSearchString] = useState(''); const pageSize = 10; const filteredRows = useMemo(() => { @@ -285,11 +257,11 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { return forms; } - if (filter === "Published") { + if (filter === 'Published') { return forms.filter((form) => form.published); } - if (filter === "Unpublished") { + if (filter === 'Unpublished') { return forms.filter((form) => !form.published); } @@ -298,69 +270,53 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { const tableHeaders = [ { - header: t("name", "Name"), - key: "name", + header: t('name', 'Name'), + key: 'name', }, { - header: t("version", "Version"), - key: "version", + header: t('version', 'Version'), + key: 'version', }, { - header: t("published", "Published"), - key: "published", + header: t('published', 'Published'), + key: 'published', }, { - header: t("retired", "Retired"), - key: "retired", + header: t('retired', 'Retired'), + key: 'retired', }, { - header: t("schemaActions", "Schema actions"), - key: "actions", + header: t('schemaActions', 'Schema actions'), + key: 'actions', }, ]; const searchResults = useMemo(() => { - if (searchString && searchString.trim() !== "") { - return filteredRows.filter((form) => - form.name.toLowerCase().includes(searchString.toLowerCase()) - ); + if (searchString && searchString.trim() !== '') { + return filteredRows.filter((form) => form.name.toLowerCase().includes(searchString.toLowerCase())); } return filteredRows; }, [searchString, filteredRows]); - const { paginated, goTo, results, currentPage } = usePagination( - searchResults, - pageSize - ); + const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize); const tableRows = results?.map((form: FormType) => ({ ...form, id: form?.uuid, - published: , - retired: , - actions: ( - - ), + published: , + retired: , + actions: , })); - const handlePublishStatusChange = ({ - selectedItem, - }: { - selectedItem: string; - }) => setFilter(selectedItem); + const handlePublishStatusChange = ({ selectedItem }: { selectedItem: string }) => setFilter(selectedItem); const handleSearch = useCallback( - (e) => { + (e: React.ChangeEvent) => { goTo(1); setSearchString(e.target.value); }, - [goTo, setSearchString] + [goTo, setSearchString], ); return ( @@ -371,8 +327,8 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { kind="info" lowContrast title={t( - "schemaSaveWarningMessage", - "The dev3 server is ephemeral at best and can't be relied upon to save your schemas permanently. To avoid losing your work, please save your schemas to your local machine. Alternatively, upload your schema to the distro repo to have it persisted across server resets." + 'schemaSaveWarningMessage', + "The dev3 server is ephemeral at best and can't be relied upon to save your schemas permanently. To avoid losing your work, please save your schemas to your local machine. Alternatively, upload your schema to the distro repo to have it persisted across server resets.", )} /> )} @@ -380,14 +336,12 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) {
@@ -395,33 +349,22 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { {isValidating ? : null} - + {({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( <> - +
- + @@ -438,18 +381,13 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) { {headers.map((header) => ( - - {header.header} - + {header.header} ))} - {rows.map((row, i) => ( - + {rows.map((row) => ( + {row.cells.map((cell) => ( {cell.value} ))} @@ -462,15 +400,8 @@ function FormsList({ forms, isValidating, mutate, t }: FormsListProps) {
-

- {t( - "noMatchingFormsToDisplay", - "No matching forms to display" - )} -

-

- {t("checkFilters", "Check the filters above")} -

+

{t('noMatchingFormsToDisplay', 'No matching forms to display')}

+

{t('checkFilters', 'Check the filters above')}

@@ -499,7 +430,7 @@ const Dashboard: React.FC = () => { return (
-

{t("formBuilder", "Form Builder")}

+

{t('formBuilder', 'Form Builder')}

{(() => { if (error) { return ; @@ -513,14 +444,7 @@ const Dashboard: React.FC = () => { return ; } - return ( - - ); + return ; })()}
); diff --git a/src/components/dashboard/dashboard.test.tsx b/src/components/dashboard/dashboard.test.tsx index 53bcb35..4061520 100644 --- a/src/components/dashboard/dashboard.test.tsx +++ b/src/components/dashboard/dashboard.test.tsx @@ -1,43 +1,43 @@ -import React from "react"; -import { screen, waitFor } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { navigate, openmrsFetch, usePagination } from "@openmrs/esm-framework"; -import { renderWithSwr, waitForLoadingToFinish } from "../../test-helpers"; -import { deleteForm } from "../../forms.resource"; -import Dashboard from "./dashboard.component"; +import React from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { navigate, openmrsFetch, usePagination } from '@openmrs/esm-framework'; +import { renderWithSwr, waitForLoadingToFinish } from '../../test-helpers'; +import { deleteForm } from '../../forms.resource'; +import Dashboard from './dashboard.component'; const mockedOpenmrsFetch = openmrsFetch as jest.Mock; const mockedDeleteForm = deleteForm as jest.Mock; -jest.mock("../../forms.resource", () => ({ +jest.mock('../../forms.resource', () => ({ deleteForm: jest.fn(), })); const mockUsePagination = usePagination as jest.Mock; const formsResponse = [ { - uuid: "2ddde996-b1c3-37f1-a53e-378dd1a4f6b5", - name: "Test Form 1", + uuid: '2ddde996-b1c3-37f1-a53e-378dd1a4f6b5', + name: 'Test Form 1', encounterType: { - uuid: "dd528487-82a5-4082-9c72-ed246bd49591", - name: "Consultation", + uuid: 'dd528487-82a5-4082-9c72-ed246bd49591', + name: 'Consultation', }, - version: "1", + version: '1', published: true, retired: false, resources: [ { - dataType: "AmpathJsonSchema", - name: "JSON schema", - uuid: "26e45c1a-a46d-4f69-af0a-c29baaed5b3e", - valueReference: "9c35c3d7-1366-45ef-b4d7-ae635b22b6a7", + dataType: 'AmpathJsonSchema', + name: 'JSON schema', + uuid: '26e45c1a-a46d-4f69-af0a-c29baaed5b3e', + valueReference: '9c35c3d7-1366-45ef-b4d7-ae635b22b6a7', }, ], }, ]; -jest.mock("@openmrs/esm-framework", () => { - const originalModule = jest.requireActual("@openmrs/esm-framework"); +jest.mock('@openmrs/esm-framework', () => { + const originalModule = jest.requireActual('@openmrs/esm-framework'); return { ...originalModule, @@ -50,26 +50,22 @@ jest.mock("@openmrs/esm-framework", () => { }; }); -describe("Dashboard", () => { - it("renders an empty state view if no forms are available", async () => { +describe('Dashboard', () => { + it('renders an empty state view if no forms are available', async () => { mockedOpenmrsFetch.mockReturnValueOnce({ data: { results: [] } }); renderDashboard(); await waitForLoadingToFinish(); - expect( - screen.getByRole("heading", { name: /form builder/i }) - ).toBeInTheDocument(); - expect(screen.getByRole("heading", { name: /forms/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /form builder/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /forms/i })).toBeInTheDocument(); expect(screen.getByTitle(/empty data illustration/i)).toBeInTheDocument(); - expect( - screen.getByText(/there are no forms to display/i) - ).toBeInTheDocument(); + expect(screen.getByText(/there are no forms to display/i)).toBeInTheDocument(); expect(screen.getByText(/create a new form/i)).toBeInTheDocument(); }); - it("searches for a form by name and filters the list of forms", async () => { + it('searches for a form by name and filters the list of forms', async () => { const user = userEvent.setup(); mockedOpenmrsFetch.mockReturnValueOnce({ @@ -82,11 +78,11 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const searchbox = screen.getByRole("searchbox") as HTMLInputElement; + const searchbox = screen.getByRole('searchbox') as HTMLInputElement; - await waitFor(() => user.type(searchbox, "COVID")); + await waitFor(() => user.type(searchbox, 'COVID')); - expect(searchbox.value).toBe("COVID"); + expect(searchbox.value).toBe('COVID'); mockUsePagination.mockImplementation(() => ({ currentPage: 1, @@ -94,12 +90,8 @@ describe("Dashboard", () => { results: formsResponse.filter((form) => form.name === searchbox.value), })); - await waitFor(() => - expect(screen.queryByText(/Test Form 1/i)).not.toBeInTheDocument() - ); - expect( - screen.getByText(/no matching forms to display/i) - ).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText(/Test Form 1/i)).not.toBeInTheDocument()); + expect(screen.getByText(/no matching forms to display/i)).toBeInTheDocument(); }); it('filters the list of forms by "published" status', async () => { @@ -115,14 +107,12 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const publishStatusFilter = screen.getByRole("combobox", { + const publishStatusFilter = screen.getByRole('combobox', { name: /filter by publish status/i, }); await waitFor(() => user.click(publishStatusFilter)); - await waitFor(() => - user.click(screen.getByRole("option", { name: /unpublished/i })) - ); + await waitFor(() => user.click(screen.getByRole('option', { name: /unpublished/i }))); mockUsePagination.mockImplementation(() => ({ currentPage: 1, @@ -131,12 +121,10 @@ describe("Dashboard", () => { })); expect(screen.queryByText(/Test Form 1/i)).not.toBeInTheDocument(); - expect( - screen.getByText(/no matching forms to display/i) - ).toBeInTheDocument(); + expect(screen.getByText(/no matching forms to display/i)).toBeInTheDocument(); }); - it("renders a list of forms fetched from the server", async () => { + it('renders a list of forms fetched from the server', async () => { mockedOpenmrsFetch.mockReturnValueOnce({ data: { results: formsResponse, @@ -153,25 +141,13 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - expect( - screen.getByRole("heading", { name: /form builder/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("combobox", { name: /filter by publish status/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /create a new form/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /edit schema/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /download schema/i }) - ).toBeInTheDocument(); - expect( - screen.getByRole("searchbox", { name: /filter table/i }) - ).toBeInTheDocument(); - expect(screen.queryByRole("table")).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /form builder/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /filter by publish status/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /create a new form/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit schema/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /download schema/i })).toBeInTheDocument(); + expect(screen.getByRole('searchbox', { name: /filter table/i })).toBeInTheDocument(); + expect(screen.queryByRole('table')).toBeInTheDocument(); expect(screen.getByText(/Test Form 1/i)).toBeInTheDocument(); }); @@ -194,7 +170,7 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const createFormButton = screen.getByRole("button", { + const createFormButton = screen.getByRole('button', { name: /create a new form/i, }); @@ -224,7 +200,7 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const editSchemaButton = screen.getByRole("button", { + const editSchemaButton = screen.getByRole('button', { name: /edit schema/i, }); @@ -253,7 +229,7 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const downloadSchemaButton = screen.getByRole("button", { + const downloadSchemaButton = screen.getByRole('button', { name: /download schema/i, }); @@ -282,25 +258,19 @@ describe("Dashboard", () => { await waitForLoadingToFinish(); - const deleteButton = screen.getByRole("button", { name: /delete schema/i }); + const deleteButton = screen.getByRole('button', { name: /delete schema/i }); expect(deleteButton).toBeInTheDocument(); await waitFor(() => user.click(deleteButton)); - const modal = screen.getByRole("presentation"); + const modal = screen.getByRole('presentation'); expect(modal).toBeInTheDocument(); expect(modal).toHaveTextContent(/delete form/i); - expect(modal).toHaveTextContent( - /are you sure you want to delete this form?/i - ); - expect(screen.getByRole("button", { name: /cancel/i })).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /danger delete/i }) - ).toBeInTheDocument(); - - await waitFor(() => - user.click(screen.getByRole("button", { name: /danger delete/i })) - ); + expect(modal).toHaveTextContent(/are you sure you want to delete this form?/i); + expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /danger delete/i })).toBeInTheDocument(); + + await waitFor(() => user.click(screen.getByRole('button', { name: /danger delete/i }))); }); }); diff --git a/src/components/empty-state/empty-data-illustration.component.tsx b/src/components/empty-state/empty-data-illustration.component.tsx index 56a83eb..e594a32 100644 --- a/src/components/empty-state/empty-data-illustration.component.tsx +++ b/src/components/empty-state/empty-data-illustration.component.tsx @@ -1,6 +1,6 @@ -import React from "react"; +import React from 'react'; -export const EmptyDataIllustration = ({ width = "64", height = "64" }) => { +export const EmptyDataIllustration = ({ width = '64', height = '64' }) => { return ( Empty data illustration @@ -18,13 +18,7 @@ export const EmptyDataIllustration = ({ width = "64", height = "64" }) => { fill="#C6C6C6" /> - + { fill="#C6C6C6" /> - + ); diff --git a/src/components/empty-state/empty-state.component.tsx b/src/components/empty-state/empty-state.component.tsx index 50ca166..e18f12f 100644 --- a/src/components/empty-state/empty-state.component.tsx +++ b/src/components/empty-state/empty-state.component.tsx @@ -1,27 +1,23 @@ -import React from "react"; -import { Layer, Link, Tile } from "@carbon/react"; -import { useTranslation } from "react-i18next"; -import { navigate, useLayoutType } from "@openmrs/esm-framework"; +import React from 'react'; +import { Layer, Link, Tile } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { navigate, useLayoutType } from '@openmrs/esm-framework'; -import { EmptyDataIllustration } from "./empty-data-illustration.component"; -import styles from "./empty-state.scss"; +import { EmptyDataIllustration } from './empty-data-illustration.component'; +import styles from './empty-state.scss'; function EmptyState() { const { t } = useTranslation(); - const isTablet = useLayoutType() === "tablet"; + const isTablet = useLayoutType() === 'tablet'; return ( -
-

{t("forms", "Forms")}

+
+

{t('forms', 'Forms')}

-

- {t("noFormsToDisplay", "There are no forms to display.")} -

+

{t('noFormsToDisplay', 'There are no forms to display.')}

@@ -30,7 +26,7 @@ function EmptyState() { }) } > - {t("createNewForm", "Create a new form")} + {t('createNewForm', 'Create a new form')}

diff --git a/src/components/error-state/error-state.component.tsx b/src/components/error-state/error-state.component.tsx index 3a4cf09..ea10793 100644 --- a/src/components/error-state/error-state.component.tsx +++ b/src/components/error-state/error-state.component.tsx @@ -1,8 +1,8 @@ -import React from "react"; -import { Layer, Tile } from "@carbon/react"; -import { useTranslation } from "react-i18next"; -import { useLayoutType } from "@openmrs/esm-framework"; -import styles from "./error-state.scss"; +import React from 'react'; +import { Layer, Tile } from '@carbon/react'; +import { useTranslation } from 'react-i18next'; +import { useLayoutType } from '@openmrs/esm-framework'; +import styles from './error-state.scss'; interface ErrorStateProps { error: Error; @@ -10,23 +10,21 @@ interface ErrorStateProps { const ErrorState: React.FC = ({ error }) => { const { t } = useTranslation(); - const isTablet = useLayoutType() === "tablet"; + const isTablet = useLayoutType() === 'tablet'; return ( -
-

{t("forms", "Forms")}

+
+

{t('forms', 'Forms')}

- {t("error", "Error")}: {error?.message} + {t('error', 'Error')}: {error?.message}

{t( - "errorCopy", - "Sorry, there was a problem displaying this information. You can try to reload this page, or contact the site administrator and quote the error code above." + 'errorCopy', + 'Sorry, there was a problem displaying this information. You can try to reload this page, or contact the site administrator and quote the error code above.', )}

diff --git a/src/components/form-editor/form-editor.component.tsx b/src/components/form-editor/form-editor.component.tsx index 0881436..b197970 100644 --- a/src/components/form-editor/form-editor.component.tsx +++ b/src/components/form-editor/form-editor.component.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Button, Column, @@ -16,33 +16,33 @@ import { TabList, TabPanels, TabPanel, -} from "@carbon/react"; -import { Download } from "@carbon/react/icons"; -import { useParams } from "react-router-dom"; -import { useTranslation } from "react-i18next"; -import { ExtensionSlot } from "@openmrs/esm-framework"; -import type { OHRIFormSchema } from "@openmrs/openmrs-form-engine-lib"; -import type { Schema, RouteParams } from "../../types"; -import { useClobdata } from "../../hooks/useClobdata"; -import { useForm } from "../../hooks/useForm"; -import ActionButtons from "../action-buttons/action-buttons.component"; -import FormRenderer from "../form-renderer/form-renderer.component"; -import InteractiveBuilder from "../interactive-builder/interactive-builder.component"; -import SchemaEditor from "../schema-editor/schema-editor.component"; -import styles from "./form-editor.scss"; +} from '@carbon/react'; +import { Download } from '@carbon/react/icons'; +import { useParams } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ExtensionSlot } from '@openmrs/esm-framework'; +import type { OHRIFormSchema } from '@openmrs/openmrs-form-engine-lib'; +import type { Schema } from '../../types'; +import { useClobdata } from '../../hooks/useClobdata'; +import { useForm } from '../../hooks/useForm'; +import ActionButtons from '../action-buttons/action-buttons.component'; +import FormRenderer from '../form-renderer/form-renderer.component'; +import InteractiveBuilder from '../interactive-builder/interactive-builder.component'; +import SchemaEditor from '../schema-editor/schema-editor.component'; +import styles from './form-editor.scss'; -type ErrorProps = { +interface ErrorProps { error: Error; title: string; -}; +} -type Status = "idle" | "formLoaded" | "schemaLoaded"; +type Status = 'idle' | 'formLoaded' | 'schemaLoaded'; -const Error = ({ error, title }: ErrorProps) => { +const ErrorNotification = ({ error, title }: ErrorProps) => { return ( { const FormEditor: React.FC = () => { const { t } = useTranslation(); - const { formUuid } = useParams(); + const { formUuid } = useParams<{ formUuid: string }>(); const isNewSchema = !formUuid; const [schema, setSchema] = useState(); const [showDraftSchemaModal, setShowDraftSchemaModal] = useState(false); const { form, formError, isLoadingForm } = useForm(formUuid); const { clobdata, clobdataError, isLoadingClobdata } = useClobdata(form); - const [status, setStatus] = useState("idle"); - const [stringifiedSchema, setStringifiedSchema] = useState( - schema ? JSON.stringify(schema, null, 2) : "" - ); - const [copied, setCopied] = useState(false); + const [status, setStatus] = useState('idle'); + const [stringifiedSchema, setStringifiedSchema] = useState(schema ? JSON.stringify(schema, null, 2) : ''); - const isLoadingFormOrSchema = - formUuid && (isLoadingClobdata || isLoadingForm); + const isLoadingFormOrSchema = Boolean(formUuid) && (isLoadingClobdata || isLoadingForm); useEffect(() => { if (formUuid) { if (form && Object.keys(form).length > 0) { - setStatus("formLoaded"); + setStatus('formLoaded'); } - if ( - status === "formLoaded" && - !isLoadingClobdata && - clobdata === undefined - ) { + if (status === 'formLoaded' && !isLoadingClobdata && clobdata === undefined) { setShowDraftSchemaModal(true); } if (clobdata && Object.keys(clobdata).length > 0) { - setStatus("schemaLoaded"); + setStatus('schemaLoaded'); setSchema(clobdata); - localStorage.setItem("formJSON", JSON.stringify(clobdata)); + localStorage.setItem('formJSON', JSON.stringify(clobdata)); } } - }, [ - clobdata, - form, - formUuid, - isLoadingClobdata, - isLoadingFormOrSchema, - status, - ]); + }, [clobdata, form, formUuid, isLoadingClobdata, isLoadingFormOrSchema, status]); useEffect(() => { setStringifiedSchema(JSON.stringify(schema, null, 2)); @@ -102,76 +87,82 @@ const FormEditor: React.FC = () => { const handleLoadDraftSchema = useCallback(() => { setShowDraftSchemaModal(false); - const draftSchema = localStorage.getItem("formJSON"); - setSchema(JSON.parse(draftSchema)); + + try { + const draftSchema = localStorage.getItem('formJSON'); + if (draftSchema) { + setSchema(JSON.parse(draftSchema) as Schema); + } + } catch (e) { + console.error('Error fetching draft schema from localStorage: ', e?.message); + } }, []); const handleSchemaChange = useCallback((updatedSchema: string) => { setStringifiedSchema(updatedSchema); }, []); - const updateSchema = useCallback((updatedSchema) => { + const updateSchema = useCallback((updatedSchema: Schema) => { setSchema(updatedSchema); - localStorage.setItem("formJSON", JSON.stringify(updatedSchema)); + localStorage.setItem('formJSON', JSON.stringify(updatedSchema)); }, []); const inputDummySchema = useCallback(() => { const dummySchema: OHRIFormSchema = { - encounterType: "", - name: "Sample Form", - processor: "EncounterFormProcessor", + encounterType: '', + name: 'Sample Form', + processor: 'EncounterFormProcessor', referencedForms: [], - uuid: "", - version: "1.0", + uuid: '', + version: '1.0', pages: [ { - label: "First Page", + label: 'First Page', sections: [ { - label: "A Section", - isExpanded: "true", + label: 'A Section', + isExpanded: 'true', questions: [ { - label: "A Question of type obs that renders a text input", - type: "obs", + id: 'sampleQuestion', + label: 'A Question of type obs that renders a text input', + type: 'obs', questionOptions: { - rendering: "text", - concept: "a-system-defined-concept-uuid", + rendering: 'text', + concept: 'a-system-defined-concept-uuid', }, - id: "sampleQuestion", }, ], }, { - label: "Another Section", - isExpanded: "true", + label: 'Another Section', + isExpanded: 'true', questions: [ { - label: - "Another Question of type obs whose answers get rendered as radio inputs", - type: "obs", + id: 'anotherSampleQuestion', + label: 'Another Question of type obs whose answers get rendered as radio inputs', + type: 'obs', questionOptions: { - rendering: "radio", - concept: "system-defined-concept-uuid", + rendering: 'radio', + concept: 'system-defined-concept-uuid', answers: [ { - concept: "another-system-defined-concept-uuid", - label: "Choice 1", + concept: 'another-system-defined-concept-uuid', + label: 'Choice 1', conceptMappings: [], }, { - concept: "yet-another-system-defined-concept-uuid", - label: "Choice 2", + concept: 'yet-another-system-defined-concept-uuid', + label: 'Choice 2', conceptMappings: [], }, { - concept: "yet-one-more-system-defined-concept-uuid", - label: "Choice 3", + concept: 'yet-one-more-system-defined-concept-uuid', + label: 'Choice 3', conceptMappings: [], }, ], }, - id: "anotherSampleQuestion", }, ], }, @@ -184,10 +175,10 @@ const FormEditor: React.FC = () => { updateSchema({ ...dummySchema }); }, [updateSchema]); - const [invalidJsonErrorMessage, setInvalidJsonErrorMessage] = useState(""); + const [invalidJsonErrorMessage, setInvalidJsonErrorMessage] = useState(''); const resetErrorMessage = useCallback(() => { - setInvalidJsonErrorMessage(""); + setInvalidJsonErrorMessage(''); }, []); const renderSchemaChanges = useCallback(() => { @@ -197,8 +188,10 @@ const FormEditor: React.FC = () => { const parsedJson: Schema = JSON.parse(stringifiedSchema); updateSchema(parsedJson); setStringifiedSchema(JSON.stringify(parsedJson, null, 2)); - } catch (error) { - setInvalidJsonErrorMessage(error.message); + } catch (e) { + if (e instanceof Error) { + setInvalidJsonErrorMessage(e.message); + } } }, [stringifiedSchema, updateSchema, resetErrorMessage]); @@ -209,26 +202,23 @@ const FormEditor: React.FC = () => { onClose={() => setShowDraftSchemaModal(false)} preventCloseOnClickOutside > - -
event.preventDefault()}> + + event.preventDefault()}>

{t( - "schemaNotFoundText", - "The schema originally associated with this form could not be found. A draft schema was found saved in your browser's local storage. Would you like to load it instead?" + 'schemaNotFoundText', + "The schema originally associated with this form could not be found. A draft schema was found saved in your browser's local storage. Would you like to load it instead?", )}

- @@ -238,14 +228,13 @@ const FormEditor: React.FC = () => { const downloadableSchema = useMemo( () => new Blob([JSON.stringify(schema, null, 2)], { - type: "application/json", + type: 'application/json', }), - [schema] + [schema], ); - const handleCopySchema = useCallback(() => { - navigator.clipboard.writeText(stringifiedSchema); - setCopied(true); + const handleCopySchema = useCallback(async () => { + await navigator.clipboard.writeText(stringifiedSchema); }, [stringifiedSchema]); return ( @@ -259,45 +248,38 @@ const FormEditor: React.FC = () => {
{isLoadingFormOrSchema ? ( - + ) : null} {isNewSchema && !schema ? ( ) : null}
{formError ? ( - + ) : null} {clobdataError ? ( - + ) : null}
{ - {t("preview", "Preview")} - {t("interactiveBuilder", "Interactive Builder")} + {t('preview', 'Preview')} + {t('interactiveBuilder', 'Interactive Builder')} - + - + diff --git a/src/components/form-renderer/form-renderer.component.tsx b/src/components/form-renderer/form-renderer.component.tsx index 64f3082..158e5c9 100644 --- a/src/components/form-renderer/form-renderer.component.tsx +++ b/src/components/form-renderer/form-renderer.component.tsx @@ -1,56 +1,55 @@ -import React, { useEffect, useState } from "react"; -import { ErrorBoundary } from "react-error-boundary"; -import { useTranslation } from "react-i18next"; -import { Button, InlineLoading, Tile } from "@carbon/react"; -import { OHRIFormSchema, OHRIForm } from "@openmrs/openmrs-form-engine-lib"; -import styles from "./form-renderer.scss"; +import React, { useEffect, useState } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useTranslation } from 'react-i18next'; +import { Button, InlineLoading, Tile } from '@carbon/react'; +import { type OHRIFormSchema, OHRIForm } from '@openmrs/openmrs-form-engine-lib'; +import styles from './form-renderer.scss'; -type ErrorFallbackProps = { +interface ErrorFallbackProps { error: Error; resetErrorBoundary: () => void; -}; +} -type FormRendererProps = { +interface FormRendererProps { isLoading: boolean; onSchemaChange?: (schema: OHRIFormSchema) => void; schema: OHRIFormSchema; -}; +} const FormRenderer: React.FC = ({ isLoading, schema }) => { const { t } = useTranslation(); const dummySchema: OHRIFormSchema = { - encounterType: "", - name: "Test Form", + encounterType: '', + name: 'Test Form', pages: [ { - label: "Test Page", + label: 'Test Page', sections: [ { - label: "Test Section", - isExpanded: "true", + label: 'Test Section', + isExpanded: 'true', questions: [ { - label: "Test Question", - type: "obs", + label: 'Test Question', + type: 'obs', questionOptions: { - rendering: "text", - concept: "xxxx", + rendering: 'text', + concept: 'xxxx', }, - id: "testQuestion", + id: 'testQuestion', }, ], }, ], }, ], - processor: "EncounterFormProcessor", + processor: 'EncounterFormProcessor', referencedForms: [], - uuid: "xxx", + uuid: 'xxx', }; - const [schemaToRender, setSchemaToRender] = - useState(dummySchema); + const [schemaToRender, setSchemaToRender] = useState(dummySchema); useEffect(() => { if (schema) { @@ -61,10 +60,7 @@ const FormRenderer: React.FC = ({ isLoading, schema }) => { if (isLoading) { return (
- +
); } @@ -73,20 +69,18 @@ const FormRenderer: React.FC = ({ isLoading, schema }) => {
{!schema && ( -

- {t("noSchemaLoaded", "No schema loaded")} -

+

{t('noSchemaLoaded', 'No schema loaded')}

{t( - "formRendererHelperText", - "Load a form schema in the Schema Editor to the left to see it rendered here by the Form Engine." + 'formRendererHelperText', + 'Load a form schema in the Schema Editor to the left to see it rendered here by the Form Engine.', )}

)} {schema === schemaToRender && ( - + )}
@@ -97,17 +91,12 @@ function ErrorFallback({ error, resetErrorBoundary }: ErrorFallbackProps) { const { t } = useTranslation(); return ( -

- {t( - "problemLoadingPreview", - "There was a problem loading the schema preview" - )} -

+

{t('problemLoadingPreview', 'There was a problem loading the form preview')}

{error.message}

); diff --git a/src/components/interactive-builder/add-question-modal.component.tsx b/src/components/interactive-builder/add-question-modal.component.tsx index 4e9b0f4..e7062e7 100644 --- a/src/components/interactive-builder/add-question-modal.component.tsx +++ b/src/components/interactive-builder/add-question-modal.component.tsx @@ -1,5 +1,6 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import flattenDeep from 'lodash-es/flattenDeep'; import { Button, ComposedModal, @@ -21,23 +22,16 @@ import { Tag, TextInput, Tile, -} from "@carbon/react"; -import { ArrowUpRight } from "@carbon/react/icons"; -import flattenDeep from "lodash-es/flattenDeep"; -import { showNotification, showToast, useConfig } from "@openmrs/esm-framework"; -import { RenderType } from "@openmrs/openmrs-form-engine-lib"; +} from '@carbon/react'; +import { ArrowUpRight } from '@carbon/react/icons'; +import { showNotification, showToast, useConfig } from '@openmrs/esm-framework'; +import type { RenderType } from '@openmrs/openmrs-form-engine-lib'; -import type { - Answer, - Concept, - ConceptMapping, - Question, - Schema, -} from "../../types"; -import { useConceptLookup } from "../../hooks/useConceptLookup"; -import styles from "./question-modal.scss"; +import type { Answer, Concept, ConceptMapping, Question, Schema } from '../../types'; +import { useConceptLookup } from '../../hooks/useConceptLookup'; +import styles from './question-modal.scss'; -type AddQuestionModalProps = { +interface AddQuestionModalProps { onModalChange: (showModal: boolean) => void; onQuestionEdit: (question: Question) => void; onSchemaChange: (schema: Schema) => void; @@ -47,7 +41,16 @@ type AddQuestionModalProps = { schema: Schema; sectionIndex: number; showModal: boolean; -}; +} + +interface Config { + fieldTypes: Array; + questionTypes: Array; +} + +interface Item { + text: string; +} const AddQuestionModal: React.FC = ({ schema, @@ -60,43 +63,49 @@ const AddQuestionModal: React.FC = ({ onModalChange, }) => { const { t } = useTranslation(); - const { fieldTypes, questionTypes } = useConfig(); - const [max, setMax] = useState(""); - const [min, setMin] = useState(""); - const [questionLabel, setQuestionLabel] = useState(""); - const [questionType, setQuestionType] = useState(""); + const { fieldTypes, questionTypes }: Config = useConfig(); + + const [answers, setAnswers] = useState>([]); + const [conceptMappings, setConceptMappings] = useState>([]); + const [conceptToLookup, setConceptToLookup] = useState(''); + const [fieldType, setFieldType] = useState(null); const [isQuestionRequired, setIsQuestionRequired] = useState(false); - const [fieldType, setFieldType] = useState(null); - const [questionId, setQuestionId] = useState(""); - const [answers, setAnswers] = useState([]); - const [selectedConcept, setSelectedConcept] = useState(null); - const [conceptMappings, setConceptMappings] = useState([]); - const [rows, setRows] = useState(2); - const [conceptToLookup, setConceptToLookup] = useState(""); - const [selectedAnswers, setSelectedAnswers] = useState([]); + const [max, setMax] = useState(''); + const [min, setMin] = useState(''); + const [questionId, setQuestionId] = useState(''); + const [questionLabel, setQuestionLabel] = useState(''); + const [questionType, setQuestionType] = useState(null); + const [rows, setRows] = useState(''); + const [selectedConcept, setSelectedConcept] = useState(null); + const [selectedAnswers, setSelectedAnswers] = useState< + Array<{ + id: string; + text: string; + }> + >([]); + const { concepts, isLoadingConcepts } = useConceptLookup(conceptToLookup); - const handleConceptChange = (event: React.ChangeEvent) => - setConceptToLookup(event.target.value); + const handleConceptChange = (event: React.ChangeEvent) => setConceptToLookup(event.target.value); const handleConceptSelect = (concept: Concept) => { - setConceptToLookup(""); + setConceptToLookup(''); setSelectedConcept(concept); setAnswers( concept?.answers?.map((answer) => ({ concept: answer?.uuid, label: answer?.display, - })) + })), ); setConceptMappings( concept?.mappings?.map((conceptMapping) => { - const data = conceptMapping.display.split(": "); + const data = conceptMapping.display.split(': '); return { relationship: conceptMapping.conceptMapType.display, type: data[0], value: data[1], }; - }) + }), ); }; @@ -109,7 +118,7 @@ const AddQuestionModal: React.FC = ({ }); }); - const questionIds = flattenDeep(nestedIds); + const questionIds: Array = flattenDeep(nestedIds); return questionIds.includes(idToTest); }; @@ -121,9 +130,7 @@ const AddQuestionModal: React.FC = ({ const createQuestion = () => { try { - const computedQuestionId = `question${questionIndex + 1}Section${ - sectionIndex + 1 - }Page-${pageIndex + 1}`; + const computedQuestionId = `question${questionIndex + 1}Section${sectionIndex + 1}Page-${pageIndex + 1}`; schema.pages[pageIndex]?.sections?.[sectionIndex]?.questions?.push({ label: questionLabel, @@ -132,9 +139,9 @@ const AddQuestionModal: React.FC = ({ id: questionId ?? computedQuestionId, questionOptions: { rendering: fieldType, - concept: selectedConcept.uuid, + concept: selectedConcept?.uuid, conceptMappings: conceptMappings, - answers: selectedAnswers.map((answer) => ({ + answers: selectedAnswers?.map((answer) => ({ concept: answer.id, label: answer.text, })), @@ -145,8 +152,8 @@ const AddQuestionModal: React.FC = ({ onSchemaChange({ ...schema }); resetIndices(); - setQuestionLabel(""); - setQuestionId(""); + setQuestionLabel(''); + setQuestionId(''); setIsQuestionRequired(false); setQuestionType(null); setFieldType(null); @@ -156,62 +163,51 @@ const AddQuestionModal: React.FC = ({ setSelectedAnswers([]); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("questionCreated", "New question created"), + description: t('questionCreated', 'New question created'), }); } catch (error) { - showNotification({ - title: t("errorCreatingQuestion", "Error creating question"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorCreatingQuestion', 'Error creating question'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - -
event.preventDefault()} - > + onModalChange(false)} preventCloseOnClickOutside> + + event.preventDefault()}> - + setQuestionLabel(event.target.value)} + onChange={(event: React.ChangeEvent) => setQuestionLabel(event.target.value)} required /> { + 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.' + 'questionIdPlaceholder', + 'Enter a unique ID e.g. "anaesthesiaType" for a question asking about the type of anaesthesia.', )} required /> @@ -220,21 +216,21 @@ const AddQuestionModal: React.FC = ({ defaultSelected="optional" name="isQuestionRequired" legendText={t( - "isQuestionRequiredOrOptional", - "Is this question a required or optional field? Required fields must be answered before the form can be submitted." + 'isQuestionRequiredOrOptional', + 'Is this question a required or optional field? Required fields must be answered before the form can be submitted.', )} > setIsQuestionRequired(false)} value="optional" /> setIsQuestionRequired(true)} value="required" /> @@ -242,93 +238,72 @@ const AddQuestionModal: React.FC = ({ - {fieldType === "number" ? ( + {fieldType === 'number' ? ( <> setMin(event.target.value)} + value={min || ''} + onChange={(event: React.ChangeEvent) => setMin(event.target.value)} required /> setMax(event.target.value)} + value={max || ''} + onChange={(event: React.ChangeEvent) => setMax(event.target.value)} required /> - ) : fieldType === "textarea" ? ( + ) : fieldType === 'textarea' ? ( setRows(event.target.value)} + labelText={t('rows', 'Rows')} + value={rows || ''} + onChange={(event: React.ChangeEvent) => setRows(event.target.value)} required /> ) : null} - {fieldType !== "ui-select-extended" && ( + {fieldType !== 'ui-select-extended' && (
- {t( - "searchForBackingConcept", - "Search for a backing concept" - )} + {t('searchForBackingConcept', 'Search for a backing concept')} setSelectedConcept(null)} onChange={handleConceptChange} - placeholder={t( - "searchConcept", - "Search using a concept name or UUID" - )} + placeholder={t('searchConcept', 'Search using a concept name or UUID')} value={(() => { if (conceptToLookup) { return conceptToLookup; @@ -336,7 +311,7 @@ const AddQuestionModal: React.FC = ({ if (selectedConcept) { return selectedConcept.display; } - return ""; + return ''; })()} required /> @@ -344,12 +319,9 @@ const AddQuestionModal: React.FC = ({ if (!conceptToLookup) return null; if (isLoadingConcepts) return ( - + ); - if (concepts && concepts?.length && !isLoadingConcepts) { + if (concepts?.length && !isLoadingConcepts) { return (
)} - {conceptMappings && conceptMappings.length ? ( + {conceptMappings?.length ? ( - - {t("mappings", "Mappings")} - + {t('mappings', 'Mappings')} - - - + + + @@ -428,31 +388,33 @@ const AddQuestionModal: React.FC = ({ ) : null} - {answers && answers.length ? ( + {answers?.length ? ( item.text} + itemToString={(item: Item) => item.text} items={answers.map((answer) => ({ id: answer.concept, text: answer.label, }))} - onChange={({ selectedItems }) => - setSelectedAnswers(selectedItems.sort()) - } + onChange={({ + selectedItems, + }: { + selectedItems: Array<{ + id: string; + text: string; + }>; + }) => setSelectedAnswers(selectedItems.sort())} size="md" - titleText={t( - "selectAnswersToDisplay", - "Select answers to display" - )} + titleText={t('selectAnswersToDisplay', 'Select answers to display')} /> ) : null} {selectedAnswers.length ? (
{selectedAnswers.map((answer) => ( - + {answer.text} ))} @@ -464,7 +426,7 @@ const AddQuestionModal: React.FC = ({ diff --git a/src/components/interactive-builder/delete-page-modal.component.tsx b/src/components/interactive-builder/delete-page-modal.component.tsx index 377bc0a..d8dd44a 100644 --- a/src/components/interactive-builder/delete-page-modal.component.tsx +++ b/src/components/interactive-builder/delete-page-modal.component.tsx @@ -1,23 +1,17 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - ComposedModal, - ModalBody, - ModalFooter, - ModalHeader, -} from "@carbon/react"; -import { showNotification, showToast } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComposedModal, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import { showNotification, showToast } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type DeletePageModalProps = { +interface DeletePageModalProps { onModalChange: (showModal: boolean) => void; onSchemaChange: (schema: Schema) => void; resetIndices: () => void; pageIndex: number; schema: Schema; showModal: boolean; -}; +} const DeletePageModal: React.FC = ({ onModalChange, @@ -37,44 +31,37 @@ const DeletePageModal: React.FC = ({ resetIndices(); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("pageDeleted", "Page deleted"), + description: t('pageDeleted', 'Page deleted'), }); } catch (error) { - showNotification({ - title: t("errorDeletingPage", "Error deleting page"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorDeletingPage', 'Error deleting page'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - + onModalChange(false)} preventCloseOnClickOutside> +

{t( - "deletePageExplainerText", - "Deleting this page will delete all the sections and questions associated with it. This action cannot be undone." + 'deletePageExplainerText', + 'Deleting this page will delete all the sections and questions associated with it. This action cannot be undone.', )}

diff --git a/src/components/interactive-builder/delete-question-modal.component.tsx b/src/components/interactive-builder/delete-question-modal.component.tsx index a7e2f75..e8c90ca 100644 --- a/src/components/interactive-builder/delete-question-modal.component.tsx +++ b/src/components/interactive-builder/delete-question-modal.component.tsx @@ -1,16 +1,10 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - ComposedModal, - ModalBody, - ModalFooter, - ModalHeader, -} from "@carbon/react"; -import { showNotification, showToast } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComposedModal, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import { showNotification, showToast } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type DeleteQuestionModal = { +interface DeleteQuestionModal { onModalChange: (showModal: boolean) => void; onSchemaChange: (schema: Schema) => void; resetIndices: () => void; @@ -19,7 +13,7 @@ type DeleteQuestionModal = { questionIndex: number; schema: Schema; showModal: boolean; -}; +} const DeleteQuestionModal: React.FC = ({ onModalChange, @@ -33,56 +27,40 @@ const DeleteQuestionModal: React.FC = ({ }) => { const { t } = useTranslation(); - const deleteQuestion = ( - pageIndex: number, - sectionIndex: number, - questionIndex: number - ) => { + const deleteQuestion = (pageIndex: number, sectionIndex: number, questionIndex: number) => { try { - schema.pages[pageIndex].sections[sectionIndex].questions.splice( - questionIndex, - 1 - ); + schema.pages[pageIndex].sections[sectionIndex].questions.splice(questionIndex, 1); onSchemaChange({ ...schema }); resetIndices(); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("QuestionDeleted", "Question deleted"), + description: t('QuestionDeleted', 'Question deleted'), }); } catch (error) { - showNotification({ - title: t("errorDeletingQuestion", "Error deleting question"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorDeletingQuestion', 'Error deleting question'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - + onModalChange(false)} preventCloseOnClickOutside> + -

- {t("deleteQuestionExplainerText", "This action cannot be undone.")} -

+

{t('deleteQuestionExplainerText', 'This action cannot be undone.')}

diff --git a/src/components/interactive-builder/delete-section-modal.component.tsx b/src/components/interactive-builder/delete-section-modal.component.tsx index 3f4af8c..dc6e48a 100644 --- a/src/components/interactive-builder/delete-section-modal.component.tsx +++ b/src/components/interactive-builder/delete-section-modal.component.tsx @@ -1,16 +1,10 @@ -import React from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - ComposedModal, - ModalBody, - ModalFooter, - ModalHeader, -} from "@carbon/react"; -import { showNotification, showToast } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComposedModal, ModalBody, ModalFooter, ModalHeader } from '@carbon/react'; +import { showNotification, showToast } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type DeleteSectionModal = { +interface DeleteSectionModal { onModalChange: (showModal: boolean) => void; onSchemaChange: (schema: Schema) => void; resetIndices: () => void; @@ -18,7 +12,7 @@ type DeleteSectionModal = { sectionIndex: number; schema: Schema; showModal: boolean; -}; +} const DeleteSectionModal: React.FC = ({ onModalChange, @@ -39,44 +33,37 @@ const DeleteSectionModal: React.FC = ({ resetIndices(); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("SectionDeleted", "Section deleted"), + description: t('SectionDeleted', 'Section deleted'), }); } catch (error) { - showNotification({ - title: t("errorDeletingSection", "Error deleting section"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorDeletingSection', 'Error deleting section'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - + onModalChange(false)} preventCloseOnClickOutside> +

{t( - "deleteSectionExplainerText", - "Deleting this section will delete all the questions associated with it. This action cannot be undone." + 'deleteSectionExplainerText', + 'Deleting this section will delete all the questions associated with it. This action cannot be undone.', )}

diff --git a/src/components/interactive-builder/draggable-question.component.tsx b/src/components/interactive-builder/draggable-question.component.tsx index c8934e0..e1af476 100644 --- a/src/components/interactive-builder/draggable-question.component.tsx +++ b/src/components/interactive-builder/draggable-question.component.tsx @@ -1,26 +1,22 @@ -import React from "react"; -import { useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; -import { useTranslation } from "react-i18next"; -import { Button, CopyButton } from "@carbon/react"; -import { Draggable, Edit, TrashCan } from "@carbon/react/icons"; -import type { Question } from "../../types"; -import styles from "./draggable-question.scss"; +import React from 'react'; +import { useDraggable } from '@dnd-kit/core'; +import { CSS } from '@dnd-kit/utilities'; +import { useTranslation } from 'react-i18next'; +import { Button, CopyButton } from '@carbon/react'; +import { Draggable, Edit, TrashCan } from '@carbon/react/icons'; +import type { Question } from '../../types'; +import styles from './draggable-question.scss'; -type DraggableQuestionProps = { +interface DraggableQuestionProps { question: Question; pageIndex: number; sectionIndex: number; questionIndex: number; - handleDuplicateQuestion: ( - question: Question, - pageId: number, - sectionId: number - ) => void; + handleDuplicateQuestion: (question: Question, pageId: number, sectionId: number) => void; handleEditButtonClick: (question: Question) => void; handleDeleteButtonClick: (question: Question) => void; questionCount: number; -}; +} export const DraggableQuestion: React.FC = ({ question, @@ -35,11 +31,10 @@ export const DraggableQuestion: React.FC = ({ const { t } = useTranslation(); const draggableId = `question-${pageIndex}-${sectionIndex}-${questionIndex}`; - const { attributes, listeners, transform, isDragging, setNodeRef } = - useDraggable({ - id: draggableId, - disabled: questionCount <= 1, - }); + const { attributes, listeners, transform, isDragging, setNodeRef } = useDraggable({ + id: draggableId, + disabled: questionCount <= 1, + }); const style = { transform: CSS.Translate.toString(transform), @@ -50,12 +45,7 @@ export const DraggableQuestion: React.FC = ({ return (
-
+

{question.label}

@@ -64,18 +54,15 @@ export const DraggableQuestion: React.FC = ({ - !isDragging && - handleDuplicateQuestion(question, pageIndex, sectionIndex) - } + onClick={() => !isDragging && handleDuplicateQuestion(question, pageIndex, sectionIndex)} />
{t("relationship", "Relationship")}{t("source", "Source")}{t("code", "Code")}{t('relationship', 'Relationship')}{t('source', 'Source')}{t('code', 'Code')}
- - - + + + {conceptMappings.map((mapping, index) => ( - - - + + + ))} @@ -477,48 +445,40 @@ const EditQuestionModal: React.FC = ({ ) : null} - {!hasConceptChanged && - questionToEdit?.questionOptions?.answers && - questionToEdit?.questionOptions.answers?.length ? ( + {!hasConceptChanged && questionToEdit?.questionOptions.answers?.length ? ( item.text} - initialSelectedItems={questionToEdit?.questionOptions?.answers?.map( - (answer) => ({ - id: answer.concept, - text: answer.label, - }) - )} - items={questionToEdit?.questionOptions?.answers?.map( - (answer) => ({ - id: answer.concept, - text: answer.label ?? "", - }) - )} - onChange={({ selectedItems }) => { + itemToString={(item: Item) => item.text} + initialSelectedItems={questionToEdit?.questionOptions?.answers?.map((answer) => ({ + id: answer.concept, + text: answer.label, + }))} + items={questionToEdit?.questionOptions?.answers?.map((answer) => ({ + id: answer.concept, + text: answer.label ?? '', + }))} + onChange={({ + selectedItems, + }: { + selectedItems: Array<{ + id: string; + text: string; + }>; + }) => { setAnswersChanged(true); setSelectedAnswers(selectedItems.sort()); }} size="md" - titleText={t( - "selectAnswersToDisplay", - "Select answers to display" - )} + titleText={t('selectAnswersToDisplay', 'Select answers to display')} /> ) : null} - {!hasConceptChanged && - questionToEdit?.questionOptions?.answers?.length && - !answersChanged ? ( + {!hasConceptChanged && questionToEdit?.questionOptions?.answers?.length && !answersChanged ? (
{questionToEdit?.questionOptions?.answers?.map((answer) => ( - + {answer?.label} ))} @@ -530,30 +490,28 @@ const EditQuestionModal: React.FC = ({ className={styles.multiSelect} direction="top" id="selectAnswers" - itemToString={(item) => item.text} + itemToString={(item: Item) => item.text} items={answersFromConcept.map((answer) => ({ id: answer.concept, text: answer.label, }))} - onChange={({ selectedItems }) => - setSelectedAnswers(selectedItems.sort()) - } + onChange={({ + selectedItems, + }: { + selectedItems: Array<{ + id: string; + text: string; + }>; + }) => setSelectedAnswers(selectedItems.sort())} size="md" - titleText={t( - "selectAnswersToDisplay", - "Select answers to display" - )} + titleText={t('selectAnswersToDisplay', 'Select answers to display')} /> ) : null} - {(hasConceptChanged || answersChanged) && ( + {(hasConceptChanged ?? answersChanged) && (
{selectedAnswers.map((selectedAnswer) => ( - + {selectedAnswer.text} ))} @@ -563,10 +521,10 @@ const EditQuestionModal: React.FC = ({ diff --git a/src/components/interactive-builder/editable-value.component.tsx b/src/components/interactive-builder/editable-value.component.tsx index 525e108..674f782 100644 --- a/src/components/interactive-builder/editable-value.component.tsx +++ b/src/components/interactive-builder/editable-value.component.tsx @@ -1,23 +1,18 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@carbon/react"; -import { Edit } from "@carbon/react/icons"; -import ValueEditor from "./value-editor.component"; -import styles from "./editable-value.scss"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@carbon/react'; +import { Edit } from '@carbon/react/icons'; +import ValueEditor from './value-editor.component'; +import styles from './editable-value.scss'; -type EditableValueProps = { - elementType?: "schema" | "page" | "section"; +interface EditableValueProps { + elementType?: 'schema' | 'page' | 'section'; id: string; value: string; onSave: (value: string) => void; -}; +} -const EditableValue: React.FC = ({ - elementType, - id, - value, - onSave, -}) => { +const EditableValue: React.FC = ({ elementType, id, value, onSave }) => { const { t } = useTranslation(); const [editing, setEditing] = useState(false); @@ -41,12 +36,12 @@ const EditableValue: React.FC = ({ return ( <> -

{value}

+

{value}

@@ -429,26 +405,22 @@ const InteractiveBuilder: React.FC = ({

{t( - "interactiveBuilderHelperText", - "The Interactive Builder lets you build your form schema without writing JSON code. The Preview tab automatically updates as you build your form. When done, click Save Form to save your form." + 'interactiveBuilderHelperText', + 'The Interactive Builder lets you build your form schema without writing JSON code. The Preview tab automatically updates as you build your form. When done, click Save Form to save your form.', )}

-
)} - handleDragEnd(event)} sensors={sensors}> + handleDragEnd(event)} sensors={sensors}> {schema?.pages?.length ? schema.pages.map((page, pageIndex) => (
-
+
= ({
{section.questions?.length ? ( - section.questions.map( - (question, questionIndex) => ( - - - - ) - ) + section.questions.map((question, questionIndex) => ( + + + + )) ) : (

{t( - "sectionExplainer", - "A section will typically contain one or more questions. Click the button below to add a question to this section." + 'sectionExplainer', + 'A section will typically contain one or more questions. Click the button below to add a question to this section.', )}

)} @@ -562,12 +515,9 @@ const InteractiveBuilder: React.FC = ({ setPageIndex(pageIndex); setSectionIndex(sectionIndex); }} - iconDescription={t( - "addQuestion", - "Add Question" - )} + iconDescription={t('addQuestion', 'Add Question')} > - {t("addQuestion", "Add Question")} + {t('addQuestion', 'Add Question')}
@@ -577,8 +527,8 @@ const InteractiveBuilder: React.FC = ({ ) : (

{t( - "pageExplainer", - "Pages typically have one or more sections. Click the button below to add a section to your page." + 'pageExplainer', + 'Pages typically have one or more sections. Click the button below to add a section to your page.', )}

)} @@ -591,9 +541,9 @@ const InteractiveBuilder: React.FC = ({ addSection(); setPageIndex(pageIndex); }} - iconDescription={t("addSection", "Add Section")} + iconDescription={t('addSection', 'Add Section')} > - {t("addSection", "Add Section")} + {t('addSection', 'Add Section')}
)) diff --git a/src/components/interactive-builder/new-form-modal.component.tsx b/src/components/interactive-builder/new-form-modal.component.tsx index 60b6555..371bf52 100644 --- a/src/components/interactive-builder/new-form-modal.component.tsx +++ b/src/components/interactive-builder/new-form-modal.component.tsx @@ -1,5 +1,5 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { Button, ComposedModal, @@ -10,26 +10,21 @@ import { ModalHeader, Stack, TextInput, -} from "@carbon/react"; -import { showToast, showNotification } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +} from '@carbon/react'; +import { showToast, showNotification } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type NewFormModalProps = { +interface NewFormModalProps { schema: Schema; onSchemaChange: (schema: Schema) => void; onModalChange: (showModal: boolean) => void; showModal: boolean; -}; +} -const NewFormModal: React.FC = ({ - schema, - onSchemaChange, - showModal, - onModalChange, -}) => { +const NewFormModal: React.FC = ({ schema, onSchemaChange, showModal, onModalChange }) => { const { t } = useTranslation(); - const [formName, setFormName] = useState(""); - const [formDescription, setFormDescription] = useState(""); + const [formName, setFormName] = useState(''); + const [formDescription, setFormDescription] = useState(''); const updateSchema = (updates: Partial) => { try { @@ -37,18 +32,20 @@ const NewFormModal: React.FC = ({ onSchemaChange(updatedSchema); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("formCreated", "New form created"), + description: t('formCreated', 'New form created'), }); } catch (error) { - showNotification({ - title: t("errorCreatingForm", "Error creating form"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorCreatingForm', 'Error creating form'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; @@ -64,41 +61,30 @@ const NewFormModal: React.FC = ({ }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - -
event.preventDefault()}> + onModalChange(false)} preventCloseOnClickOutside> + + event.preventDefault()}> - + ) => - setFormName(event.target.value) - } + onChange={(event: React.ChangeEvent) => setFormName(event.target.value)} /> - + ) => - setFormDescription(event.target.value) - } + onChange={(event: React.ChangeEvent) => setFormDescription(event.target.value)} /> @@ -106,10 +92,10 @@ const NewFormModal: React.FC = ({ diff --git a/src/components/interactive-builder/page-modal.component.tsx b/src/components/interactive-builder/page-modal.component.tsx index fe8e2ff..ee94076 100644 --- a/src/components/interactive-builder/page-modal.component.tsx +++ b/src/components/interactive-builder/page-modal.component.tsx @@ -1,33 +1,19 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - ComposedModal, - Form, - FormGroup, - ModalBody, - ModalFooter, - ModalHeader, - TextInput, -} from "@carbon/react"; -import { showToast, showNotification } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComposedModal, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react'; +import { showToast, showNotification } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type PageModalProps = { +interface PageModalProps { schema: Schema; onSchemaChange: (schema: Schema) => void; showModal: boolean; onModalChange: (showModal: boolean) => void; -}; +} -const PageModal: React.FC = ({ - schema, - onSchemaChange, - showModal, - onModalChange, -}) => { +const PageModal: React.FC = ({ schema, onSchemaChange, showModal, onModalChange }) => { const { t } = useTranslation(); - const [pageTitle, setPageTitle] = useState(""); + const [pageTitle, setPageTitle] = useState(''); const handleUpdatePageTitle = () => { updatePages(); @@ -43,49 +29,47 @@ const PageModal: React.FC = ({ }); onSchemaChange({ ...schema }); - setPageTitle(""); + setPageTitle(''); } showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("pageCreated", "New page created"), + description: t('pageCreated', 'New page created'), }); } catch (error) { - showNotification({ - title: t("errorCreatingPage", "Error creating page"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorCreatingPage', 'Error creating page'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - -
event.preventDefault()}> + onModalChange(false)} preventCloseOnClickOutside> + + event.preventDefault()}> - + setPageTitle(event.target.value)} + onChange={(event: React.ChangeEvent) => setPageTitle(event.target.value)} /> diff --git a/src/components/interactive-builder/question-modal.scss b/src/components/interactive-builder/question-modal.scss index fb843e1..a13a639 100644 --- a/src/components/interactive-builder/question-modal.scss +++ b/src/components/interactive-builder/question-modal.scss @@ -104,3 +104,10 @@ width: 100%; } } + +.error { + width: 100%; + max-width: unset; + padding: '0rem'; + margin-bottom: 1rem; +} diff --git a/src/components/interactive-builder/section-modal.component.tsx b/src/components/interactive-builder/section-modal.component.tsx index 27180b4..7e8ba7d 100644 --- a/src/components/interactive-builder/section-modal.component.tsx +++ b/src/components/interactive-builder/section-modal.component.tsx @@ -1,26 +1,17 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { - Button, - ComposedModal, - Form, - FormGroup, - ModalBody, - ModalFooter, - ModalHeader, - TextInput, -} from "@carbon/react"; -import { showToast, showNotification } from "@openmrs/esm-framework"; -import type { Schema } from "../../types"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, ComposedModal, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react'; +import { showToast, showNotification } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; -type SectionModalProps = { +interface SectionModalProps { schema: Schema; onSchemaChange: (schema: Schema) => void; pageIndex: number; resetIndices: () => void; showModal: boolean; onModalChange: (showModal: boolean) => void; -}; +} const SectionModal: React.FC = ({ schema, @@ -31,7 +22,7 @@ const SectionModal: React.FC = ({ onModalChange, }) => { const { t } = useTranslation(); - const [sectionTitle, setSectionTitle] = useState(""); + const [sectionTitle, setSectionTitle] = useState(''); const handleUpdatePageSections = () => { updateSections(); @@ -42,54 +33,52 @@ const SectionModal: React.FC = ({ try { schema.pages[pageIndex]?.sections?.push({ label: sectionTitle, - isExpanded: "true", + isExpanded: 'true', questions: [], }); onSchemaChange({ ...schema }); - setSectionTitle(""); + setSectionTitle(''); resetIndices(); showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: t("sectionCreated", "New section created"), + description: t('sectionCreated', 'New section created'), }); } catch (error) { - showNotification({ - title: t("errorCreatingSection", "Error creating section"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorCreatingSection', 'Error creating section'), + kind: 'error', + critical: true, + description: error?.message, + }); + } } }; return ( - onModalChange(false)} - preventCloseOnClickOutside - > - -
event.preventDefault()}> + onModalChange(false)} preventCloseOnClickOutside> + + event.preventDefault()}> - + setSectionTitle(event.target.value)} + onChange={(event: React.ChangeEvent) => setSectionTitle(event.target.value)} /> diff --git a/src/components/interactive-builder/value-editor.component.tsx b/src/components/interactive-builder/value-editor.component.tsx index d79f2aa..2d58aaa 100644 --- a/src/components/interactive-builder/value-editor.component.tsx +++ b/src/components/interactive-builder/value-editor.component.tsx @@ -1,23 +1,18 @@ -import React, { useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Button, TextInput } from "@carbon/react"; -import { Close, Save } from "@carbon/react/icons"; -import styles from "./value-editor.scss"; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, TextInput } from '@carbon/react'; +import { Close, Save } from '@carbon/react/icons'; +import styles from './value-editor.scss'; -type ValueEditorProps = { +interface ValueEditorProps { id: string; handleCancel: () => void; handleSave: (value: string) => void; onChange?: (event: React.ChangeEvent) => void; value: string; -}; +} -const ValueEditor: React.FC = ({ - id, - handleCancel, - handleSave, - value, -}) => { +const ValueEditor: React.FC = ({ id, handleCancel, handleSave, value }) => { const { t } = useTranslation(); const [tmpValue, setTmpValue] = useState(value); @@ -27,7 +22,7 @@ const ValueEditor: React.FC = ({ id={id} labelText="" value={tmpValue} - onChange={(event) => setTmpValue(event.target.value)} + onChange={(event: React.ChangeEvent) => setTmpValue(event.target.value)} />
diff --git a/src/components/modals/save-form-modal.component.tsx b/src/components/modals/save-form-modal.component.tsx index 2c9751f..c7c99a7 100644 --- a/src/components/modals/save-form-modal.component.tsx +++ b/src/components/modals/save-form-modal.component.tsx @@ -1,6 +1,6 @@ -import React, { SyntheticEvent, useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { useParams } from "react-router-dom"; +import React, { type SyntheticEvent, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useParams } from 'react-router-dom'; import { Button, ComposedModal, @@ -15,8 +15,8 @@ import { Stack, TextArea, TextInput, -} from "@carbon/react"; -import { navigate, showNotification, showToast } from "@openmrs/esm-framework"; +} from '@carbon/react'; +import { navigate, showNotification, showToast } from '@openmrs/esm-framework'; import { deleteClobdata, @@ -25,46 +25,44 @@ import { saveNewForm, updateForm, uploadSchema, -} from "../../forms.resource"; -import type { EncounterType, Resource, RouteParams, Schema } from "../../types"; -import { useEncounterTypes } from "../../hooks/useEncounterTypes"; -import { useForm } from "../../hooks/useForm"; -import styles from "./save-form-modal.scss"; +} from '../../forms.resource'; +import type { EncounterType } from '../../types'; +import type { Resource, Schema } from '../../types'; +import { useEncounterTypes } from '../../hooks/useEncounterTypes'; +import { useForm } from '../../hooks/useForm'; +import styles from './save-form-modal.scss'; -type FormGroupData = { +interface FormGroupData { name: string; uuid: string; version: string; encounterType: EncounterType; description: string; resources: Array; -}; +} -type SaveFormModalProps = { +interface SaveFormModalProps { form: FormGroupData; schema: Schema; -}; +} const SaveFormModal: React.FC = ({ form, schema }) => { const { t } = useTranslation(); - const { formUuid } = useParams(); + const { encounterTypes } = useEncounterTypes(); + const { formUuid } = useParams<{ formUuid: string }>(); const { mutate } = useForm(formUuid); const isSavingNewForm = !formUuid; - const { encounterTypes } = useEncounterTypes(); - const [openSaveFormModal, setOpenSaveFormModal] = useState(false); - const [openConfirmSaveModal, setOpenConfirmSaveModal] = useState(false); - const [saveState, setSaveState] = useState(""); - const [isSavingForm, setIsSavingForm] = useState(false); + const [description, setDescription] = useState(''); + const [encounterType, setEncounterType] = useState(''); const [isInvalidVersion, setIsInvalidVersion] = useState(false); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const [encounterType, setEncounterType] = useState(""); - const [version, setVersion] = useState(""); + const [isSavingForm, setIsSavingForm] = useState(false); + const [name, setName] = useState(''); + const [openConfirmSaveModal, setOpenConfirmSaveModal] = useState(false); + const [openSaveFormModal, setOpenSaveFormModal] = useState(false); + const [saveState, setSaveState] = useState(''); + const [version, setVersion] = useState(''); - const clearDraftFormSchema = useCallback( - () => localStorage.removeItem("formSchema"), - [] - ); + const clearDraftFormSchema = useCallback(() => localStorage.removeItem('formSchema'), []); useEffect(() => { if (schema) { @@ -82,23 +80,21 @@ const SaveFormModal: React.FC = ({ form, schema }) => { }; const openModal = useCallback((option: string) => { - if (option === "newVersion") { - setSaveState("newVersion"); + if (option === 'newVersion') { + setSaveState('newVersion'); setOpenConfirmSaveModal(false); setOpenSaveFormModal(true); - } else if (option === "new") { - setSaveState("newVersion"); + } else if (option === 'new') { + setSaveState('newVersion'); setOpenSaveFormModal(true); - } else if (option === "update") { - setSaveState("update"); + } else if (option === 'update') { + setSaveState('update'); setOpenConfirmSaveModal(false); setOpenSaveFormModal(true); } }, []); - const handleSubmit = async ( - event: SyntheticEvent<{ name: { value: string } }> - ) => { + const handleSubmit = async (event: SyntheticEvent<{ name: { value: string } }>) => { event.preventDefault(); setIsSavingForm(true); @@ -109,20 +105,14 @@ const SaveFormModal: React.FC = ({ form, schema }) => { description: { value: string }; }; - if (saveState === "new" || saveState === "newVersion") { + if (saveState === 'new' || saveState === 'newVersion') { const name = target.name.value, version = target.version.value, encounterType = target.encounterType.value, description = target.description.value; try { - const newForm = await saveNewForm( - name, - version, - false, - description, - encounterType - ); + const newForm = await saveNewForm(name, version, false, description, encounterType); const updatedSchema = { ...schema, @@ -135,32 +125,32 @@ const SaveFormModal: React.FC = ({ form, schema }) => { const newValueReference = await uploadSchema(updatedSchema); await getResourceUuid(newForm.uuid, newValueReference.toString()); + showToast({ - title: t("formCreated", "New form created"), - kind: "success", + title: t('formCreated', 'New form created'), + kind: 'success', critical: true, description: - name + - " " + - t( - "saveSuccessMessage", - "was created successfully. It is now visible on the Forms dashboard." - ), + name + ' ' + t('saveSuccessMessage', 'was created successfully. It is now visible on the Forms dashboard.'), }); clearDraftFormSchema(); setOpenSaveFormModal(false); - mutate(); + await mutate(); + navigate({ to: `${window.spaBase}/form-builder/edit/${newForm.uuid}`, }); + setIsSavingForm(false); } catch (error) { - showNotification({ - title: t("errorCreatingForm", "Error creating form"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorCreatingForm', 'Error creating form'), + kind: 'error', + critical: true, + description: error?.message, + }); + } setIsSavingForm(false); } } else { @@ -176,75 +166,59 @@ const SaveFormModal: React.FC = ({ form, schema }) => { await updateForm(form.uuid, name, version, description, encounterType); if (form?.resources?.length !== 0) { - const existingValueReferenceUuid = form?.resources?.find( - ({ name }) => name === "JSON schema" - )?.valueReference; + const existingValueReferenceUuid = + form?.resources?.find(({ name }) => name === 'JSON schema')?.valueReference ?? ''; await deleteClobdata(existingValueReferenceUuid) - .catch((error) => - console.error("Unable to delete clobdata: ", error) - ) + .catch((error) => console.error('Unable to delete clobdata: ', error)) .then(() => { - const resourceUuidToDelete = form?.resources?.find( - ({ name }) => name === "JSON schema" - )?.uuid; + const resourceUuidToDelete = form?.resources?.find(({ name }) => name === 'JSON schema')?.uuid ?? ''; deleteResource(form?.uuid, resourceUuidToDelete) .then(() => { uploadSchema(updatedSchema) .then((result) => { getResourceUuid(form?.uuid, result.toString()) - .then(() => { + .then(async () => { showToast({ - title: t("success", "Success!"), - kind: "success", + title: t('success', 'Success!'), + kind: 'success', critical: true, - description: - form?.name + - " " + - t("saveSuccess", "was updated successfully"), + description: form?.name + ' ' + t('saveSuccess', 'was updated successfully'), }); setOpenSaveFormModal(false); - mutate(); + await mutate(); setIsSavingForm(false); }) .catch((err) => { - console.error( - "Error associating form with new schema: ", - err - ); + console.error('Error associating form with new schema: ', err); showNotification({ - title: t("errorSavingForm", "Unable to save form"), - kind: "error", + title: t('errorSavingForm', 'Unable to save form'), + kind: 'error', critical: true, description: t( - "saveError", - "There was a problem saving your form. Try saving again. To ensure you don’t lose your changes, copy them, reload the page and then paste them back into the editor." + 'saveError', + 'There was a problem saving your form. Try saving again. To ensure you don’t lose your changes, copy them, reload the page and then paste them back into the editor.', ), }); }); }) - .catch((err) => - console.error("Error uploading new schema: ", err) - ); + .catch((err) => console.error('Error uploading new schema: ', err)); }) - .catch((error) => - console.error( - "Unable to create new clobdata resource: ", - error - ) - ); + .catch((error) => console.error('Unable to create new clobdata resource: ', error)); }); } } catch (error) { - showNotification({ - title: t("errorUpdatingForm", "Error updating form"), - kind: "error", - critical: true, - description: error?.message, - }); + if (error instanceof Error) { + showNotification({ + title: t('errorUpdatingForm', 'Error updating form'), + kind: 'error', + critical: true, + description: error?.message, + }); + } setIsSavingForm(false); } @@ -259,74 +233,62 @@ const SaveFormModal: React.FC = ({ form, schema }) => { onClose={() => setOpenConfirmSaveModal(false)} preventCloseOnClickOutside > - +

{t( - "saveAsModal", - "A version of the form you're working on already exists on the server. Do you want to update the form or to save it as a new version?" + 'saveAsModal', + "A version of the form you're working on already exists on the server. Do you want to update the form or to save it as a new version?", )}

- - -
) : null} - setOpenSaveFormModal(false)} - preventCloseOnClickOutside - > - + setOpenSaveFormModal(false)} preventCloseOnClickOutside> +

{t( - "saveExplainerText", - "Clicking the Save button saves your form schema to the database. To see your form in your frontend, you first need to publish it. Click the Publish button to publish your form." + 'saveExplainerText', + 'Clicking the Save button saves your form schema to the database. To see your form in your frontend, you first need to publish it. Click the Publish button to publish your form.', )}

- + setName(event.target.value)} - placeholder={t( - "formNamePlaceholder", - "e.g. OHRI Express Care Patient Encounter Form" - )} + labelText={t('formName', 'Form name')} + onChange={(event: React.ChangeEvent) => setName(event.target.value)} + placeholder={t('formNamePlaceholder', 'e.g. OHRI Express Care Patient Encounter Form')} required value={name} /> - {saveState === "update" ? ( + {saveState === 'update' ? ( ) : null} { + onChange={(event: React.ChangeEvent) => { checkVersionValidity(event.target.value); if (!isInvalidVersion) { @@ -334,49 +296,39 @@ const SaveFormModal: React.FC = ({ form, schema }) => { } }} invalid={isInvalidVersion} - invalidText={t( - "invalidVersionWarning", - "Version can only start with with a number" - )} + invalidText={t('invalidVersionWarning', 'Version can only start with with a number')} required value={version} />
{t("relationship", "Relationship")}{t("source", "Source")}{t("code", "Code")}{t('relationship', 'Relationship')}{t('source', 'Source')}{t('code', 'Code')}
{mapping.relationship ?? "--"}{mapping.type ?? "--"}{mapping.value ?? "--"}{mapping.relationship ?? '--'}{mapping.type ?? '--'}{mapping.value ?? '--'}