From 2e44e278aac43261cbd3587f60f85a1f086c30ba Mon Sep 17 00:00:00 2001 From: hadijahkyampeire Date: Thu, 26 Sep 2024 00:02:48 +0300 Subject: [PATCH 1/4] rework the dashboard --- i18next-parser.config.js | 2 +- package.json | 10 +- .../dashboard/dashboard.component.tsx | 168 +++++++--- src/components/dashboard/dashboard.scss | 26 +- .../add-package-modal.component.tsx | 150 +++++++++ .../add-submenu-modal.component.tsx | 165 ++++++++++ .../interactive-builder.component.tsx | 148 +++++++++ .../interactive-builder.scss | 116 +++++++ .../interactive-builder/modals.scss | 29 ++ src/components/schema-editor/dummy-schema.ts | 17 + .../schema-editor/schema-editor.component.tsx | 40 +++ .../view-editor/view-editor.component.tsx | 267 ++++++++++++++-- src/components/view-editor/view-editor.scss | 15 +- src/index.ts | 15 +- src/root.component.tsx | 2 +- src/routes.json | 10 + src/types.ts | 33 ++ src/utils.ts | 24 ++ translations/am.json | 72 +++-- translations/en.json | 72 +++-- translations/es.json | 72 +++-- translations/fr.json | 72 +++-- translations/he.json | 72 +++-- translations/km.json | 72 +++-- yarn.lock | 301 ++++++++++-------- 25 files changed, 1613 insertions(+), 357 deletions(-) create mode 100644 src/components/interactive-builder/add-package-modal.component.tsx create mode 100644 src/components/interactive-builder/add-submenu-modal.component.tsx create mode 100644 src/components/interactive-builder/interactive-builder.component.tsx create mode 100644 src/components/interactive-builder/interactive-builder.scss create mode 100644 src/components/interactive-builder/modals.scss create mode 100644 src/components/schema-editor/dummy-schema.ts create mode 100644 src/components/schema-editor/schema-editor.component.tsx create mode 100644 src/utils.ts diff --git a/i18next-parser.config.js b/i18next-parser.config.js index c720f96..806f02a 100644 --- a/i18next-parser.config.js +++ b/i18next-parser.config.js @@ -42,7 +42,7 @@ module.exports = { lineEnding: 'auto', // Control the line ending. See options at https://github.com/ryanve/eol - locales: ['en'], + locales: ['en', 'fr', 'am', 'es', 'he', 'km'], // An array of the locales in your applications namespaceSeparator: ':', diff --git a/package.json b/package.json index b3c6f70..5b4f803 100644 --- a/package.json +++ b/package.json @@ -47,12 +47,14 @@ "dependencies": { "@carbon/react": "^1.47.0", "ajv": "^8.17.1", + "classnames": "^2.5.1", "dotenv": "^16.4.5", "file-loader": "^6.2.0", "fuzzy": "^0.1.3", "lodash-es": "^4.17.21", "react-ace": "^11.0.1", - "sass": "^1.67.0" + "sass": "^1.67.0", + "uuid": "^10.0.0" }, "peerDependencies": { "@openmrs/esm-framework": "*", @@ -79,6 +81,7 @@ "@types/jest": "^29.5.12", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", + "@types/uuid": "^10", "@types/webpack-env": "^1.18.5", "@typescript-eslint/eslint-plugin": "^7.9.0", "@typescript-eslint/parser": "^6.21.0", @@ -90,7 +93,7 @@ "eslint-plugin-testing-library": "^6.2.2", "husky": "^8.0.3", "i18next": "^23.11.4", - "i18next-parser": "^8.13.0", + "i18next-parser": "^9.0.2", "identity-obj-proxy": "^3.0.0", "jest": "^29.7.0", "jest-cli": "^29.7.0", @@ -113,5 +116,8 @@ "*.{ts,tsx}": "eslint --cache --fix --max-warnings 0", "*.{css,scss,ts,tsx}": "prettier --write --list-different" }, + "resolutions": { + "cheerio": "^1.0.0-rc.5" + }, "packageManager": "yarn@4.3.1" } diff --git a/src/components/dashboard/dashboard.component.tsx b/src/components/dashboard/dashboard.component.tsx index e43ed7e..1f5e5dc 100644 --- a/src/components/dashboard/dashboard.component.tsx +++ b/src/components/dashboard/dashboard.component.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState, useEffect } from 'react'; import { useTranslation, type TFunction } from 'react-i18next'; import { Button, @@ -18,31 +18,33 @@ import { Tile, } from '@carbon/react'; import { Add, Download, Edit, TrashCan } from '@carbon/react/icons'; -import { type KeyedMutator } from 'swr'; import { ContentPackagesBuilderPagination } from '../pagination'; import Header from '../header/header.component'; import styles from './dashboard.scss'; import { mockContentPackages } from '../../../__mocks__/content-packages.mock'; -import { navigate, useLayoutType, usePagination } from '@openmrs/esm-framework'; - -type Mutator = KeyedMutator<{ - data: { - results: Array; - }; -}>; +import { navigate, useLayoutType, usePagination, ConfigurableLink, showModal } from '@openmrs/esm-framework'; +import { getAllPackagesFromLocalStorage, deletePackageFromLocalStorage } from '../../utils'; interface ActionButtonsProps { - clinicalViews: any; - mutate: Mutator; responsiveSize: string; + clinicalViewKey: string; t: TFunction; + onEdit: (key: string) => void; + onDelete: (key: string) => void; + onDownload: (key: string) => void; } -function ActionButtons({ clinicalViews, mutate, responsiveSize, t }: ActionButtonsProps) { +function ActionButtons({ responsiveSize, clinicalViewKey, t, onDelete, onDownload, onEdit }: ActionButtonsProps) { const defaultEnterDelayInMs = 300; - const launchDeleteClinicalViewsPackageModal = {}; + const launchDeleteClinicalViewsPackageModal = () => { + const dispose = showModal('delete-clinicalView-modal', { + closeModal: () => dispose(), + clinicalViewKey, + onDelete, + }); + }; const EditButton = () => { return ( @@ -50,11 +52,7 @@ function ActionButtons({ clinicalViews, mutate, responsiveSize, t }: ActionButto enterDelayMs={defaultEnterDelayInMs} kind="ghost" label={t('editSchema', 'Edit schema')} - onClick={() => - navigate({ - to: `${window.spaBase}/clinical-views-builder/edit/${clinicalViews}`, - }) - } + onClick={onEdit} size={responsiveSize} > @@ -64,11 +62,12 @@ function ActionButtons({ clinicalViews, mutate, responsiveSize, t }: ActionButto const DownloadSchemaButton = () => { return ( - + @@ -100,16 +99,42 @@ function ActionButtons({ clinicalViews, mutate, responsiveSize, t }: ActionButto ); } -function ContentPackagesList({ contentPackages, isValidating, mutate, t }: any) { +function ContentPackagesList({ isValidating, t }: any) { const pageSize = 10; const isTablet = useLayoutType() === 'tablet'; const responsiveSize = isTablet ? 'lg' : 'sm'; const [searchString, setSearchString] = useState(''); + const [clinicalViews, setClinicalViews] = useState([]); + + useEffect(() => { + const packages = getAllPackagesFromLocalStorage(); + setClinicalViews(Object.entries(packages).map(([key, value]) => ({ key, ...(value as object) }))); + }, []); + + const filteredViews = useMemo(() => { + const searchTerm = searchString.trim().toLowerCase(); + return clinicalViews.filter((pkg) => pkg.key.toLowerCase().includes(searchTerm)); + }, [clinicalViews, searchString]); + + const handleEdit = (packageKey: string) => { + navigate({ + to: `${window.spaBase}/clinical-views-builder/edit/${packageKey}`, + }); + }; + + const handleDelete = (packageKey: string) => { + deletePackageFromLocalStorage(packageKey); + setClinicalViews(clinicalViews.filter((pkg) => pkg.key !== packageKey)); + }; const tableHeaders = [ { - header: t('packageName', 'Package name'), - key: 'key', + header: t('name', 'Name'), + key: 'name', + }, + { + header: t('dashboards', 'Dashboards'), + key: 'dashboards', }, { header: t('schemaActions', 'Schema actions'), @@ -117,31 +142,85 @@ function ContentPackagesList({ contentPackages, isValidating, mutate, t }: any) }, ]; - const searchResults = useMemo(() => { - const searchTerm = searchString?.trim().toLowerCase(); + const { paginated, goTo, results, currentPage } = usePagination(filteredViews, pageSize); + + const handleDownload = (key) => { + const schema = localStorage.getItem(`packageJSON_${key}`); + if (schema) { + const blob = new Blob([schema], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${key}.json`; + a.click(); + } else { + console.error('No schema found to download.'); + } + }; + + const getNavGroupTitle = (schema) => { + const packageSlotKey = 'patient-chart-dashboard-slot'; + const packageConfig = schema?.['@openmrs/esm-patient-chart-app']?.extensionSlots[packageSlotKey]; - if (searchTerm) { - return mockContentPackages.filter((contentPackage) => - Object.keys(contentPackage).some((key) => key.toLowerCase().includes(searchTerm)), - ); + if (packageConfig) { + const navGroupKey = packageConfig?.add[0]; + const navGroupConfig = packageConfig?.configure[navGroupKey]; + return navGroupConfig?.title || 'Unnamed Clinical View'; } - return mockContentPackages; - }, [searchString]); + return 'default_schema_name'; + }; - const { paginated, goTo, results, currentPage } = usePagination(searchResults, pageSize); + const getDashboardTitles = (schema) => { + const packageSlotKey = 'patient-chart-dashboard-slot'; - const tableRows = Array.from(results, (result) => { - return Object.keys(result).map((key) => { - const contentPackage = result[key]; - return { - ...contentPackage, - id: key, - key: key, - actions: , - }; - }); - }).flat(); + const packageConfig = schema?.['@openmrs/esm-patient-chart-app']?.extensionSlots?.[packageSlotKey]; + + const navGroupKey = packageConfig?.add?.[0]; + const navGroupConfig = packageConfig?.configure?.[navGroupKey]; + + const submenuSlotKey = navGroupConfig?.slotName; + const submenuConfig = schema?.['@openmrs/esm-patient-chart-app']?.extensionSlots?.[submenuSlotKey]; + + if (submenuConfig && submenuConfig.add) { + const dashboardTitles = submenuConfig.add.map((dashboardKey) => { + return submenuConfig.configure?.[dashboardKey]?.title || 'Unnamed Dashboard'; + }); + + return dashboardTitles.length ? dashboardTitles.join(', ') : 'No dashboards available'; + } + + return 'No dashboards available'; + }; + + const tableRows = results.map((contentPackage) => { + const clinicalViewName = getNavGroupTitle(contentPackage); + const dashboardTitles = getDashboardTitles(contentPackage); + + return { + id: contentPackage.key, + name: ( + + {clinicalViewName} + + ), + dashboards: dashboardTitles, + actions: ( + handleEdit(contentPackage.key)} + onDelete={() => handleDelete(contentPackage.key)} + onDownload={() => handleDownload(contentPackage.key)} + /> + ), + }; + }); const handleSearch = useCallback( (e: React.ChangeEvent) => { @@ -158,6 +237,7 @@ function ContentPackagesList({ contentPackages, isValidating, mutate, t }: any) {isValidating ? : null} +
{t('clinicalViewsTableHeader', 'Clinical Views List')}
{({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => ( <> @@ -182,7 +262,7 @@ function ContentPackagesList({ contentPackages, isValidating, mutate, t }: any) }) } > - {t('createNewClinicalview', 'Create a new clinical view')} + {t('createNewClinicalView', 'Create a new clinical view')} @@ -224,7 +304,7 @@ function ContentPackagesList({ contentPackages, isValidating, mutate, t }: any) {paginated && ( { goTo(page); }} diff --git a/src/components/dashboard/dashboard.scss b/src/components/dashboard/dashboard.scss index 58b9f8e..2054628 100644 --- a/src/components/dashboard/dashboard.scss +++ b/src/components/dashboard/dashboard.scss @@ -106,29 +106,21 @@ } +.tableHeading { + display: flex; + align-items: center; + @include type.type-style('heading-compact-02'); + min-height: 2.5rem; + width: 100%; + padding: 1rem 0; + margin: 2rem 0; +} .content { @include type.type-style('heading-compact-02'); color: colors.$gray-70; margin-bottom: 0.5rem; } -.tileContainer { - background-color: colors.$white-0; - border-top: 1px solid colors.$gray-70; - padding: 5rem 0; -} - -.tile { - margin: auto; - width: fit-content; -} - -.tileContent { - display: flex; - flex-direction: column; - align-items: center; -} - .warningMessage { margin: 1rem 0; } diff --git a/src/components/interactive-builder/add-package-modal.component.tsx b/src/components/interactive-builder/add-package-modal.component.tsx new file mode 100644 index 0000000..9fcef8e --- /dev/null +++ b/src/components/interactive-builder/add-package-modal.component.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, TextInput } from '@carbon/react'; +import { showSnackbar } from '@openmrs/esm-framework'; +import type { Schema } from '../../types'; + +import styles from './modals.scss'; + +interface PackageModalProps { + closeModal: () => void; + schema: Schema; + onSchemaChange: (schema: Schema) => void; +} + +const toCamelCase = (str: string) => { + return str + .replace(/(?:^\w|[A-Z]|\b\w|\s+)/g, (match, index) => (index === 0 ? match.toLowerCase() : match.toUpperCase())) + .replace(/\s+/g, ''); +}; + +const isValidSlotName = (slotName: string) => { + return /^[a-zA-Z0-9-]+$/.test(slotName); +}; + +const PackageModal: React.FC = ({ closeModal, schema, onSchemaChange }) => { + const { t } = useTranslation(); + const [key, setKey] = useState(''); + const [title, setTitle] = useState(''); + const [slotName, setSlotName] = useState(''); + const [slotNameError, setSlotNameError] = useState(''); + + useEffect(() => { + if (title) { + setKey(toCamelCase(title)); + } + }, [title]); + + const handleSlotNameChange = (event: React.ChangeEvent) => { + const inputValue = event.target.value; + setSlotName(inputValue); + + if (!isValidSlotName(inputValue)) { + setSlotNameError( + t('invalidSlotName', 'Slot name must contain only letters, numbers, and dashes, with no spaces'), + ); + } else { + setSlotNameError(''); + } + }; + + const handleUpdatePackageTitle = () => { + if (!slotNameError && key && title && slotName) { + updatePackages(); + closeModal(); + } + }; + + const updatePackages = () => { + try { + if (title && slotName) { + const updatedSchema = { + ...schema, + '@openmrs/esm-patient-chart-app': { + ...schema['@openmrs/esm-patient-chart-app'], + extensionSlots: { + ...schema['@openmrs/esm-patient-chart-app'].extensionSlots, + 'patient-chart-dashboard-slot': { + add: [ + ...(schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot']?.add || + []), + `nav-group#${key}`, + ], + configure: { + ...schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot']?.configure, + [`nav-group#${key}`]: { + title, + slotName, + isExpanded: true, + }, + }, + }, + }, + }, + }; + + onSchemaChange(updatedSchema); + + setTitle(''); + setSlotName(''); + } + + showSnackbar({ + title: t('success', 'Success!'), + kind: 'success', + isLowContrast: true, + subtitle: t('packageCreated', 'New package created'), + }); + } catch (error) { + if (error instanceof Error) { + showSnackbar({ + title: t('errorCreatingPackage', 'Error creating package'), + kind: 'error', + subtitle: error?.message, + }); + } + } + }; + + return ( + <> + +
event.preventDefault()}> + + + ) => setTitle(event.target.value)} + /> + + + + + +
+ + + + + + ); +}; + +export default PackageModal; diff --git a/src/components/interactive-builder/add-submenu-modal.component.tsx b/src/components/interactive-builder/add-submenu-modal.component.tsx new file mode 100644 index 0000000..148621b --- /dev/null +++ b/src/components/interactive-builder/add-submenu-modal.component.tsx @@ -0,0 +1,165 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Form, FormGroup, ModalBody, ModalFooter, ModalHeader, Stack, TextInput } from '@carbon/react'; +import { showSnackbar } from '@openmrs/esm-framework'; +import { type Schema, type ExtensionSlot } from '../../types'; +import styles from './modals.scss'; + +interface NewSubMenuModalProps { + schema: Schema; + onSchemaChange: (schema: Schema) => void; + closeModal: () => void; +} + +const NewSubMenuModal: React.FC = ({ schema, onSchemaChange, closeModal }) => { + const { t } = useTranslation(); + const [programName, setProgramName] = useState(''); + const [menuName, setMenuName] = useState(''); + const [slotName, setSlotName] = useState(''); + + const formatProgramIdentifier = (program: string): string => { + return program + .split(' ') + .map((word) => word[0].toLowerCase()) + .join(''); + }; + + const createSubmenuKey = (programShort: string, menu: string): string => { + return `dashboard#${programShort}-${menu.split(' ').join('-').toLowerCase()}`; + }; + + const createSlot = (programShort: string, menu: string): string => { + return `${programShort}-${menu.split(' ').join('-').toLowerCase()}-dashboard-slot`; + }; + + const findSlotNameDynamically = useCallback(() => { + const patientChartSlot = schema['@openmrs/esm-patient-chart-app']?.extensionSlots?.['patient-chart-dashboard-slot']; + if (patientChartSlot && patientChartSlot.configure) { + const navGroupKey = Object.keys(patientChartSlot.configure).find( + (key) => key.startsWith('nav-group#') && patientChartSlot.configure[key]?.slotName, + ); + if (navGroupKey) { + return patientChartSlot.configure[navGroupKey]?.slotName; + } + } + return null; + }, [schema]); + + useEffect(() => { + const slot = findSlotNameDynamically(); + if (slot) { + setSlotName(slot); + } else { + showSnackbar({ + title: t('errorFindingSlotName', 'Error'), + kind: 'error', + subtitle: t('slotNameNotFound', 'Slot name not found in the schema'), + }); + } + }, [schema, findSlotNameDynamically, t]); + + const existingSlot = schema['@openmrs/esm-patient-chart-app'].extensionSlots[slotName] || { + add: [], + configure: {}, + }; + + const updateSchema = () => { + try { + const programShort = formatProgramIdentifier(programName); + const key = createSubmenuKey(programShort, menuName); + const slot = createSlot(programShort, menuName); + + const updatedSchema = { + ...schema, + '@openmrs/esm-patient-chart-app': { + ...schema['@openmrs/esm-patient-chart-app'], + extensionSlots: { + ...schema['@openmrs/esm-patient-chart-app'].extensionSlots, + [slotName]: { + add: [...existingSlot.add, key], + configure: { + ...existingSlot.configure, + [key]: { + title: menuName, + path: key.split('#')[1], + slot, + }, + }, + } as ExtensionSlot, + }, + }, + }; + + onSchemaChange(updatedSchema); + + // Show success notification + showSnackbar({ + title: t('success', 'Success!'), + kind: 'success', + isLowContrast: true, + subtitle: t('submenuCreated', 'New submenu created'), + }); + } catch (error) { + if (error instanceof Error) { + showSnackbar({ + title: t('errorCreatingSubmenu', 'Error creating submenu'), + kind: 'error', + subtitle: error.message, + }); + } + } + }; + + const handleCreateSubmenu = () => { + if (programName && menuName) { + updateSchema(); + closeModal(); + } + }; + + return ( + <> + +
event.preventDefault()}> + + + + ) => setProgramName(event.target.value)} + /> + + + ) => setMenuName(event.target.value)} + /> + + + +
+ + + + + + ); +}; + +export default NewSubMenuModal; diff --git a/src/components/interactive-builder/interactive-builder.component.tsx b/src/components/interactive-builder/interactive-builder.component.tsx new file mode 100644 index 0000000..e6ae9fb --- /dev/null +++ b/src/components/interactive-builder/interactive-builder.component.tsx @@ -0,0 +1,148 @@ +import React, { useCallback } from 'react'; +import { showModal } from '@openmrs/esm-framework'; +import { useTranslation } from 'react-i18next'; +import { v4 as uuidv4 } from 'uuid'; +import { Button, Accordion, AccordionItem } from '@carbon/react'; +import { Add } from '@carbon/react/icons'; +import { type DynamicExtensionSlot, type Schema } from '../../types'; +import styles from './interactive-builder.scss'; + +interface InteractiveBuilderProps { + schema: Schema; + onSchemaChange: (updatedSchema: Schema) => void; +} + +const InteractiveBuilder = ({ schema, onSchemaChange }: InteractiveBuilderProps) => { + const { t } = useTranslation(); + + const initializeSchema = useCallback(() => { + const dummySchema: Schema = { + id: uuidv4(), + '@openmrs/esm-patient-chart-app': { + extensionSlots: { + 'patient-chart-dashboard-slot': { + add: [], + configure: {}, + }, + }, + }, + }; + + if (!schema) { + onSchemaChange({ ...dummySchema }); + } + + return schema || dummySchema; + }, [onSchemaChange, schema]); + + const launchAddClinicalViewModal = useCallback(() => { + const schema = initializeSchema(); + const dispose = showModal('new-package-modal', { + closeModal: () => dispose(), + schema, + onSchemaChange, + }); + }, [onSchemaChange, initializeSchema]); + + const launchAddClinicalViewMenuModal = useCallback(() => { + const dispose = showModal('new-menu-modal', { + closeModal: () => dispose(), + schema, + onSchemaChange, + }); + }, [schema, onSchemaChange]); + + const getNavGroupTitle = (schema) => { + if (schema) { + const navGroupKey = Object.keys( + schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot'].configure, + )[0]; + + if (navGroupKey) { + return schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot'].configure[ + navGroupKey + ].title; + } + } + return null; + }; + + const navGroupTitle = getNavGroupTitle(schema); + + const packageSlotKey = 'patient-chart-dashboard-slot'; + const packageConfig = schema?.['@openmrs/esm-patient-chart-app'].extensionSlots[packageSlotKey]; + + const navGroupKey = packageConfig?.add[0]; + const navGroupConfig = packageConfig?.configure[navGroupKey]; + + const submenuSlotKey = navGroupConfig?.slotName; + const submenuConfig = schema?.['@openmrs/esm-patient-chart-app']?.extensionSlots[ + submenuSlotKey + ] as DynamicExtensionSlot; + + return ( +
+ {!navGroupTitle ? ( +
+ + {t( + 'interactiveBuilderHelperText', + 'The Interactive Builder lets you build your clinical view schema without writing JSON code. When done, click Save Package to save your clinical view package.', + )} + + +
+ ) : ( + + {t('interactiveBuilderInfo', 'Continue adding sub menus and configuring dashboards')} + + )} + + {schema && ( +
+
{navGroupTitle}
+
{t('clinicalViewMenus', 'Clinical View Submenus')}
+ {submenuConfig ? ( + submenuConfig.add.map((submenuKey) => { + const submenuDetails = submenuConfig.configure[submenuKey]; + return ( + + +

Menu Slot: {submenuDetails?.slot}

+

+ {t( + 'helperTextForAddDasboards', + 'Now configure dashboards to show on the patient chart when this submenu is clicked.', + )} +

+ +
+
+ ); + }) + ) : ( +

+ {t('noSubmenusText', 'No submenus views added yet. Click the button to add a new submenu link.')} +

+ )} + +
+ )} +
+ ); +}; + +export default InteractiveBuilder; diff --git a/src/components/interactive-builder/interactive-builder.scss b/src/components/interactive-builder/interactive-builder.scss new file mode 100644 index 0000000..67dcacb --- /dev/null +++ b/src/components/interactive-builder/interactive-builder.scss @@ -0,0 +1,116 @@ +@use '@carbon/colors'; +@use '@carbon/type'; + +.interactiveBuilderContainer { + margin: 1rem; +} +.container { + :global(.cds--modal-content:focus) { + outline: none; + } + + :global(.cds--tooltip) { + z-index: 1; + } +} + +.flexContainer { + display: flex; + align-items: center; +} + +.helperText { + @include type.type-style('body-compact-01'); + margin: 1rem 0rem; +} + +.subHeading { + font-size: 1rem; + margin: 1rem 0rem 0.5rem; +} + +.heading { + @include type.type-style('heading-01'); + margin: 1rem 0rem 0.5rem; +} + +.styledHeading { + @include type.type-style('heading-02'); + margin: 0.5rem 0rem; + + &:after { + content: ""; + display: block; + width: 2rem; + padding-top: 0.188rem; + border-bottom: 0.375rem solid var(--brand-03); + } +} + +.subheading { + @extend .heading; + @include type.type-style('heading-02'); +} + +.name { + @extend .heading; + @include type.type-style('heading-02'); +} + +.editableFieldsContainer { + margin: 1rem 0; + border-bottom: 1px solid colors.$gray-20; +} + +.addQuestionButton { + margin: 0.75rem 0; +} + +.header { + margin: 2rem 0; +} + +.explainer { + @include type.type-style('heading-02'); +} + +.smallParagraph { + font-style: italic; + font-size: 0.875rem; +} +.topHeader { + display: flex; + flex-direction: column; +} + +.startButton { + margin: 1rem 0; +} + +.editorContainer { + display: flex; + justify-content: space-between; + margin: 0.25rem 0.5rem; + align-items: center; + width: 100%; +} + +.buttonContainer { + span { + margin: 0rem 0.5rem; + } +} + + +.packageLabel { + @include type.type-style('heading-02'); + margin: 0.5rem 0rem; + + &:after { + content: ""; + display: block; + width: 2rem; + padding-top: 0.188rem; + border-bottom: 0.375rem solid var(--brand-03); + } +} diff --git a/src/components/interactive-builder/modals.scss b/src/components/interactive-builder/modals.scss new file mode 100644 index 0000000..4e3cc34 --- /dev/null +++ b/src/components/interactive-builder/modals.scss @@ -0,0 +1,29 @@ +@use '@carbon/layout'; + +.modalHeader { + :global { + .cds--modal-close-button { + position: absolute; + inset-block-start: 0; + inset-inline-end: 0; + margin: 0; + margin-top: calc(-1 * #{layout.$spacing-05}); + } + + .cds--modal-close { + background-color: rgba(0, 0, 0, 0); + + &:hover { + background-color: var(--cds-layer-hover); + } + } + + .cds--popover--left > .cds--popover > .cds--popover-content { + transform: translate(-4rem, 0.85rem); + } + + .cds--popover--left > .cds--popover > .cds--popover-caret { + transform: translate(-3.75rem, 1.25rem); + } + } +} diff --git a/src/components/schema-editor/dummy-schema.ts b/src/components/schema-editor/dummy-schema.ts new file mode 100644 index 0000000..1cc7ff7 --- /dev/null +++ b/src/components/schema-editor/dummy-schema.ts @@ -0,0 +1,17 @@ +export const dummySchema = { + $schema: './standard-schema.json', + '@openmrs/esm-patient-chart-app': { + extensionSlots: { + 'patient-chart-dashboard-slot': { + add: ['nav-group#hivCareTreatment'], + configure: { + 'nav-group#hivCareTreatment': { + title: 'HIV Care Treatment', + slotName: 'hiv-care-treatment-slot', + isExpanded: true, + }, + }, + }, + }, + }, +}; diff --git a/src/components/schema-editor/schema-editor.component.tsx b/src/components/schema-editor/schema-editor.component.tsx new file mode 100644 index 0000000..965c1d4 --- /dev/null +++ b/src/components/schema-editor/schema-editor.component.tsx @@ -0,0 +1,40 @@ +import React, { useEffect } from 'react'; +import AceEditor from 'react-ace'; +import 'ace-builds/webpack-resolver'; +import 'ace-builds/src-noconflict/ext-language_tools'; + +interface InteractiveBuilderProps { + onSchemaChange: (updatedSchema: string) => void; + stringifiedSchema: string; +} + +const SchemaEditor = ({ stringifiedSchema, onSchemaChange }: InteractiveBuilderProps) => { + const handleEditorChange = (newValue: string) => { + onSchemaChange(newValue); + }; + + return ( + + ); +}; + +export default SchemaEditor; diff --git a/src/components/view-editor/view-editor.component.tsx b/src/components/view-editor/view-editor.component.tsx index 457c247..7255fa3 100644 --- a/src/components/view-editor/view-editor.component.tsx +++ b/src/components/view-editor/view-editor.component.tsx @@ -1,12 +1,16 @@ -import React, { useState } from 'react'; -import styles from './view-editor.scss'; +import React, { useState, useCallback, useMemo, useEffect } from 'react'; import classNames from 'classnames'; -import { Column, Grid, IconButton } from '@carbon/react'; +import { useParams, useLocation } from 'react-router-dom'; +import { Column, CopyButton, Grid, IconButton, Button, FileUploader } from '@carbon/react'; import { type TFunction, useTranslation } from 'react-i18next'; -import { ArrowLeft, Maximize, Minimize } from '@carbon/react/icons'; +import { ArrowLeft, Maximize, Minimize, Download } from '@carbon/react/icons'; import Header from '../header/header.component'; -import { ConfigurableLink } from '@openmrs/esm-framework'; -import { Button } from '@carbon/react'; +import { ConfigurableLink, showSnackbar } from '@openmrs/esm-framework'; +import SchemaEditor from '../schema-editor/schema-editor.component'; +import InteractiveBuilder from '../interactive-builder/interactive-builder.component'; +import { type Schema } from '../../types'; + +import styles from './view-editor.scss'; interface TranslationFnProps { t: TFunction; @@ -14,10 +18,172 @@ interface TranslationFnProps { const ContentPackagesEditorContent: React.FC = ({ t }) => { const [isMaximized, setIsMaximized] = useState(false); - const responsiveSize = isMaximized ? 16 : 8; + + const [schema, setSchema] = useState(); + const [stringifiedSchema, setStringifiedSchema] = useState(schema ? JSON.stringify(schema, null, 2) : ''); + const [isSaving, setIsSaving] = useState(false); + + const { clinicalViewId } = useParams(); // Extract 'id' from the URL + const location = useLocation(); // To check if it's in 'edit' mode + + useEffect(() => { + if (clinicalViewId && location.pathname.includes('edit')) { + loadSchema(clinicalViewId); + } + }, [clinicalViewId, location]); + + const loadSchema = (id: string) => { + const savedSchema = localStorage.getItem(`packageJSON_${id}`); + if (savedSchema) { + const parsedSchema: Schema = JSON.parse(savedSchema); + setSchema(parsedSchema); + setStringifiedSchema(JSON.stringify(parsedSchema, null, 2)); + } + }; + const handleToggleMaximize = () => { setIsMaximized(!isMaximized); }; + + const handleSchemaChange = useCallback((updatedSchema: string) => { + setStringifiedSchema(updatedSchema); + }, []); + + const updateSchema = useCallback((updatedSchema: Schema) => { + setSchema(updatedSchema); + const stringfiedJson: string = JSON.stringify(updatedSchema); + setStringifiedSchema(stringfiedJson); + }, []); + + const renderSchemaChanges = useCallback(() => { + const parsedJson: Schema = JSON.parse(stringifiedSchema); + updateSchema(parsedJson); + setStringifiedSchema(JSON.stringify(parsedJson, null, 2)); + }, [stringifiedSchema, updateSchema]); + + const inputDummySchema = useCallback(() => { + const dummySchema: Schema = { + id: 'unique-schema-id-1', + '@openmrs/esm-patient-chart-app': { + extensionSlots: { + 'patient-chart-dashboard-slot': { + add: ['nav-group#testPackage'], + configure: { + 'nav-group#testPackage': { + title: 'Package One', + slotName: 'package-one-slot', + isExpanded: true, + }, + }, + }, + 'package-one-slot': { + add: ['dashboard#menuOne', 'dashboard#menuTwo'], + configure: { + 'dashboard#menuOne': { + title: 'First Menu', + slotName: 'first-menu-slot', + path: 'first-menu-path', + }, + 'dashboard#menuTwo': { + title: 'Second Menu', + slotName: 'second-menu-slot', + path: 'second-menu-path', + }, + }, + }, + }, + }, + }; + + setStringifiedSchema(JSON.stringify(dummySchema, null, 2)); + updateSchema({ ...dummySchema }); + }, [updateSchema]); + + const handleSchemaImport = (event: React.ChangeEvent) => { + const file = event.target.files[0]; + const reader = new FileReader(); + + reader.onload = (e) => { + const result = e.target?.result; + if (typeof result === 'string') { + const fileContent: string = result; + const parsedJson: Schema = JSON.parse(fileContent); + setSchema(parsedJson); + } else if (result instanceof ArrayBuffer) { + const decoder = new TextDecoder(); + const fileContent: string = decoder.decode(result); + const parsedJson: Schema = JSON.parse(fileContent); + setSchema(parsedJson); + } + }; + + reader.readAsText(file); + }; + + const downloadableSchema = useMemo( + () => + new Blob([JSON.stringify(schema, null, 2)], { + type: 'application/json', + }), + [schema], + ); + + const handleCopySchema = useCallback(async () => { + await navigator.clipboard.writeText(stringifiedSchema); + }, [stringifiedSchema]); + + const getNavGroupTitle = (schema) => { + if (schema) { + const navGroupKey = Object.keys( + schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot'].configure, + )[0]; + + if (navGroupKey) { + return schema['@openmrs/esm-patient-chart-app'].extensionSlots['patient-chart-dashboard-slot'].configure[ + navGroupKey + ].title; + } + } + return 'default_schema_name'; + }; + + const handleSavePackage = () => { + setIsSaving(true); + if (schema && schema.id) { + const existingSchema = localStorage.getItem(`packageJSON_${schema.id}`); + + if (existingSchema) { + // If it exists, update the schema + localStorage.setItem(`packageJSON_${schema.id}`, JSON.stringify(schema)); + showSnackbar({ + title: t('clinicalViewUpdated', 'Clinical view updated'), + kind: 'success', + subtitle: t('updateSuccessMessage', 'Clinical view updated successfully'), + }); + setIsSaving(false); + } else { + localStorage.setItem(`packageJSON_${schema.id}`, JSON.stringify(schema)); + showSnackbar({ + title: t('clinicalViewCreated', 'Clinical view saved'), + kind: 'success', + subtitle: t('creationSuccessMessage', 'Clinical view saved successfully'), + }); + setIsSaving(false); + } + } else { + setIsSaving(false); + showSnackbar({ + title: t('errorSaving', 'Error saving'), + kind: 'error', + subtitle: t('savingErrorMessage', 'There was an error saving a clinical view'), + }); + } + }; + + const navGroupTitle = getNavGroupTitle(schema); + const sanitizedTitle = navGroupTitle.replace(/\s+/g, '_'); + + const responsiveSize = isMaximized ? 16 : 8; const defaultEnterDelayInMs = 300; return ( @@ -30,22 +196,81 @@ const ContentPackagesEditorContent: React.FC = ({ t }) => {
{t('schemaEditor', 'Schema editor')} +
+ {!schema ? ( + + ) : null} + {!schema ? ( + + ) : null} + +
+ {schema ? ( + <> + + {isMaximized ? : } + + +
+ + + + + + ) : null} +
+
+
- <> - - {isMaximized ? : } - - -
Content of json
-
Content of preview
+
+ {t('interactiveBuilder', 'Interactive Builder')} +
+ +
+
+ +
+ +
@@ -55,7 +280,7 @@ const ContentPackagesEditorContent: React.FC = ({ t }) => { function BackButton({ t }: TranslationFnProps) { return (
- +
From c872353b5ad25b558e00e34e7db7fd6a7ebe26e6 Mon Sep 17 00:00:00 2001 From: hadijahkyampeire Date: Thu, 26 Sep 2024 03:20:14 +0300 Subject: [PATCH 4/4] remove react-router-dom --- .../schema-editor/schema-editor.component.tsx | 2 +- yarn.lock | 32 +------------------ 2 files changed, 2 insertions(+), 32 deletions(-) diff --git a/src/components/schema-editor/schema-editor.component.tsx b/src/components/schema-editor/schema-editor.component.tsx index 965c1d4..36d14d4 100644 --- a/src/components/schema-editor/schema-editor.component.tsx +++ b/src/components/schema-editor/schema-editor.component.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import AceEditor from 'react-ace'; import 'ace-builds/webpack-resolver'; import 'ace-builds/src-noconflict/ext-language_tools'; diff --git a/yarn.lock b/yarn.lock index 60f1787..05c07eb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3133,7 +3133,6 @@ __metadata: "@types/jest": "npm:^29.5.12" "@types/react": "npm:^18.3.2" "@types/react-dom": "npm:^18.3.0" - "@types/react-router-dom": "npm:^5.3.3" "@types/uuid": "npm:^10" "@types/webpack-env": "npm:^1.18.5" "@typescript-eslint/eslint-plugin": "npm:^7.9.0" @@ -3165,7 +3164,6 @@ __metadata: react-dom: "npm:^18.3.1" react-error-boundary: "npm:^4.0.13" react-i18next: "npm:^11.18.6" - react-router-dom: "npm:^6.26.2" rxjs: "npm:^6.6.7" sass: "npm:^1.67.0" swc-loader: "npm:^0.2.6" @@ -6241,13 +6239,6 @@ __metadata: languageName: node linkType: hard -"@types/history@npm:^4.7.11": - version: 4.7.11 - resolution: "@types/history@npm:4.7.11" - checksum: 10/1da529a3485f3015daf794effa3185493bf7dd2551c26932389c614f5a0aab76ab97645897d1eef9c74ead216a3848fcaa019f165bbd6e4b71da6eff164b4c68 - languageName: node - linkType: hard - "@types/html-minifier-terser@npm:^6.0.0": version: 6.1.0 resolution: "@types/html-minifier-terser@npm:6.1.0" @@ -6423,27 +6414,6 @@ __metadata: languageName: node linkType: hard -"@types/react-router-dom@npm:^5.3.3": - version: 5.3.3 - resolution: "@types/react-router-dom@npm:5.3.3" - dependencies: - "@types/history": "npm:^4.7.11" - "@types/react": "npm:*" - "@types/react-router": "npm:*" - checksum: 10/28c4ea48909803c414bf5a08502acbb8ba414669b4b43bb51297c05fe5addc4df0b8fd00e0a9d1e3535ec4073ef38aaafac2c4a2b95b787167d113bc059beff3 - languageName: node - linkType: hard - -"@types/react-router@npm:*": - version: 5.1.20 - resolution: "@types/react-router@npm:5.1.20" - dependencies: - "@types/history": "npm:^4.7.11" - "@types/react": "npm:*" - checksum: 10/72d78d2f4a4752ec40940066b73d7758a0824c4d0cbeb380ae24c8b1cdacc21a6fc835a99d6849b5b295517a3df5466fc28be038f1040bd870f8e39e5ded43a4 - languageName: node - linkType: hard - "@types/react@npm:*, @types/react@npm:^18.3.2": version: 18.3.7 resolution: "@types/react@npm:18.3.7" @@ -16232,7 +16202,7 @@ __metadata: languageName: node linkType: hard -"react-router-dom@npm:^6.26.2, react-router-dom@npm:^6.3.0": +"react-router-dom@npm:^6.3.0": version: 6.26.2 resolution: "react-router-dom@npm:6.26.2" dependencies: