From 24e590fa04435742310982fec3d7483ce669da6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com> Date: Mon, 8 Jan 2024 16:58:35 +0100 Subject: [PATCH] Merge remote-tracking branch 'origin/development' into MIN-169-display-legend --- .eslintrc.json | 9 +- package-lock.json | 59 +++++ package.json | 1 + setupTests.ts | 4 + .../Modal/LoginModal/LoginModal.component.tsx | 9 +- .../BioEntityDrawer.component.tsx | 8 +- .../Map/Drawer/Drawer.component.tsx | 2 + .../Annotations.component.test.tsx | 119 +++++++++ .../Annotations/Annotations.component.tsx | 40 +++ .../Drawer/ExportDrawer/Annotations/index.ts | 1 + .../CheckboxFilter.component.test.tsx | 109 ++++++++ .../CheckboxFilter.component.tsx | 105 ++++++++ .../ExportDrawer/CheckboxFilter/index.ts | 1 + .../CollapsibleSection.component.test.tsx | 37 +++ .../CollapsibleSection.component.tsx | 26 ++ .../ExportDrawer/CollapsibleSection/index.ts | 1 + .../Annotations.component.test.tsx | 122 +++++++++ .../Annotations/Annotations.component.tsx | 27 ++ .../Elements/Annotations/index.ts | 1 + .../Columns/Columns.component.test.tsx | 26 ++ .../Elements/Columns/Columns.component.tsx | 9 + .../Elements/Columns/Columns.constants.tsx | 86 +++++++ .../ExportDrawer/Elements/Columns/index.ts | 1 + .../Elements/Elements.component.tsx | 13 + .../Elements/Types/Types.component.test.tsx | 29 +++ .../Elements/Types/Types.component.tsx | 16 ++ .../Elements/Types/Types.utils.test.ts | 36 +++ .../Elements/Types/Types.utils.ts | 35 +++ .../ExportDrawer/Elements/Types/index.ts | 1 + .../Map/Drawer/ExportDrawer/Elements/index.ts | 1 + .../ExportDrawer.component.test.tsx | 70 +++++ .../ExportDrawer/ExportDrawer.component.tsx | 24 ++ .../TabButton/TabButton.component.test.tsx | 37 +++ .../TabButton/TabButton.component.tsx | 22 ++ .../Drawer/ExportDrawer/TabButton/index.ts | 1 + .../TabNavigator.component.test.tsx | 36 +++ .../TabNavigator/TabNavigator.component.tsx | 21 ++ .../TabNavigator/TabNavigator.constants.ts | 5 + .../TabNavigator/TabNavigator.types.ts | 3 + .../Drawer/ExportDrawer/TabNavigator/index.ts | 1 + .../Map/Drawer/ExportDrawer/index.ts | 1 + .../OverlaysDrawer.component.tsx | 21 +- .../FileUpload/FileUpload.component.test.tsx | 42 +++ .../FileUpload/FileUpload.component.tsx | 62 +++++ .../UserOverlayForm/FileUpload/index.ts | 1 + .../OverlaySelector.component.test.tsx | 48 ++++ .../OverlaySelector.component.tsx | 84 ++++++ .../UserOverlayForm/OverlaySelector/index.ts | 1 + .../UserOverlayForm.component.test.tsx | 240 ++++++++++++++++++ .../UserOverlayForm.component.tsx | 121 +++++++++ .../UserOverlayForm.constants.ts | 22 ++ .../UserOverlayForm/UserOverlayForm.types.ts | 1 + .../UserOverlayForm.utils.test.ts | 29 +++ .../UserOverlayForm/UserOverlayForm.utils.ts | 27 ++ .../hooks/useUserOverlayForm.test.ts | 59 +++++ .../hooks/useUserOverlayForm.ts | 143 +++++++++++ .../OverlaysDrawer/UserOverlayForm/index.ts | 1 + .../UserOverlays.component.test.tsx | 83 ++++++ .../UserOverlays/UserOverlays.component.tsx | 24 +- .../BioEntitiesAccordion.component.test.tsx | 6 +- .../ResultsList.component.test.tsx | 3 + .../SearchDrawerWrapper.component.test.tsx | 6 + .../createOverlayGeometryFeature.ts | 12 +- .../overlaysLayer/getOverlayFeatures.ts | 30 ++- .../getPolygonLatitudeCoordinates.test.ts | 40 +++ .../getPolygonLatitudeCoordinates.ts | 26 ++ .../overlaysLayer/useOlMapOverlaysLayer.ts | 35 ++- .../reactionsLayer/useOlMapReactionsLayer.ts | 3 +- src/models/bioEntitySchema.ts | 11 +- src/models/configurationSchema.ts | 105 ++++++++ src/models/fixtures/overlaysFixture.ts | 18 +- src/models/fixtures/statisticsFixture.ts | 8 + src/models/mapOverlay.ts | 23 ++ src/models/statisticsSchema.ts | 7 + src/redux/apiPath.ts | 5 + .../configuration/configuration.adapter.ts | 12 +- src/redux/configuration/configuration.mock.ts | 38 ++- .../configuration/configuration.reducers.ts | 26 +- .../configuration/configuration.selectors.ts | 23 +- .../configuration/configuration.slice.ts | 3 +- .../configuration/configuration.thunks.ts | 14 +- .../configuration/configuration.types.ts | 4 + src/redux/drawer/drawer.constants.ts | 3 + src/redux/drawer/drawer.reducers.ts | 7 + src/redux/drawer/drawer.selectors.ts | 10 + src/redux/drawer/drawer.slice.ts | 3 + src/redux/drawer/drawer.types.ts | 5 + src/redux/drawer/drawerFixture.ts | 49 ++++ .../overlayBioEntity.selector.ts | 32 ++- .../overlayBioEntity.thunk.ts | 18 ++ .../overlayBioEntity.utils.test.ts | 64 +++++ .../overlayBioEntity.utils.ts | 43 ++++ src/redux/overlays/overlays.constants.ts | 2 + src/redux/overlays/overlays.mock.ts | 17 ++ src/redux/overlays/overlays.reducers.test.ts | 72 +++++- src/redux/overlays/overlays.reducers.ts | 15 +- src/redux/overlays/overlays.selectors.ts | 9 + src/redux/overlays/overlays.slice.ts | 7 +- src/redux/overlays/overlays.thunks.ts | 144 ++++++++++- src/redux/overlays/overlays.types.ts | 10 +- src/redux/project/project.selectors.ts | 5 + src/redux/root/init.thunks.ts | 15 +- src/redux/root/query.selectors.ts | 28 +- src/redux/root/root.fixtures.ts | 2 + src/redux/statistics/statistics.mock.ts | 8 + .../statistics/statistics.reducers.test.ts | 70 +++++ src/redux/statistics/statistics.reducers.ts | 18 ++ src/redux/statistics/statistics.selectors.ts | 16 ++ src/redux/statistics/statistics.slice.ts | 20 ++ src/redux/statistics/statistics.thunks.ts | 17 ++ src/redux/statistics/statistics.types.ts | 4 + src/redux/store.ts | 2 + src/shared/Input/Input.component.test.tsx | 70 +++++ src/shared/Input/Input.component.tsx | 33 +++ src/shared/Input/index.ts | 1 + .../Textarea/Textarea.component.test.tsx | 53 ++++ src/shared/Textarea/Textarea.component.tsx | 22 ++ src/shared/Textarea/index.ts | 1 + src/types/OLrendering.ts | 4 + src/types/models.ts | 14 +- src/types/query.ts | 3 + src/utils/number/roundToTwoDigits.test.ts | 32 +++ src/utils/number/roundToTwoDigits.ts | 2 + src/utils/parseQueryToTypes.test.ts | 16 ++ src/utils/parseQueryToTypes.ts | 1 + .../useReduxBusQueryManager.test.ts | 4 + 126 files changed, 3502 insertions(+), 91 deletions(-) create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Annotations/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/Elements/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabButton/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts create mode 100644 src/components/Map/Drawer/ExportDrawer/index.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts create mode 100644 src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx create mode 100644 src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.test.ts create mode 100644 src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.ts create mode 100644 src/models/configurationSchema.ts create mode 100644 src/models/fixtures/statisticsFixture.ts create mode 100644 src/models/statisticsSchema.ts create mode 100644 src/redux/configuration/configuration.types.ts create mode 100644 src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts create mode 100644 src/redux/overlays/overlays.constants.ts create mode 100644 src/redux/statistics/statistics.mock.ts create mode 100644 src/redux/statistics/statistics.reducers.test.ts create mode 100644 src/redux/statistics/statistics.reducers.ts create mode 100644 src/redux/statistics/statistics.selectors.ts create mode 100644 src/redux/statistics/statistics.slice.ts create mode 100644 src/redux/statistics/statistics.thunks.ts create mode 100644 src/redux/statistics/statistics.types.ts create mode 100644 src/shared/Input/Input.component.test.tsx create mode 100644 src/shared/Input/Input.component.tsx create mode 100644 src/shared/Input/index.ts create mode 100644 src/shared/Textarea/Textarea.component.test.tsx create mode 100644 src/shared/Textarea/Textarea.component.tsx create mode 100644 src/shared/Textarea/index.ts create mode 100644 src/utils/number/roundToTwoDigits.test.ts create mode 100644 src/utils/number/roundToTwoDigits.ts diff --git a/.eslintrc.json b/.eslintrc.json index 3ed8a0da..8712576f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -89,7 +89,14 @@ "config": "./tailwind.config.ts" } ], - "prettier/prettier": "error" + "prettier/prettier": "error", + "jsx-a11y/label-has-associated-control": [ + 2, + { + "controlComponents": ["Input"], + "depth": 3 + } + ] }, "overrides": [ { diff --git a/package-lock.json b/package-lock.json index 268c6f7e..4fb5b060 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", @@ -3050,6 +3051,14 @@ "node": ">= 4.0.0" } }, + "node_modules/attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==", + "engines": { + "node": ">=4" + } + }, "node_modules/autoprefixer": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", @@ -6349,6 +6358,17 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "dependencies": { + "tslib": "^2.4.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -11359,6 +11379,22 @@ "react": "^18.2.0" } }, + "node_modules/react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "dependencies": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", @@ -15894,6 +15930,11 @@ "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==", "dev": true }, + "attr-accept": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.2.tgz", + "integrity": "sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==" + }, "autoprefixer": { "version": "10.4.15", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.15.tgz", @@ -18287,6 +18328,14 @@ "flat-cache": "^3.0.4" } }, + "file-selector": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz", + "integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==", + "requires": { + "tslib": "^2.4.0" + } + }, "fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -21770,6 +21819,16 @@ "scheduler": "^0.23.0" } }, + "react-dropzone": { + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.2.3.tgz", + "integrity": "sha512-O3om8I+PkFKbxCukfIR3QAGftYXDZfOE2N1mr/7qebQJHs7U+/RSL/9xomJNpRg9kM5h9soQSdf0Gc7OHF5Fug==", + "requires": { + "attr-accept": "^2.2.2", + "file-selector": "^0.6.0", + "prop-types": "^15.8.1" + } + }, "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", diff --git a/package.json b/package.json index deee43c5..081b8f16 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", + "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", diff --git a/setupTests.ts b/setupTests.ts index e8c65391..1d944c81 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -10,6 +10,10 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ jest.mock('next/router', () => require('next-router-mock')); +global.TextEncoder = jest.fn().mockImplementation(() => ({ + encode: jest.fn(), +})); + const localStorageMock = (() => { let store: { [key: PropertyKey]: string; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx index 354b5337..40ac9436 100644 --- a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx @@ -3,6 +3,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { loadingUserSelector } from '@/redux/user/user.selectors'; import { login } from '@/redux/user/user.thunks'; import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; import Link from 'next/link'; import React from 'react'; @@ -27,26 +28,26 @@ export const LoginModal: React.FC = () => { <form onSubmit={handleSubmit}> <label className="mb-5 block text-sm font-semibold" htmlFor="login"> Login: - <input + <Input type="text" name="login" id="login" placeholder="Your login here.." value={credentials.login} onChange={handleChange} - className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + className="mt-2.5 text-sm font-medium text-font-400" /> </label> <label className="text-sm font-semibold" htmlFor="password"> Password: - <input + <Input type="password" name="password" id="password" placeholder="Your password here.." value={credentials.password} onChange={handleChange} - className="mt-2.5 h-10 w-full rounded-s border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + className="mt-2.5 text-sm font-medium text-font-400" /> </label> <div className="mb-10 text-right"> diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 8bf424af..2d60852e 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx @@ -1,7 +1,7 @@ -import { DrawerHeading } from '@/shared/DrawerHeading'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { searchedFromMapBioEntityElement } from '@/redux/bioEntity/bioEntity.selectors'; import { ZERO } from '@/constants/common'; +import { searchedFromMapBioEntityElement } from '@/redux/bioEntity/bioEntity.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { DrawerHeading } from '@/shared/DrawerHeading'; import { AnnotationItem } from './AnnotationItem'; import { AssociatedSubmap } from './AssociatedSubmap'; @@ -26,7 +26,7 @@ export const BioEntityDrawer = (): React.ReactNode => { /> <div className="flex flex-col gap-6 p-6"> <div className="text-sm font-normal"> - Compartment: <b className="font-semibold">{bioEntityData.compartment}</b> + Compartment: <b className="font-semibold">{bioEntityData.compartmentName}</b> </div> {bioEntityData.fullName && ( <div className="text-sm font-normal"> diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index ace83985..b55022ff 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -7,6 +7,7 @@ import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrappe import { SubmapsDrawer } from './SubmapsDrawer'; import { OverlaysDrawer } from './OverlaysDrawer'; import { BioEntityDrawer } from './BioEntityDrawer/BioEntityDrawer.component'; +import { ExportDrawer } from './ExportDrawer'; export const Drawer = (): JSX.Element => { const { isOpen, drawerName } = useAppSelector(drawerSelector); @@ -24,6 +25,7 @@ export const Drawer = (): JSX.Element => { {isOpen && drawerName === 'reaction' && <ReactionDrawer />} {isOpen && drawerName === 'overlays' && <OverlaysDrawer />} {isOpen && drawerName === 'bio-entity' && <BioEntityDrawer />} + {isOpen && drawerName === 'export' && <ExportDrawer />} </div> ); }; diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx new file mode 100644 index 00000000..df05c8e0 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx @@ -0,0 +1,119 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { act } from 'react-dom/test-utils'; +import { Annotations } from './Annotations.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Annotations /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Annotations - component', () => { + it('should display annotations checkboxes when fetching data is successful', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('compartment')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + }); + }); + it('should not display annotations checkboxes when fetching data fails', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display annotations checkboxes when fetched data is empty object', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: {}, + }, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should display loading message when fetching data is pending', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx new file mode 100644 index 00000000..a68fd389 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx @@ -0,0 +1,40 @@ +/* eslint-disable no-magic-numbers */ +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, +} from '@/shared/Accordion'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + elementAnnotationsSelector, + loadingStatisticsSelector, +} from '@/redux/statistics/statistics.selectors'; +import { CheckboxFilter } from '../CheckboxFilter'; + +export const Annotations = (): React.ReactNode => { + const loadingStatistics = useAppSelector(loadingStatisticsSelector); + const elementAnnotations = useAppSelector(elementAnnotationsSelector); + const isPending = loadingStatistics === 'pending'; + + const mappedElementAnnotations = elementAnnotations + ? Object.keys(elementAnnotations)?.map(el => ({ id: el, label: el })) + : []; + + return ( + <Accordion allowZeroExpanded> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>Select annotations</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel> + {isPending && <p>Loading...</p>} + {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > 0 && ( + <CheckboxFilter options={mappedElementAnnotations} /> + )} + </AccordionItemPanel> + </AccordionItem> + </Accordion> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts new file mode 100644 index 00000000..3b82aaf7 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts @@ -0,0 +1 @@ +export { Annotations } from './Annotations.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx new file mode 100644 index 00000000..1fb3437a --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.test.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; + +import { CheckboxFilter } from './CheckboxFilter.component'; + +const options = [ + { id: '1', label: 'Option 1' }, + { id: '2', label: 'Option 2' }, + { id: '3', label: 'Option 3' }, +]; + +describe('CheckboxFilter - component', () => { + it('should render CheckboxFilter properly', () => { + render(<CheckboxFilter options={options} />); + expect(screen.getByTestId('search')).toBeInTheDocument(); + }); + + it('should filter options based on search term', async () => { + render(<CheckboxFilter options={options} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: `Option 1` } }); + + expect(screen.getByLabelText('Option 1')).toBeInTheDocument(); + expect(screen.queryByText('Option 2')).not.toBeInTheDocument(); + expect(screen.queryByText('Option 3')).not.toBeInTheDocument(); + }); + + it('should handle checkbox value change', async () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + const checkbox = screen.getByLabelText('Option 1'); + + fireEvent.click(checkbox); + + expect(onCheckedChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + }); + + it('should call onFilterChange when searching new term', async () => { + const onFilterChange = jest.fn(); + render(<CheckboxFilter options={options} onFilterChange={onFilterChange} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: 'Option 1' } }); + + expect(onFilterChange).toHaveBeenCalledWith([{ id: '1', label: 'Option 1' }]); + }); + it('should display message when no elements are found', async () => { + render(<CheckboxFilter options={options} />); + const searchInput = screen.getByLabelText('search-input'); + + fireEvent.change(searchInput, { target: { value: 'Nonexistent Option' } }); + + expect(screen.getByText('No matching elements found.')).toBeInTheDocument(); + }); + it('should display message when options are empty', () => { + const onFilterChange = jest.fn(); + render(<CheckboxFilter options={[]} onFilterChange={onFilterChange} />); + + expect(screen.getByText('No matching elements found.')).toBeInTheDocument(); + }); + it('should handle multiple checkbox selection', () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + + const checkbox1 = screen.getByLabelText('Option 1'); + const checkbox2 = screen.getByLabelText('Option 2'); + + fireEvent.click(checkbox1); + fireEvent.click(checkbox2); + + expect(onCheckedChange).toHaveBeenCalledWith([ + { id: '1', label: 'Option 1' }, + { id: '2', label: 'Option 2' }, + ]); + }); + it('should handle unchecking a checkbox', () => { + const onCheckedChange = jest.fn(); + render(<CheckboxFilter options={options} onCheckedChange={onCheckedChange} />); + + const checkbox = screen.getByLabelText('Option 1'); + + fireEvent.click(checkbox); // Check + fireEvent.click(checkbox); // Uncheck + + expect(onCheckedChange).toHaveBeenCalledWith([]); + }); + it('should render search input when isSearchEnabled is true', () => { + render(<CheckboxFilter options={options} />); + const searchInput = screen.getByLabelText('search-input'); + expect(searchInput).toBeInTheDocument(); + }); + + it('should not render search input when isSearchEnabled is false', () => { + render(<CheckboxFilter options={options} isSearchEnabled={false} />); + const searchInput = screen.queryByLabelText('search-input'); + expect(searchInput).not.toBeInTheDocument(); + }); + + it('should not filter options based on search input when isSearchEnabled is false', () => { + render(<CheckboxFilter options={options} isSearchEnabled={false} />); + const searchInput = screen.queryByLabelText('search-input'); + expect(searchInput).not.toBeInTheDocument(); + options.forEach(option => { + const checkboxLabel = screen.getByText(option.label); + expect(checkboxLabel).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx new file mode 100644 index 00000000..68dbe9c6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx @@ -0,0 +1,105 @@ +/* eslint-disable no-magic-numbers */ +import Image from 'next/image'; +import React, { useEffect, useState } from 'react'; +import lensIcon from '@/assets/vectors/icons/lens.svg'; +import { twMerge } from 'tailwind-merge'; + +type CheckboxItem = { id: string; label: string }; + +type CheckboxFilterProps = { + options: CheckboxItem[]; + onFilterChange?: (filteredItems: CheckboxItem[]) => void; + onCheckedChange?: (filteredItems: CheckboxItem[]) => void; + isSearchEnabled?: boolean; +}; + +export const CheckboxFilter = ({ + options, + onFilterChange, + onCheckedChange, + isSearchEnabled = true, +}: CheckboxFilterProps): React.ReactNode => { + const [searchTerm, setSearchTerm] = useState(''); + const [filteredOptions, setFilteredOptions] = useState<CheckboxItem[]>(options); + const [checkedCheckboxes, setCheckedCheckboxes] = useState<CheckboxItem[]>([]); + + const filterOptions = (term: string): void => { + const filteredItems = options.filter(item => + item.label.toLowerCase().includes(term.toLowerCase()), + ); + + setFilteredOptions(filteredItems); + onFilterChange?.(filteredItems); + }; + + const handleSearchTermChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const newSearchTerm = e.target.value; + setSearchTerm(newSearchTerm); + filterOptions(newSearchTerm); + }; + + const handleCheckboxChange = (option: CheckboxItem): void => { + const newCheckedCheckboxes = checkedCheckboxes.includes(option) + ? checkedCheckboxes.filter(item => item !== option) + : [...checkedCheckboxes, option]; + + setCheckedCheckboxes(newCheckedCheckboxes); + onCheckedChange?.(newCheckedCheckboxes); + }; + + useEffect(() => { + setFilteredOptions(options); + }, [options]); + + return ( + <div className="relative" data-testid="checkbox-filter"> + {isSearchEnabled && ( + <div className="relative" data-testid="search"> + <input + name="search-input" + aria-label="search-input" + value={searchTerm} + onChange={handleSearchTermChange} + placeholder="Search..." + className="h-9 w-full rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" + /> + + <Image + src={lensIcon} + alt="lens icon" + height={16} + width={16} + className="absolute right-4 top-2.5" + /> + </div> + )} + + <div + className={twMerge( + 'mb-6 max-h-[300px] overflow-y-auto py-2.5 pr-2.5', + isSearchEnabled && 'mt-6', + )} + > + {filteredOptions.length === 0 ? ( + <p className="w-full text-sm text-font-400">No matching elements found.</p> + ) : ( + <ul className="columns-2 gap-8"> + {filteredOptions.map(option => ( + <li key={option.id} className="mb-5 flex items-center gap-x-2"> + <input + type="checkbox" + id={option.id} + className=" h-4 w-4 shrink-0 accent-primary-500" + onChange={(): void => handleCheckboxChange(option)} + /> + <label htmlFor={option.id} className="break-all text-sm"> + {option.label} + </label> + </li> + ))} + </ul> + )} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts new file mode 100644 index 00000000..45a47c9f --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/index.ts @@ -0,0 +1 @@ +export { CheckboxFilter } from './CheckboxFilter.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx new file mode 100644 index 00000000..99569fa0 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { CollapsibleSection } from './CollapsibleSection.component'; + +describe('CollapsibleSection - component', () => { + it('should render with title and content', () => { + render( + <CollapsibleSection title="Section"> + <div>Content</div> + </CollapsibleSection>, + ); + + expect(screen.getByText('Section')).toBeInTheDocument(); + expect(screen.getByText('Content')).toBeInTheDocument(); + }); + + it('should collapse and expands on button click', () => { + render( + <CollapsibleSection title="Test Section"> + <div>Test Content</div> + </CollapsibleSection>, + ); + + const button = screen.getByText('Test Section'); + const content = screen.getByText('Test Content'); + + expect(content).not.toBeVisible(); + + // Expand + fireEvent.click(button); + expect(content).toBeVisible(); + + // Collapse + fireEvent.click(button); + expect(content).not.toBeVisible(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx new file mode 100644 index 00000000..b0d478bb --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/CollapsibleSection.component.tsx @@ -0,0 +1,26 @@ +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, +} from '@/shared/Accordion'; + +type CollapsibleSectionProps = { + title: string; + children: React.ReactNode; +}; + +export const CollapsibleSection = ({ + title, + children, +}: CollapsibleSectionProps): React.ReactNode => ( + <Accordion allowZeroExpanded> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>{title}</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel>{children}</AccordionItemPanel> + </AccordionItem> + </Accordion> +); diff --git a/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts new file mode 100644 index 00000000..7d4a61e4 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/CollapsibleSection/index.ts @@ -0,0 +1 @@ +export { CollapsibleSection } from './CollapsibleSection.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx new file mode 100644 index 00000000..df19cb66 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx @@ -0,0 +1,122 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { act } from 'react-dom/test-utils'; +import { Annotations } from './Annotations.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Annotations /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Annotations - component', () => { + it('should display annotations checkboxes when fetching data is successful', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: { + compartment: 1, + pathway: 0, + }, + }, + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('compartment')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + }); + }); + it('should not display annotations checkboxes when fetching data fails', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display annotations checkboxes when fetched data is empty object', async () => { + renderComponent({ + statistics: { + data: { + ...statisticsFixture, + elementAnnotations: {}, + }, + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + + it('should display loading message when fetching data is pending', async () => { + renderComponent({ + statistics: { + data: undefined, + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select annotations')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx new file mode 100644 index 00000000..f3795e9b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + elementAnnotationsSelector, + loadingStatisticsSelector, +} from '@/redux/statistics/statistics.selectors'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; + +export const Annotations = (): React.ReactNode => { + const loadingStatistics = useAppSelector(loadingStatisticsSelector); + const elementAnnotations = useAppSelector(elementAnnotationsSelector); + const isPending = loadingStatistics === 'pending'; + + const mappedElementAnnotations = elementAnnotations + ? Object.keys(elementAnnotations)?.map(el => ({ id: el, label: el })) + : []; + + return ( + <CollapsibleSection title="Select annotations"> + {isPending && <p>Loading...</p>} + {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > 0 && ( + <CheckboxFilter options={mappedElementAnnotations} /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts new file mode 100644 index 00000000..3b82aaf7 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts @@ -0,0 +1 @@ +export { Annotations } from './Annotations.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx new file mode 100644 index 00000000..381ba5cc --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx @@ -0,0 +1,26 @@ +import { render, screen } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { Columns } from './Columns.component'; + +describe('Columns - component', () => { + it('should display select column accordion', async () => { + render(<Columns />); + + expect(screen.getByText('Select column')).toBeInTheDocument(); + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + }); + it('should display columns checkboxes', async () => { + render(<Columns />); + + expect(screen.getByText('Select column')).toBeInTheDocument(); + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).toBeVisible(); + expect(screen.queryByLabelText('References')).toBeVisible(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx new file mode 100644 index 00000000..c6d8084f --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx @@ -0,0 +1,9 @@ +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { COLUMNS } from './Columns.constants'; + +export const Columns = (): React.ReactNode => ( + <CollapsibleSection title="Select column"> + <CheckboxFilter options={COLUMNS} isSearchEnabled={false} /> + </CollapsibleSection> +); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx new file mode 100644 index 00000000..e2ece6b5 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx @@ -0,0 +1,86 @@ +export const COLUMNS = [ + { + id: 'id', + label: 'ID', + }, + { + id: 'description', + label: 'Description', + }, + { + id: 'modelId', + label: 'Map id', + }, + { + id: 'mapName', + label: 'Map name', + }, + { + id: 'symbol', + label: 'Symbol', + }, + { + id: 'abbreviation', + label: 'Abbreviation', + }, + { + id: 'synonyms', + label: 'Synonyms', + }, + { + id: 'references', + label: 'References', + }, + { + id: 'name', + label: 'Name', + }, + { + id: 'type', + label: 'Type', + }, + { + id: 'complexId', + label: 'Complex id', + }, + { + id: 'complexName', + label: 'Complex name', + }, + { + id: 'compartmentId', + label: 'Compartment/Pathway id', + }, + { + id: 'compartmentName', + label: 'Compartment/Pathway name', + }, + { + id: 'charge', + label: 'Charge', + }, + { + id: 'fullName', + label: 'Full name', + }, + { + id: 'formula', + label: 'Formula', + }, + { + id: 'formerSymbols', + label: 'Former symbols', + }, + { + id: 'linkedSubmodelId', + label: 'Linked submap id', + }, + { + id: 'elementId', + label: 'Element external id', + }, + { + id: 'ALL', + label: 'All', + }, +]; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts new file mode 100644 index 00000000..167db867 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts @@ -0,0 +1 @@ +export { Columns } from './Columns.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx new file mode 100644 index 00000000..c4d5d6f4 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -0,0 +1,13 @@ +import { Types } from './Types'; +import { Annotations } from '../Annotations'; +import { Columns } from './Columns'; + +export const Elements = (): React.ReactNode => { + return ( + <div data-testid="elements-tab"> + <Types /> + <Columns /> + <Annotations /> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx new file mode 100644 index 00000000..4d228509 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { Types } from './Types.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Types /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Types Component', () => { + test('renders without crashing', () => { + renderComponent(); + expect(screen.getByText('Select types')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx new file mode 100644 index 00000000..0c37bdf6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx @@ -0,0 +1,16 @@ +import { elementTypesSelector } from '@/redux/configuration/configuration.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { getCheckboxElements } from './Types.utils'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; + +export const Types = (): React.ReactNode => { + const elementTypes = useAppSelector(elementTypesSelector); + const checkboxElements = getCheckboxElements(elementTypes); + + return ( + <CollapsibleSection title="Select types"> + {checkboxElements && <CheckboxFilter options={checkboxElements} isSearchEnabled={false} />} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts new file mode 100644 index 00000000..34e10ae6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts @@ -0,0 +1,36 @@ +import { getCheckboxElements } from './Types.utils'; + +describe('getCheckboxElements', () => { + it('should return an empty array when elementTypes is undefined', () => { + const result = getCheckboxElements(undefined); + expect(result).toEqual([]); + }); + + it('should map elementTypes to MappedElementTypes and exclude duplicates based on name and parentClass', () => { + const elementTypes = [ + { className: 'class1', name: 'type1', parentClass: 'parent1' }, + { className: 'class2', name: 'type2', parentClass: 'parent2' }, + { className: 'class1', name: 'type1', parentClass: 'parent1' }, + { className: 'class3', name: 'type3', parentClass: 'parent3' }, + { className: 'class2', name: 'type2', parentClass: 'parent2' }, + ]; + + const result = getCheckboxElements(elementTypes); + + expect(result).toEqual([ + { id: 'type1', label: 'type1' }, + { id: 'type2', label: 'type2' }, + { id: 'type3', label: 'type3' }, + ]); + }); + + it('should handle an empty array of elementTypes', () => { + const result = getCheckboxElements([]); + expect(result).toEqual([]); + }); + + it('should return an empty array when elementTypes is undefined', () => { + const result = getCheckboxElements(undefined); + expect(result).toEqual([]); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts new file mode 100644 index 00000000..a8a7cc99 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts @@ -0,0 +1,35 @@ +type ElementTypes = + | { + className: string; + name: string; + parentClass: string; + }[] + | undefined; + +type MappedElementTypes = { id: string; label: string }[]; + +type PresenceMap = { [key: string]: boolean }; + +export const getCheckboxElements = (elementTypes: ElementTypes): MappedElementTypes => { + if (!elementTypes) return []; + + const excludedTypes: PresenceMap = {}; + elementTypes?.forEach(type => { + excludedTypes[type.parentClass] = true; + }); + + const mappedElementTypes: MappedElementTypes = []; + const processedNames: PresenceMap = {}; + + elementTypes.forEach(elementType => { + if (excludedTypes[elementType.className] || processedNames[elementType.name]) return; + + processedNames[elementType.name] = true; + mappedElementTypes.push({ + id: elementType.name, + label: elementType.name, + }); + }); + + return mappedElementTypes; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts new file mode 100644 index 00000000..ce8a0cc1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts @@ -0,0 +1 @@ +export { Types } from './Types.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/index.ts new file mode 100644 index 00000000..4a0d339a --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Elements/index.ts @@ -0,0 +1 @@ +export { Elements } from './Elements.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx new file mode 100644 index 00000000..cec6029b --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.test.tsx @@ -0,0 +1,70 @@ +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { render, screen } from '@testing-library/react'; +import { openedExportDrawerFixture } from '@/redux/drawer/drawerFixture'; +import { act } from 'react-dom/test-utils'; +import { ExportDrawer } from './ExportDrawer.component'; +import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ExportDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ExportDrawer - component', () => { + it('should display drawer heading and tab names', () => { + renderComponent(); + + expect(screen.getByText('Export')).toBeInTheDocument(); + + Object.keys(TAB_NAMES).forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + it('should close drawer after clicking close button', () => { + const { store } = renderComponent({ + drawer: openedExportDrawerFixture, + }); + const closeButton = screen.getByRole('close-drawer-button'); + + closeButton.click(); + + const { + drawer: { isOpen }, + } = store.getState(); + + expect(isOpen).toBe(false); + }); + it('should set elements as initial tab', () => { + renderComponent(); + + expect(screen.getByTestId('elements-tab')).toBeInTheDocument(); + }); + it('should set correct tab on tab change', () => { + renderComponent(); + const currentTab = screen.getByRole('button', { current: true }); + const networkTab = screen.getByText(/network/i); + const elementsTab = screen.getByTestId('elements-tab'); + expect(currentTab).not.toBe(networkTab); + expect(screen.getByTestId('elements-tab')).toBeInTheDocument(); + + act(() => { + networkTab.click(); + }); + expect(screen.getByRole('button', { current: true })).toBe(networkTab); + expect(elementsTab).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx new file mode 100644 index 00000000..068348d1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx @@ -0,0 +1,24 @@ +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useState } from 'react'; +import { TabNavigator } from './TabNavigator'; +import { Elements } from './Elements'; +import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; +import { TabNames } from './TabNavigator/TabNavigator.types'; + +export const ExportDrawer = (): React.ReactNode => { + const [activeTab, setActiveTab] = useState<TabNames>(TAB_NAMES.ELEMENTS); + + const handleTabChange = (tabName: TabNames): void => { + setActiveTab(tabName); + }; + + return ( + <div data-testid="export-drawer" className="h-full max-h-full"> + <DrawerHeading title="Export" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto px-6"> + <TabNavigator activeTab={activeTab} onTabChange={handleTabChange} /> + {activeTab === TAB_NAMES.ELEMENTS && <Elements />} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx new file mode 100644 index 00000000..e4c872bd --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.test.tsx @@ -0,0 +1,37 @@ +import { RenderResult, fireEvent, render, screen } from '@testing-library/react'; +import { TabButton } from './TabButton.component'; + +const mockHandleChangeTab = jest.fn(); + +const renderTabButton = (label: string, active = false): RenderResult => + render(<TabButton label={label} handleChangeTab={mockHandleChangeTab} active={active} />); + +describe('TabButton - component', () => { + it('should render TabButton with custom label', () => { + renderTabButton('Map'); + + expect(screen.getByText('Map')).toBeInTheDocument(); + }); + + it('should handle click event', () => { + renderTabButton('Network'); + + fireEvent.click(screen.getByText('Network')); + expect(mockHandleChangeTab).toHaveBeenCalled(); + }); + + it('should indicate active tab correctly', () => { + renderTabButton('Graphics', true); + + const currentTab = screen.getByRole('button', { current: true }); + expect(currentTab).toHaveTextContent('Graphics'); + }); + it('should indicate not active tab correctly', () => { + renderTabButton('Graphics'); + + const activeTab = screen.queryByRole('button', { current: true }); + const graphicsTab = screen.getByRole('button', { current: false }); + expect(activeTab).not.toBeInTheDocument(); + expect(graphicsTab).toHaveTextContent('Graphics'); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx new file mode 100644 index 00000000..c30a1082 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/TabButton.component.tsx @@ -0,0 +1,22 @@ +import { twMerge } from 'tailwind-merge'; + +type TabButtonProps = { + handleChangeTab: () => void; + active: boolean; + label: string; +}; + +export const TabButton = ({ handleChangeTab, active, label }: TabButtonProps): React.ReactNode => ( + <button + type="button" + className={twMerge( + 'text-sm font-normal text-[#979797]', + active && + 'relative py-2.5 font-semibold leading-6 text-cetacean-blue before:absolute before:inset-x-0 before:top-0 before:block before:h-1 before:rounded-b before:bg-primary-500 before:content-[""]', + )} + aria-current={active} + onClick={handleChangeTab} + > + {label} + </button> +); diff --git a/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts b/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts new file mode 100644 index 00000000..f22cacf6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabButton/index.ts @@ -0,0 +1 @@ +export { TabButton } from './TabButton.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx new file mode 100644 index 00000000..c604f80e --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.test.tsx @@ -0,0 +1,36 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { TAB_NAMES } from './TabNavigator.constants'; +import { TabNavigator } from './TabNavigator.component'; + +const mockOnTabChange = jest.fn(); + +describe('TabNavigator - component', () => { + beforeEach(() => { + mockOnTabChange.mockReset(); + }); + it('should render TabNavigator with correct tabs', () => { + render(<TabNavigator activeTab="elements" onTabChange={mockOnTabChange} />); + + Object.keys(TAB_NAMES).forEach(label => { + expect(screen.getByText(label)).toBeInTheDocument(); + }); + }); + + it('should change tabs correctly', () => { + render(<TabNavigator activeTab="elements" onTabChange={mockOnTabChange} />); + + fireEvent.click(screen.getByText(/network/i)); + expect(mockOnTabChange).toHaveBeenCalledWith('network'); + + fireEvent.click(screen.getByText(/graphics/i)); + expect(mockOnTabChange).toHaveBeenCalledWith('graphics'); + }); + + it('should set initial active tab', () => { + render(<TabNavigator activeTab="network" onTabChange={mockOnTabChange} />); + const currentTab = screen.getByRole('button', { current: true }); + const networkTab = screen.getByText(/network/i); + + expect(currentTab).toBe(networkTab); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx new file mode 100644 index 00000000..e8714166 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.component.tsx @@ -0,0 +1,21 @@ +import { TabButton } from '../TabButton'; +import { TAB_NAMES } from './TabNavigator.constants'; +import { TabNames } from './TabNavigator.types'; + +type TabNavigatorProps = { + activeTab: TabNames; + onTabChange: (tabName: TabNames) => void; +}; + +export const TabNavigator = ({ activeTab, onTabChange }: TabNavigatorProps): React.ReactNode => ( + <div className="flex gap-5"> + {Object.entries(TAB_NAMES).map(([label, tabName]) => ( + <TabButton + key={tabName} + handleChangeTab={(): void => onTabChange(tabName)} + label={label} + active={activeTab === tabName} + /> + ))} + </div> +); diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts new file mode 100644 index 00000000..3eda3a54 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.constants.ts @@ -0,0 +1,5 @@ +export const TAB_NAMES = { + ELEMENTS: 'elements', + NETWORK: 'network', + GRAPHICS: 'graphics', +} as const; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts new file mode 100644 index 00000000..cd0ee383 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/TabNavigator.types.ts @@ -0,0 +1,3 @@ +import { TAB_NAMES } from './TabNavigator.constants'; + +export type TabNames = (typeof TAB_NAMES)[keyof typeof TAB_NAMES]; diff --git a/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts b/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts new file mode 100644 index 00000000..b471dcc5 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/TabNavigator/index.ts @@ -0,0 +1 @@ +export { TabNavigator } from './TabNavigator.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/index.ts b/src/components/Map/Drawer/ExportDrawer/index.ts new file mode 100644 index 00000000..313d407d --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/index.ts @@ -0,0 +1 @@ +export { ExportDrawer } from './ExportDrawer.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx index c64ecf74..15b84a16 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx @@ -1,15 +1,26 @@ import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentStepOverlayDrawerStateSelector } from '@/redux/drawer/drawer.selectors'; +import { STEP } from '@/constants/searchDrawer'; import { GeneralOverlays } from './GeneralOverlays'; import { UserOverlays } from './UserOverlays'; +import { UserOverlayForm } from './UserOverlayForm'; export const OverlaysDrawer = (): JSX.Element => { + const currentStep = useAppSelector(currentStepOverlayDrawerStateSelector); + return ( <div data-testid="overlays-drawer" className="h-full max-h-full"> - <DrawerHeading title="Overlays" /> - <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> - <GeneralOverlays /> - <UserOverlays /> - </div> + {currentStep === STEP.FIRST && ( + <> + <DrawerHeading title="Overlays" /> + <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> + <GeneralOverlays /> + <UserOverlays /> + </div> + </> + )} + {currentStep === STEP.SECOND && <UserOverlayForm />} </div> ); }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx new file mode 100644 index 00000000..83a68bfa --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.test.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { FileUpload } from './FileUpload.component'; + +describe('FileUpload component', () => { + const handleChangeFile = jest.fn(); + const handleChangeOverlayContent = jest.fn(); + const handleOverlayChange = jest.fn(); + const uploadedFile = new File(['file content'], 'test.txt', { + type: 'text/plain', + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders correctly with default state', () => { + render( + <FileUpload + handleChangeFile={handleChangeFile} + handleChangeOverlayContent={handleChangeOverlayContent} + updateUserOverlayForm={handleOverlayChange} + uploadedFile={null} + />, + ); + + expect(screen.getByText(/drag and drop here or/i)).toBeInTheDocument(); + }); + + it('renders filename when file type is correct', () => { + render( + <FileUpload + handleChangeFile={handleChangeFile} + handleChangeOverlayContent={handleChangeOverlayContent} + updateUserOverlayForm={handleOverlayChange} + uploadedFile={uploadedFile} + />, + ); + + expect(screen.getByText(/test.txt/i)).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx new file mode 100644 index 00000000..4370a451 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/FileUpload.component.tsx @@ -0,0 +1,62 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { useDropzone } from 'react-dropzone'; +import { processOverlayContentChange } from '../UserOverlayForm.utils'; + +type FileUploadProps = { + updateUserOverlayForm: (nameType: string, value: string) => void; + handleChangeOverlayContent: (value: string) => void; + handleChangeFile: (value: File) => void; + uploadedFile: File | null; +}; + +export const FileUpload = ({ + handleChangeFile, + handleChangeOverlayContent, + updateUserOverlayForm, + uploadedFile, +}: FileUploadProps): React.ReactNode => { + const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ + accept: { + 'text/plain': ['.txt'], + }, + onDrop: acceptedFiles => { + handleChangeFile(acceptedFiles[0]); + + const file = acceptedFiles[0]; + if (file) { + const reader = new FileReader(); + reader.readAsText(file); + reader.onload = (e): void => { + if (e.target) { + const content = e.target?.result as string; + handleChangeOverlayContent(content); + processOverlayContentChange(content, updateUserOverlayForm); + } + }; + } + }, + }); + + return ( + <div + {...getRootProps()} + className="flex h-16 items-center justify-center rounded-lg bg-cultured" + data-testid="dropzone" + > + <input {...getInputProps()} data-testid="dropzone-input" /> + <p className="text-xs font-semibold"> + {uploadedFile && uploadedFile.name} + + {isDragActive && !isDragReject && 'Drop the file here ...'} + + {!isDragActive && !uploadedFile && ( + <> + Drag and drop here or <span className="text-[#004DE2]">browse</span> + </> + )} + {isDragReject && 'Invalid file type. Please choose a supported format .txt'} + </p> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts new file mode 100644 index 00000000..07a87a8d --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/FileUpload/index.ts @@ -0,0 +1 @@ +export { FileUpload } from './FileUpload.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx new file mode 100644 index 00000000..acc140eb --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.test.tsx @@ -0,0 +1,48 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { OverlaySelector } from './OverlaySelector.component'; +import { SelectorItem } from '../UserOverlayForm.types'; + +const items: SelectorItem[] = [ + { id: '1', label: 'Item 1' }, + { id: '2', label: 'Item 2' }, + { id: '3', label: 'Item 3' }, +]; + +const onChangeMock = jest.fn(); + +describe('OverlaySelector component', () => { + it('renders the component with initial values', () => { + const label = 'Select an item'; + const value = items[0]; + + render(<OverlaySelector items={items} value={value} onChange={onChangeMock} label={label} />); + + expect(screen.getByText(label)).toBeInTheDocument(); + + expect(screen.getByTestId('selector-dropdown-button-name')).toHaveTextContent(value.label); + }); + + it('opens the dropdown and selects an item', () => { + const label = 'Select an item'; + const value = items[0]; + + render(<OverlaySelector items={items} value={value} onChange={onChangeMock} label={label} />); + + fireEvent.click(screen.getByTestId('selector-dropdown-button-name')); + + expect(screen.getByRole('listbox')).toBeInTheDocument(); + + const selectedItem = items[1]; + const firstItem = screen.getByText(selectedItem.label); + + fireEvent.click(firstItem); + + waitFor(() => { + expect(screen.queryByRole('listbox')).not.toBeInTheDocument(); + }); + + expect(onChangeMock).toHaveBeenCalledWith(selectedItem); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx new file mode 100644 index 00000000..0e29ed5f --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/OverlaySelector.component.tsx @@ -0,0 +1,84 @@ +/* eslint-disable no-magic-numbers */ +import { useSelect } from 'downshift'; + +import { twMerge } from 'tailwind-merge'; +import { Icon } from '@/shared/Icon'; +import { SelectorItem } from '../UserOverlayForm.types'; + +type OverlaySelectorProps = { + items: SelectorItem[]; + value: SelectorItem; + onChange: (item: SelectorItem) => void; + label: string; +}; + +export const OverlaySelector = ({ + items, + value, + onChange, + label, +}: OverlaySelectorProps): JSX.Element => { + const onItemSelect = (item: SelectorItem | undefined | null): void => { + if (item) { + onChange(item); + } + }; + + const { + isOpen, + getToggleButtonProps, + getMenuProps, + highlightedIndex, + getItemProps, + selectedItem, + } = useSelect({ + items, + defaultSelectedItem: items[0], + selectedItem: value, + onSelectedItemChange: ({ selectedItem: newSelectedItem }) => onItemSelect(newSelectedItem), + }); + + return ( + <div className="mb-2.5"> + <p className="my-2.5 text-sm">{label}</p> + + <div className={twMerge('relative rounded-t bg-cultured text-xs', !isOpen && 'rounded-b')}> + <div className={twMerge('flex w-full flex-col rounded-t py-2 pl-4 pr-3')}> + <div + {...getToggleButtonProps()} + className="flex cursor-pointer flex-row items-center justify-between bg-cultured" + > + <span data-testid="selector-dropdown-button-name" className="font-medium"> + {selectedItem?.label} + </span> + <Icon + name="chevron-down" + className={twMerge('arrow-button h-6 w-6 fill-primary-500', isOpen && 'rotate-180')} + /> + </div> + </div> + <ul + {...getMenuProps()} + className={`absolute inset-x-0 z-10 max-h-80 w-full overflow-scroll rounded-b bg-cultured p-0 ${ + !isOpen && 'hidden' + }`} + > + {isOpen && + items.map((item, index) => ( + <li + className={twMerge( + 'border-t', + highlightedIndex === index && 'text-primary-500', + 'flex flex-col px-4 py-2', + )} + key={item.id} + {...getItemProps({ item, index })} + > + <span>{item.label}</span> + </li> + ))} + </ul> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts new file mode 100644 index 00000000..147e4894 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/OverlaySelector/index.ts @@ -0,0 +1 @@ +export { OverlaySelector } from './OverlaySelector.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx new file mode 100644 index 00000000..b7c754c8 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx @@ -0,0 +1,240 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { AppDispatch, RootState, StoreType } from '@/redux/store'; +import { DEFAULT_ERROR } from '@/constants/errors'; +import { drawerOverlaysStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '@/redux/apiPath'; +import { + createdOverlayFileFixture, + createdOverlayFixture, + uploadedOverlayFileContentFixture, +} from '@/models/fixtures/overlaysFixture'; +import { UserOverlayForm } from './UserOverlayForm.component'; + +const mockedAxiosClient = mockNetworkResponse(); + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <UserOverlayForm /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const renderComponentWithActionListener = ( + initialStoreState: InitialStoreState = {}, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStoreState); + + return ( + render( + <Wrapper> + <UserOverlayForm /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('UserOverlayForm - Component', () => { + it('renders the UserOverlayForm component', () => { + renderComponent(); + + expect(screen.getByTestId('overlay-name')).toBeInTheDocument(); + expect(screen.getByLabelText('upload overlay')).toBeInTheDocument(); + }); + + it('should submit the form with elements list when upload button is clicked', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + renderComponent({ + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { value: 'Elements List Overlay' }, + }); + + fireEvent.click(screen.getByLabelText('upload overlay')); + + expect(screen.getByLabelText('upload overlay')).toBeDisabled(); + }); + + it('should create correct name for file which contains elements list as content', async () => { + const { store } = renderComponentWithActionListener({ + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { value: 'Elements List Overlay' }, + }); + + const actions = store.getActions(); + fireEvent.click(screen.getByLabelText('upload overlay')); + + expect(actions[0].meta.arg.filename).toBe('unknown.txt'); + }); + + it('should update the form inputs based on overlay content provided by elements list', async () => { + renderComponent({ + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { + value: '#NAME = John\n# DESCRIPTION = Some description\n# TYPE = GENETIC_VARIANT\n', + }, + }); + + expect(screen.getByTestId('overlay-name')).toHaveValue('John'); + expect(screen.getByTestId('overlay-description')).toHaveValue('Some description'); + expect(screen.getByText('GENETIC_VARIANT')).toBeVisible(); + }); + + it('should display correct filename', async () => { + const uploadedFile = new File(['file content'], 'test.txt', { + type: 'text/plain', + }); + renderComponent({ + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + fireEvent.change(screen.getByTestId('dropzone-input'), { + target: { files: [uploadedFile] }, + }); + + const dropzone: HTMLInputElement = screen.getByTestId('dropzone-input'); + expect(dropzone?.files?.[0].name).toBe('test.txt'); + }); + + it('should not submit when form is not filled', async () => { + renderComponent({ + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + expect(screen.getByTestId('overlay-description')).toHaveValue(''); + fireEvent.click(screen.getByLabelText('upload overlay')); + expect(screen.getByLabelText('upload overlay')).not.toBeDisabled(); + }); + it('should navigate to overlays after clicking backward button', async () => { + const { store } = renderComponent({ + drawer: drawerOverlaysStepOneFixture, + project: { + data: projectFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + overlays: { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, + }, + }); + + const backButton = screen.getByRole('back-button'); + + backButton.click(); + + const { + drawer: { + overlayDrawerState: { currentStep }, + }, + } = store.getState(); + + expect(currentStep).toBe(1); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx new file mode 100644 index 00000000..ed8f04b7 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.tsx @@ -0,0 +1,121 @@ +/* eslint-disable no-magic-numbers */ +import { DrawerHeadingBackwardButton } from '@/shared/DrawerHeadingBackwardButton'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openOverlaysDrawer } from '@/redux/drawer/drawer.slice'; +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import { Textarea } from '@/shared/Textarea'; +import { OverlaySelector } from './OverlaySelector'; +import { OVERLAY_GROUPS, OVERLAY_TYPES } from './UserOverlayForm.constants'; +import { FileUpload } from './FileUpload'; +import { useUserOverlayForm } from './hooks/useUserOverlayForm'; + +export const UserOverlayForm = (): React.ReactNode => { + const dispatch = useAppDispatch(); + const { + name, + type, + group, + description, + uploadedFile, + elementsList, + isPending, + handleChangeName, + handleChangeDescription, + handleChangeType, + handleChangeGroup, + handleChangeElementsList, + handleSubmit, + updateUserOverlayForm, + handleChangeUploadedFile, + handleChangeOverlayContent, + } = useUserOverlayForm(); + + const navigateToOverlays = (): void => { + dispatch(openOverlaysDrawer()); + }; + + return ( + <> + <DrawerHeadingBackwardButton backwardFunction={navigateToOverlays}> + Add overlay + </DrawerHeadingBackwardButton> + <form className="flex h-[calc(100%-93px)] max-h-[calc(100%-93px)] flex-col overflow-y-auto p-6"> + <div className="mb-2.5"> + <p className="mb-2.5 text-sm">Upload file</p> + <FileUpload + uploadedFile={uploadedFile} + updateUserOverlayForm={updateUserOverlayForm} + handleChangeFile={handleChangeUploadedFile} + handleChangeOverlayContent={handleChangeOverlayContent} + /> + <p className="my-5 text-center">or</p> + <label className="text-sm" htmlFor="elementsList"> + Provide list of elements here + <Textarea + id="elementsList" + name="elementsList" + data-testid="overlay-elements-list" + value={elementsList} + onChange={handleChangeElementsList} + rows={6} + placeholder="Type here" + className="mt-2.5" + /> + </label> + </div> + + <label className="mb-2.5 text-sm" htmlFor="name"> + Name + <Input + type="text" + name="name" + id="name" + data-testid="overlay-name" + value={name} + onChange={handleChangeName} + placeholder="Overlays 11/07/2022" + sizeVariant="medium" + className="mt-2.5 text-xs" + /> + </label> + + <OverlaySelector + value={type} + onChange={handleChangeType} + items={OVERLAY_TYPES} + label="Type" + /> + + <OverlaySelector + value={group} + onChange={handleChangeGroup} + items={OVERLAY_GROUPS} + label="Select group" + /> + + <label className="mt-2.5 text-sm" htmlFor="description"> + Description + <Textarea + id="description" + name="description" + value={description} + data-testid="overlay-description" + onChange={handleChangeDescription} + rows={4} + placeholder="Type Description" + className="mt-2.5" + /> + </label> + <Button + className="mt-2.5 items-center justify-center self-start" + onClick={handleSubmit} + disabled={isPending} + aria-label="upload overlay" + > + Upload + </Button> + </form> + </> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts new file mode 100644 index 00000000..3b4622db --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.constants.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-magic-numbers */ +export const OVERLAY_TYPES = [ + { + id: 'GENERIC', + label: 'GENERIC', + }, + { + id: 'GENETIC_VARIANT', + label: 'GENETIC_VARIANT', + }, +]; + +export const OVERLAY_GROUPS = [ + { + id: 'WITHOUT_GROUP', + label: 'Without group', + }, +]; + +export const DEFAULT_GROUP = OVERLAY_GROUPS[0]; + +export const DEFAULT_TYPE = OVERLAY_TYPES[0]; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts new file mode 100644 index 00000000..531a4e20 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.types.ts @@ -0,0 +1 @@ +export type SelectorItem = { id: string; label: string }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts new file mode 100644 index 00000000..9f3baf2a --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.test.ts @@ -0,0 +1,29 @@ +/* eslint-disable no-magic-numbers */ +import { processOverlayContentChange } from './UserOverlayForm.utils'; + +const handleOverlayChange = jest.fn(); + +describe('processOverlayContentChange', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should parse overlay file content and invoke the handleOverlayChange callback for valid lines', () => { + const fileContent = `#NAME = John\n# DESCRIPTION = Some description\n# TYPE = Type1\n`; + + processOverlayContentChange(fileContent, handleOverlayChange); + + expect(handleOverlayChange).toHaveBeenCalledTimes(3); + expect(handleOverlayChange).toHaveBeenCalledWith('NAME', 'John'); + expect(handleOverlayChange).toHaveBeenCalledWith('DESCRIPTION', 'Some description'); + expect(handleOverlayChange).toHaveBeenCalledWith('TYPE', 'Type1'); + }); + + it('should handle lines with invalid format without calling handleOverlayChange', () => { + const fileContent = `InvalidLine1\n#InvalidLine2\n=InvalidLine3\n`; + + processOverlayContentChange(fileContent, handleOverlayChange); + + expect(handleOverlayChange).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts new file mode 100644 index 00000000..e3c49999 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.utils.ts @@ -0,0 +1,27 @@ +/* eslint-disable no-magic-numbers */ + +type OverlayDataCallback = { + (nameType: string, value: string): void; +}; + +const OVERLAY_INFO_INDICATOR = '#'; +const ASSIGNMENT_OPERATOR = '='; + +export const processOverlayContentChange = ( + fileContent: string, + callback: OverlayDataCallback, +): void => { + const content = fileContent.trim(); + const lines = content.split('\n'); + + lines.forEach(line => { + const isOverlayInfoLine = line.indexOf(OVERLAY_INFO_INDICATOR) === 0; + const hasAssignment = line.indexOf(ASSIGNMENT_OPERATOR) > 0; + + if (isOverlayInfoLine && hasAssignment) { + const nameType = line.substring(1, line.indexOf(ASSIGNMENT_OPERATOR)).trim(); + const value = line.substring(line.indexOf(ASSIGNMENT_OPERATOR) + 1).trim(); + callback(nameType, value); + } + }); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts new file mode 100644 index 00000000..988e9ea0 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.test.ts @@ -0,0 +1,59 @@ +import { renderHook } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { ChangeEvent } from 'react'; +import { useUserOverlayForm } from './useUserOverlayForm'; + +describe('useUserOverlayForm', () => { + it('should update state when form fields are changed', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeType({ id: '1', label: 'Test Type' }); + result.current.handleChangeGroup({ id: '1', label: 'Test Group' }); + }); + + expect(result.current.type).toEqual({ id: '1', label: 'Test Type' }); + expect(result.current.group).toEqual({ id: '1', label: 'Test Group' }); + }); + + it('should update overlayContent when handleChangeOverlayContent is called', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeOverlayContent('Test Overlay Content'); + }); + + expect(result.current.overlayContent).toBe('Test Overlay Content'); + }); + it('should update elementsList and overlayContent when handleChangeElementsList is called', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.handleChangeElementsList({ + target: { value: 'Test Elements List' }, + } as ChangeEvent<HTMLTextAreaElement>); + }); + + expect(result.current.elementsList).toBe('Test Elements List'); + expect(result.current.overlayContent).toBe('Test Elements List'); + }); + it('should update state variables based on updateUserOverlayForm', () => { + const { Wrapper } = getReduxWrapperWithStore(); + const { result } = renderHook(() => useUserOverlayForm(), { wrapper: Wrapper }); + + act(() => { + result.current.updateUserOverlayForm('NAME', 'Test Name'); + }); + + expect(result.current.name).toBe('Test Name'); + + act(() => { + result.current.updateUserOverlayForm('DESCRIPTION', 'Test Description'); + }); + expect(result.current.description).toBe('Test Description'); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts new file mode 100644 index 00000000..dda281bf --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/hooks/useUserOverlayForm.ts @@ -0,0 +1,143 @@ +import { useState, ChangeEvent } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { projectIdSelector } from '@/redux/project/project.selectors'; +import { addOverlay } from '@/redux/overlays/overlays.thunks'; +import { loadingAddOverlay } from '@/redux/overlays/overlays.selectors'; +import { DEFAULT_GROUP, DEFAULT_TYPE, OVERLAY_TYPES } from '../UserOverlayForm.constants'; +import { SelectorItem } from '../UserOverlayForm.types'; +import { processOverlayContentChange } from '../UserOverlayForm.utils'; + +type ReturnType = { + name: string; + type: SelectorItem; + group: SelectorItem; + description: string; + uploadedFile: File | null; + elementsList: string; + overlayContent: string; + projectId?: string; + isPending: boolean; + handleChangeName: (e: ChangeEvent<HTMLInputElement>) => void; + handleChangeDescription: (e: ChangeEvent<HTMLTextAreaElement>) => void; + handleChangeType: (value: SelectorItem) => void; + handleChangeGroup: (value: SelectorItem) => void; + handleChangeUploadedFile: (value: File) => void; + handleChangeOverlayContent: (value: string) => void; + handleChangeElementsList: (e: ChangeEvent<HTMLTextAreaElement>) => void; + handleSubmit: () => Promise<void>; + updateUserOverlayForm: (nameType: string, value: string) => void; +}; + +export const useUserOverlayForm = (): ReturnType => { + const dispatch = useAppDispatch(); + const projectId = useAppSelector(projectIdSelector); + const loadingAddOverlayStatus = useAppSelector(loadingAddOverlay); + const isPending = loadingAddOverlayStatus === 'pending'; + + const [name, setName] = useState(''); + const [type, setType] = useState<SelectorItem>(DEFAULT_TYPE); + const [group, setGroup] = useState<SelectorItem>(DEFAULT_GROUP); + const [description, setDescription] = useState(''); + const [uploadedFile, setUploadedFile] = useState<File | null>(null); + const [elementsList, setElementsList] = useState(''); + const [overlayContent, setOverlayContent] = useState(''); + + const handleChangeName = (e: ChangeEvent<HTMLInputElement>): void => { + setName(e.target.value); + }; + + const handleChangeDescription = (e: ChangeEvent<HTMLTextAreaElement>): void => { + setDescription(e.target.value); + }; + + const handleChangeType = (value: SelectorItem): void => { + setType(value); + }; + + const handleChangeGroup = (value: SelectorItem): void => { + setGroup(value); + }; + + const handleChangeUploadedFile = (value: File): void => { + setUploadedFile(value); + }; + + const handleChangeOverlayContent = (value: string): void => { + setOverlayContent(value); + }; + + const updateUserOverlayForm = (nameType: string, value: string): void => { + switch (nameType) { + case 'NAME': + setName(value); + break; + case 'DESCRIPTION': + setDescription(value); + break; + case 'TYPE': { + const foundType = OVERLAY_TYPES.find(el => el.id === value); + if (foundType) { + setType(foundType); + } + break; + } + default: + break; + } + }; + + const handleChangeElementsList = (e: ChangeEvent<HTMLTextAreaElement>): void => { + processOverlayContentChange(e.target.value, updateUserOverlayForm); // When user change elements list we have to analyze content. If it contains overlay info like e.g NAME we need to update field NAME in form + setOverlayContent(e.target.value); + setElementsList(e.target.value); + }; + + const handleSubmit = async (): Promise<void> => { + let filename = uploadedFile?.name; + + if (!filename) { + filename = 'unknown.txt'; // Elements list is sent to the backend as a file, so we need to create a filename for the elements list. + } + + if (!overlayContent || !projectId || !description || !name) return; + + dispatch( + addOverlay({ + content: overlayContent, + description, + filename, + name, + projectId, + type: type.id, + }), + ); + + setName(''); + setDescription(''); + setElementsList(''); + setOverlayContent(''); + setUploadedFile(null); + }; + + return { + name, + type, + group, + description, + uploadedFile, + elementsList, + overlayContent, + projectId, + isPending, + handleChangeName, + handleChangeDescription, + handleChangeType, + handleChangeGroup, + handleChangeElementsList, + handleSubmit, + updateUserOverlayForm, + handleChangeUploadedFile, + handleChangeOverlayContent, + }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts new file mode 100644 index 00000000..e51db0f0 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/index.ts @@ -0,0 +1 @@ +export { UserOverlayForm } from './UserOverlayForm.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx new file mode 100644 index 00000000..fbf7dc2e --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx @@ -0,0 +1,83 @@ +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { UserOverlays } from './UserOverlays.component'; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <UserOverlays /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('UserOverlays component', () => { + it('renders loading message when user is loading', () => { + renderComponent({ + user: { + loading: 'pending', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + }); + + expect(screen.getByText('Loading')).toBeInTheDocument(); + }); + + it('renders login button when user is not authenticated', () => { + renderComponent({ + user: { + loading: 'failed', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + }); + + expect(screen.getByLabelText('login button')).toBeInTheDocument(); + }); + + it('dispatches openLoginModal action when Login button is clicked', () => { + const { store } = renderComponent({ + user: { + loading: 'failed', + authenticated: false, + error: { name: '', message: '' }, + login: null, + }, + modal: { + isOpen: false, + modalName: 'none', + modalTitle: '', + overviewImagesState: {}, + }, + }); + screen.getByLabelText('login button').click(); + const state = store.getState().modal; + expect(state.isOpen).toEqual(true); + expect(state.modalName).toEqual('login'); + }); + + it('renders add overlay button when user is authenticated', () => { + renderComponent({ + user: { + loading: 'succeeded', + authenticated: true, + error: { name: '', message: '' }, + login: 'test', + }, + }); + + expect(screen.getByLabelText('add overlay button')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx index d594f016..2db18b42 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.tsx @@ -1,3 +1,4 @@ +import { displayAddOverlaysDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { openLoginModal } from '@/redux/modal/modal.slice'; @@ -8,27 +9,40 @@ export const UserOverlays = (): JSX.Element => { const dispatch = useAppDispatch(); const loadingUser = useAppSelector(loadingUserSelector); const authenticatedUser = useAppSelector(authenticatedUserSelector); + const isPending = loadingUser === 'pending'; const handleLoginClick = (): void => { dispatch(openLoginModal()); }; + const handleAddOverlay = (): void => { + dispatch(displayAddOverlaysDrawer()); + }; + return ( <div className="p-6"> - {loadingUser === 'pending' && <h1>Loading</h1>} + {isPending && <h1>Loading</h1>} - {loadingUser !== 'pending' && !authenticatedUser && ( + {!isPending && !authenticatedUser && ( <> <p className="mb-5 font-semibold">User provided overlays:</p> <p className="mb-5 text-sm"> You are not logged in, please login to upload and view custom overlays </p> - <Button onClick={handleLoginClick}>Login</Button> + <Button onClick={handleLoginClick} aria-label="login button"> + Login + </Button> </> )} - {/* TODO: Implement user overlays */} - {authenticatedUser && <h1>Authenticated</h1>} + {authenticatedUser && ( + <div className="flex items-center justify-between"> + <p>User provided overlays:</p> + <Button onClick={handleAddOverlay} aria-label="add overlay button"> + Add overlay + </Button> + </div> + )} </div> ); }; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx index bc8a61dd..b16f5069 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx @@ -1,6 +1,6 @@ +import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { MODELS_MOCK } from '@/models/mocks/modelsMock'; import { StoreType } from '@/redux/store'; -import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { Accordion } from '@/shared/Accordion'; import { InitialStoreState, @@ -73,7 +73,7 @@ describe('BioEntitiesAccordion - component', () => { }); expect(screen.getByText('Content (10)')).toBeInTheDocument(); - expect(screen.getByText('Core PD map (4)')).toBeInTheDocument(); - expect(screen.getByText('Histamine signaling (1)')).toBeInTheDocument(); + expect(screen.getByText('Core PD map (3)')).toBeInTheDocument(); + expect(screen.getByText('Histamine signaling (5)')).toBeInTheDocument(); }); }); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx index a533a5b8..94bc618d 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx @@ -26,6 +26,9 @@ const INITIAL_STATE: InitialStoreState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, drugs: { data: [ diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx index 9477d59d..c36eb3a9 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx @@ -45,6 +45,9 @@ describe('SearchDrawerWrapper - component', () => { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, }); @@ -65,6 +68,9 @@ describe('SearchDrawerWrapper - component', () => { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }, }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts index 90887721..9f27f856 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts @@ -1,13 +1,19 @@ -import { Fill, Style } from 'ol/style'; +import { Fill, Stroke, Style } from 'ol/style'; import { fromExtent } from 'ol/geom/Polygon'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; +const createFeatureFromExtent = ([xMin, yMin, xMax, yMax]: number[]): Feature<Polygon> => + new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); + +const getBioEntityOverlayFeatureStyle = (color: string): Style => + new Style({ fill: new Fill({ color }), stroke: new Stroke({ color: 'black', width: 1 }) }); + export const createOverlayGeometryFeature = ( [xMin, yMin, xMax, yMax]: number[], color: string, ): Feature<Polygon> => { - const feature = new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); - feature.setStyle(new Style({ fill: new Fill({ color }) })); + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + feature.setStyle(getBioEntityOverlayFeatureStyle(color)); return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts index a2a7fb43..45723ea8 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getOverlayFeatures.ts @@ -3,14 +3,18 @@ import { OverlayBioEntityRender } from '@/types/OLrendering'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; import type Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; +import { OverlayOrder } from '@/redux/overlayBioEntity/overlayBioEntity.utils'; +import { ZERO } from '@/constants/common'; import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; import { getColorByAvailableProperties } from './getColorByAvailableProperties'; +import { getPolygonLatitudeCoordinates } from './getPolygonLatitudeCoordinates'; type GetOverlayFeaturesProps = { bioEntities: OverlayBioEntityRender[]; pointToProjection: UsePointToProjectionResult; getHex3ColorGradientColorWithAlpha: GetHex3ColorGradientColorWithAlpha; defaultColor: string; + overlaysOrder: OverlayOrder[]; }; export const getOverlayFeatures = ({ @@ -18,13 +22,27 @@ export const getOverlayFeatures = ({ pointToProjection, getHex3ColorGradientColorWithAlpha, defaultColor, + overlaysOrder, }: GetOverlayFeaturesProps): Feature<Polygon>[] => - bioEntities.map(entity => - createOverlayGeometryFeature( + bioEntities.map(entity => { + /** + * Depending on number of active overlays + * it's required to calculate xMin and xMax coordinates of the polygon + * so "entity" might be devided equali between active overlays + */ + const { xMin, xMax } = getPolygonLatitudeCoordinates({ + width: entity.width, + nOverlays: overlaysOrder.length, + xMin: entity.x1, + overlayIndexBasedOnOrder: + overlaysOrder.find(({ id }) => id === entity.overlayId)?.index || ZERO, + }); + + return createOverlayGeometryFeature( [ - ...pointToProjection({ x: entity.x1, y: entity.y1 }), - ...pointToProjection({ x: entity.x2, y: entity.y2 }), + ...pointToProjection({ x: xMin, y: entity.y1 }), + ...pointToProjection({ x: xMax, y: entity.y2 }), ], getColorByAvailableProperties(entity, getHex3ColorGradientColorWithAlpha, defaultColor), - ), - ); + ); + }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.test.ts new file mode 100644 index 00000000..abb97f15 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.test.ts @@ -0,0 +1,40 @@ +import { getPolygonLatitudeCoordinates } from './getPolygonLatitudeCoordinates'; + +describe('getPolygonLatitudeCoordinates', () => { + const cases = [ + { + width: 80, + nOverlays: 3, + xMin: 2137.5, + overlayIndexBasedOnOrder: 2, + expected: { xMin: 2190.83, xMax: 2217.5 }, + }, + { + width: 120, + nOverlays: 6, + xMin: 2137.5, + overlayIndexBasedOnOrder: 5, + expected: { xMin: 2237.5, xMax: 2257.5 }, + }, + { + width: 40, + nOverlays: 1, + xMin: 2137.5, + overlayIndexBasedOnOrder: 0, + expected: { xMin: 2137.5, xMax: 2177.5 }, + }, + ]; + + it.each(cases)( + 'should return the correct latitude coordinates for width=$width, nOverlays=$nOverlays, xMin=$xMin, and overlayIndexBasedOnOrder=$overlayIndexBasedOnOrder', + ({ width, nOverlays, xMin, overlayIndexBasedOnOrder, expected }) => { + const result = getPolygonLatitudeCoordinates({ + width, + nOverlays, + xMin, + overlayIndexBasedOnOrder, + }); + expect(result).toEqual(expected); + }, + ); +}); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.ts new file mode 100644 index 00000000..bd997739 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/getPolygonLatitudeCoordinates.ts @@ -0,0 +1,26 @@ +import { roundToTwoDigits } from '@/utils/number/roundToTwoDigits'; + +type GetLatitudeCoordinatesProps = { + width: number; + nOverlays: number; + /** bottom left corner of entity drawn on the map */ + xMin: number; + overlayIndexBasedOnOrder: number; +}; + +type PolygonLatitudeCoordinates = { + xMin: number; + xMax: number; +}; + +export const getPolygonLatitudeCoordinates = ({ + width, + nOverlays, + xMin, + overlayIndexBasedOnOrder, +}: GetLatitudeCoordinatesProps): PolygonLatitudeCoordinates => { + const polygonWidth = width / nOverlays; + const newXMin = xMin + polygonWidth * overlayIndexBasedOnOrder; + const xMax = newXMin + polygonWidth; + return { xMin: roundToTwoDigits(newXMin), xMax: roundToTwoDigits(xMax) }; +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts index 048fecd7..50be44c0 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts @@ -1,17 +1,37 @@ import { useTriColorLerp } from '@/hooks/useTriColorLerp'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { overlayBioEntitiesForCurrentModelSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { + getOverlayOrderSelector, + overlayBioEntitiesForCurrentModelSelector, +} from '@/redux/overlayBioEntity/overlayBioEntity.selector'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import { Polygon } from 'ol/geom'; +import { Geometry } from 'ol/geom'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useMemo } from 'react'; + +import { Feature } from 'ol'; import { getOverlayFeatures } from './getOverlayFeatures'; -export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Polygon>> => { +/** + * Prerequisites: "view" button triggers opening overlays -> it triggers downloading overlayBioEntityData for given overlay for ALL available submaps(models) + * + * 1. For each active overlay + * 2. get overlayBioEntity data (current map data passed by selector) + * 3. based on nOverlays, calculate coordinates for given overlayBioEntity to render Polygon from extend + * 4. Calculate coordinates in following steps: + * - polygonWidth = width/nOverlays + * - xMin = xMin + polygonWidth * overlayIndexBasedOnOrder + * - xMax = xMin + polygonWidth + * - yMin,yMax -> is const taken from store + * 5. generate Feature(xMin,yMin,xMax,yMax) + */ + +export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => { const pointToProjection = usePointToProjection(); const { getHex3ColorGradientColorWithAlpha, defaultColorHex } = useTriColorLerp(); const bioEntities = useAppSelector(overlayBioEntitiesForCurrentModelSelector); + const overlaysOrder = useAppSelector(getOverlayOrderSelector); const features = useMemo( () => @@ -20,8 +40,15 @@ export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Polygon>> => { pointToProjection, getHex3ColorGradientColorWithAlpha, defaultColor: defaultColorHex, + overlaysOrder, }), - [bioEntities, getHex3ColorGradientColorWithAlpha, pointToProjection, defaultColorHex], + [ + bioEntities, + getHex3ColorGradientColorWithAlpha, + pointToProjection, + defaultColorHex, + overlaysOrder, + ], ); const vectorSource = useMemo(() => { diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts index 3722f302..37bb871b 100644 --- a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -4,6 +4,7 @@ import { allReactionsSelectorOfCurrentMap } from '@/redux/reactions/reactions.se import { Reaction } from '@/types/models'; import { LinePoint } from '@/types/reactions'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; import { Geometry } from 'ol/geom'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; @@ -17,7 +18,7 @@ import { getLineFeature } from './getLineFeature'; const getReactionsLines = (reactions: Reaction[]): LinePoint[] => reactions.map(({ lines }) => lines.map(({ start, end }): LinePoint => [start, end])).flat(); -export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Geometry>> => { +export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => { const pointToProjection = usePointToProjection(); const reactions = useSelector(allReactionsSelectorOfCurrentMap); const reactionsLines = getReactionsLines(reactions); diff --git a/src/models/bioEntitySchema.ts b/src/models/bioEntitySchema.ts index 9014bc87..5a66d612 100644 --- a/src/models/bioEntitySchema.ts +++ b/src/models/bioEntitySchema.ts @@ -1,13 +1,13 @@ import { z } from 'zod'; -import { referenceSchema } from './referenceSchema'; -import { glyphSchema } from './glyphSchema'; -import { modificationResiduesSchema } from './modificationResiduesSchema'; -import { submodelSchema } from './submodelSchema'; import { colorSchema } from './colorSchema'; -import { productsSchema } from './products'; +import { glyphSchema } from './glyphSchema'; import { lineSchema } from './lineSchema'; +import { modificationResiduesSchema } from './modificationResiduesSchema'; import { operatorSchema } from './operatorSchema'; +import { productsSchema } from './products'; +import { referenceSchema } from './referenceSchema'; import { structuralStateSchema } from './structuralStateSchema'; +import { submodelSchema } from './submodelSchema'; export const bioEntitySchema = z.object({ id: z.number(), @@ -33,6 +33,7 @@ export const bioEntitySchema = z.object({ synonyms: z.array(z.string()), formerSymbols: z.array(z.string()), fullName: z.string().nullable(), + compartmentName: z.string().nullable(), abbreviation: z.string().nullable(), formula: z.string().nullable(), glyph: glyphSchema.nullable(), diff --git a/src/models/configurationSchema.ts b/src/models/configurationSchema.ts new file mode 100644 index 00000000..db44bb56 --- /dev/null +++ b/src/models/configurationSchema.ts @@ -0,0 +1,105 @@ +import { z } from 'zod'; + +export const elementTypeSchema = z.object({ + className: z.string(), + name: z.string(), + parentClass: z.string(), +}); + +export const optionSchema = z.object({ + idObject: z.number(), + type: z.string(), + valueType: z.string(), + commonName: z.string(), + isServerSide: z.boolean(), + group: z.string(), + value: z.string().optional(), +}); + +export const imageFormatSchema = z.object({ + name: z.string(), + handler: z.string(), + extension: z.string(), +}); + +export const modelFormatSchema = z.object({ + name: z.string(), + handler: z.string(), + extension: z.string(), +}); + +export const overlayTypeSchema = z.object({ name: z.string() }); + +export const reactionTypeSchema = z.object({ + className: z.string(), + name: z.string(), + parentClass: z.string(), +}); + +export const miriamTypesSchema = z.record( + z.string(), + z.object({ + commonName: z.string(), + homepage: z.string().nullable(), + registryIdentifier: z.string().nullable(), + uris: z.array(z.string()), + }), +); + +export const bioEntityFieldSchema = z.object({ commonName: z.string(), name: z.string() }); + +export const annotatorSchema = z.object({ + className: z.string(), + name: z.string(), + description: z.string(), + url: z.string(), + elementClassNames: z.array(z.string()), + parameters: z.array( + z.object({ + field: z.string().nullable().optional(), + annotation_type: z.string().nullable().optional(), + order: z.number(), + type: z.string(), + }), + ), +}); + +export const privilegeTypeSchema = z.record( + z.string(), + z.object({ + commonName: z.string(), + objectType: z.string().nullable(), + valueType: z.string(), + }), +); + +export const mapTypeSchema = z.object({ name: z.string(), id: z.string() }); + +export const mapCanvasTypeSchema = z.object({ name: z.string(), id: z.string() }); + +export const unitTypeSchema = z.object({ name: z.string(), id: z.string() }); + +export const modificationStateTypeSchema = z.record( + z.string(), + z.object({ commonName: z.string(), abbreviation: z.string() }), +); + +export const configurationSchema = z.object({ + elementTypes: z.array(elementTypeSchema), + options: z.array(optionSchema), + imageFormats: z.array(imageFormatSchema), + modelFormats: z.array(modelFormatSchema), + overlayTypes: z.array(overlayTypeSchema), + reactionTypes: z.array(reactionTypeSchema), + miriamTypes: miriamTypesSchema, + bioEntityFields: z.array(bioEntityFieldSchema), + version: z.string(), + buildDate: z.string(), + gitHash: z.string(), + annotators: z.array(annotatorSchema), + privilegeTypes: privilegeTypeSchema, + mapTypes: z.array(mapTypeSchema), + mapCanvasTypes: z.array(mapCanvasTypeSchema), + unitTypes: z.array(unitTypeSchema), + modificationStateTypes: modificationStateTypeSchema, +}); diff --git a/src/models/fixtures/overlaysFixture.ts b/src/models/fixtures/overlaysFixture.ts index c0a26efd..d981dba3 100644 --- a/src/models/fixtures/overlaysFixture.ts +++ b/src/models/fixtures/overlaysFixture.ts @@ -2,9 +2,25 @@ import { ZOD_SEED } from '@/constants'; import { z } from 'zod'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { mapOverlay } from '../mapOverlay'; +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '../mapOverlay'; export const overlaysFixture = createFixture(z.array(mapOverlay), { seed: ZOD_SEED, array: { min: 2, max: 2 }, }); + +export const createdOverlayFileFixture = createFixture(createdOverlayFileSchema, { + seed: ZOD_SEED, +}); + +export const uploadedOverlayFileContentFixture = createFixture(uploadedOverlayFileContentSchema, { + seed: ZOD_SEED, +}); +export const createdOverlayFixture = createFixture(createdOverlaySchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/statisticsFixture.ts b/src/models/fixtures/statisticsFixture.ts new file mode 100644 index 00000000..92500578 --- /dev/null +++ b/src/models/fixtures/statisticsFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { statisticsSchema } from '../statisticsSchema'; + +export const statisticsFixture = createFixture(statisticsSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/mapOverlay.ts b/src/models/mapOverlay.ts index a22b65aa..16da5717 100644 --- a/src/models/mapOverlay.ts +++ b/src/models/mapOverlay.ts @@ -12,3 +12,26 @@ export const mapOverlay = z.object({ type: z.string(), order: z.number(), }); + +export const createdOverlayFileSchema = z.object({ + id: z.number(), + filename: z.string(), + length: z.number(), + owner: z.string(), + uploadedDataLength: z.number(), +}); + +export const uploadedOverlayFileContentSchema = createdOverlayFileSchema.extend({}); + +export const createdOverlaySchema = z.object({ + name: z.string(), + googleLicenseConsent: z.boolean(), + creator: z.string(), + description: z.string(), + genomeType: z.string().nullable(), + genomeVersion: z.string().nullable(), + idObject: z.number(), + publicOverlay: z.boolean(), + type: z.string(), + order: z.number(), +}); diff --git a/src/models/statisticsSchema.ts b/src/models/statisticsSchema.ts new file mode 100644 index 00000000..8cb37fac --- /dev/null +++ b/src/models/statisticsSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const statisticsSchema = z.object({ + elementAnnotations: z.record(z.string(), z.number()), + publications: z.number(), + reactionAnnotations: z.record(z.string(), z.number()), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index cc657a71..433b6c49 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -32,6 +32,11 @@ export const apiPath = { getSessionValid: (): string => `users/isSessionValid`, postLogin: (): string => `doLogin`, getConfigurationOptions: (): string => 'configuration/options/', + getConfiguration: (): string => 'configuration/', getOverlayBioEntity: ({ overlayId, modelId }: { overlayId: number; modelId: number }): string => `projects/${PROJECT_ID}/overlays/${overlayId}/models/${modelId}/bioEntities/`, + createOverlay: (projectId: string): string => `projects/${projectId}/overlays/`, + createOverlayFile: (): string => `files/`, + uploadOverlayFileContent: (fileId: number): string => `files/${fileId}:uploadContent`, + getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`, }; diff --git a/src/redux/configuration/configuration.adapter.ts b/src/redux/configuration/configuration.adapter.ts index cb3c59be..d99fb14c 100644 --- a/src/redux/configuration/configuration.adapter.ts +++ b/src/redux/configuration/configuration.adapter.ts @@ -2,6 +2,7 @@ import { DEFAULT_ERROR } from '@/constants/errors'; import { Loading } from '@/types/loadingState'; import { ConfigurationOption } from '@/types/models'; import { createEntityAdapter } from '@reduxjs/toolkit'; +import { ConfigurationMainState } from './configuration.types'; export const configurationAdapter = createEntityAdapter<ConfigurationOption>({ selectId: option => option.type, @@ -12,7 +13,14 @@ const REQUEST_INITIAL_STATUS: { loading: Loading; error: Error } = { error: DEFAULT_ERROR, }; -export const CONFIGURATION_INITIAL_STATE = - configurationAdapter.getInitialState(REQUEST_INITIAL_STATUS); +const MAIN_CONFIGURATION_INITIAL_STATE: ConfigurationMainState = { + data: undefined, + ...REQUEST_INITIAL_STATUS, +}; + +export const CONFIGURATION_INITIAL_STATE = { + options: configurationAdapter.getInitialState(REQUEST_INITIAL_STATUS), + main: MAIN_CONFIGURATION_INITIAL_STATE, +}; export type ConfigurationState = typeof CONFIGURATION_INITIAL_STATE; diff --git a/src/redux/configuration/configuration.mock.ts b/src/redux/configuration/configuration.mock.ts index ce8f052d..d3719904 100644 --- a/src/redux/configuration/configuration.mock.ts +++ b/src/redux/configuration/configuration.mock.ts @@ -7,21 +7,35 @@ import { import { ConfigurationState } from './configuration.adapter'; export const CONFIGURATION_INITIAL_STORE_MOCK: ConfigurationState = { - ids: [], - entities: {}, - loading: 'idle', - error: DEFAULT_ERROR, + options: { + ids: [], + entities: {}, + loading: 'idle', + error: DEFAULT_ERROR, + }, + main: { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, + }, }; /** IMPORTANT MOCK IDS MUST MATCH KEYS IN ENTITIES */ export const CONFIGURATION_INITIAL_STORE_MOCKS: ConfigurationState = { - ids: CONFIGURATION_OPTIONS_TYPES_MOCK, - entities: { - [CONFIGURATION_OPTIONS_TYPES_MOCK[0]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[0], - [CONFIGURATION_OPTIONS_TYPES_MOCK[1]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[1], - [CONFIGURATION_OPTIONS_TYPES_MOCK[2]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[2], - [CONFIGURATION_OPTIONS_TYPES_MOCK[3]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[3], + options: { + ids: CONFIGURATION_OPTIONS_TYPES_MOCK, + entities: { + [CONFIGURATION_OPTIONS_TYPES_MOCK[0]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[0], + [CONFIGURATION_OPTIONS_TYPES_MOCK[1]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[1], + [CONFIGURATION_OPTIONS_TYPES_MOCK[2]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[2], + [CONFIGURATION_OPTIONS_TYPES_MOCK[3]]: CONFIGURATION_OPTIONS_COLOURS_MOCK[3], + }, + loading: 'idle', + error: DEFAULT_ERROR, + }, + main: { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, }, - loading: 'idle', - error: DEFAULT_ERROR, }; diff --git a/src/redux/configuration/configuration.reducers.ts b/src/redux/configuration/configuration.reducers.ts index 01cd1fe5..57e60a05 100644 --- a/src/redux/configuration/configuration.reducers.ts +++ b/src/redux/configuration/configuration.reducers.ts @@ -1,21 +1,37 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { getConfigurationOptions } from './configuration.thunks'; +import { getConfiguration, getConfigurationOptions } from './configuration.thunks'; import { ConfigurationState, configurationAdapter } from './configuration.adapter'; export const getConfigurationOptionsReducer = ( builder: ActionReducerMapBuilder<ConfigurationState>, ): void => { builder.addCase(getConfigurationOptions.pending, state => { - state.loading = 'pending'; + state.options.loading = 'pending'; }); builder.addCase(getConfigurationOptions.fulfilled, (state, action) => { if (action.payload) { - state.loading = 'succeeded'; - configurationAdapter.addMany(state, action.payload); + state.options.loading = 'succeeded'; + configurationAdapter.addMany(state.options, action.payload); } }); builder.addCase(getConfigurationOptions.rejected, state => { - state.loading = 'failed'; + state.options.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; + +export const getConfigurationReducer = ( + builder: ActionReducerMapBuilder<ConfigurationState>, +): void => { + builder.addCase(getConfiguration.pending, state => { + state.main.loading = 'pending'; + }); + builder.addCase(getConfiguration.fulfilled, (state, action) => { + state.main.loading = 'succeeded'; + state.main.data = action.payload; + }); + builder.addCase(getConfiguration.rejected, state => { + state.main.loading = 'failed'; // TODO to discuss manage state of failure }); }; diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts index 2831a55f..3fc6d2e7 100644 --- a/src/redux/configuration/configuration.selectors.ts +++ b/src/redux/configuration/configuration.selectors.ts @@ -11,36 +11,47 @@ import { } from './configuration.constants'; const configurationSelector = createSelector(rootSelector, state => state.configuration); +const configurationOptionsSelector = createSelector(configurationSelector, state => state.options); const configurationAdapterSelectors = configurationAdapter.getSelectors(); export const minColorValSelector = createSelector( - configurationSelector, + configurationOptionsSelector, state => configurationAdapterSelectors.selectById(state, MIN_COLOR_VAL_NAME_ID)?.value, ); export const maxColorValSelector = createSelector( - configurationSelector, + configurationOptionsSelector, state => configurationAdapterSelectors.selectById(state, MAX_COLOR_VAL_NAME_ID)?.value, ); export const neutralColorValSelector = createSelector( - configurationSelector, + configurationOptionsSelector, state => configurationAdapterSelectors.selectById(state, NEUTRAL_COLOR_VAL_NAME_ID)?.value, ); export const overlayOpacitySelector = createSelector( - configurationSelector, + configurationOptionsSelector, state => configurationAdapterSelectors.selectById(state, OVERLAY_OPACITY_NAME_ID)?.value, ); export const simpleColorValSelector = createSelector( - configurationSelector, + configurationOptionsSelector, state => configurationAdapterSelectors.selectById(state, SIMPLE_COLOR_VAL_NAME_ID)?.value, ); -export const defaultLegendImagesSelector = createSelector(configurationSelector, state => +export const defaultLegendImagesSelector = createSelector(configurationOptionsSelector, state => LEGEND_FILE_NAMES_IDS.map( legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value, ).filter(legendImage => Boolean(legendImage)), ); + +export const configurationMainSelector = createSelector( + configurationSelector, + state => state.main.data, +); + +export const elementTypesSelector = createSelector( + configurationMainSelector, + state => state?.elementTypes, +); diff --git a/src/redux/configuration/configuration.slice.ts b/src/redux/configuration/configuration.slice.ts index 4bf43488..7758564a 100644 --- a/src/redux/configuration/configuration.slice.ts +++ b/src/redux/configuration/configuration.slice.ts @@ -1,5 +1,5 @@ import { createSlice } from '@reduxjs/toolkit'; -import { getConfigurationOptionsReducer } from './configuration.reducers'; +import { getConfigurationOptionsReducer, getConfigurationReducer } from './configuration.reducers'; import { CONFIGURATION_INITIAL_STATE } from './configuration.adapter'; export const configurationSlice = createSlice({ @@ -8,6 +8,7 @@ export const configurationSlice = createSlice({ reducers: {}, extraReducers: builder => { getConfigurationOptionsReducer(builder); + getConfigurationReducer(builder); }, }); diff --git a/src/redux/configuration/configuration.thunks.ts b/src/redux/configuration/configuration.thunks.ts index ad3812bb..012e8b1c 100644 --- a/src/redux/configuration/configuration.thunks.ts +++ b/src/redux/configuration/configuration.thunks.ts @@ -1,8 +1,9 @@ -import { ConfigurationOption } from '@/types/models'; +import { Configuration, ConfigurationOption } from '@/types/models'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { configurationSchema } from '@/models/configurationSchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { apiPath } from '../apiPath'; @@ -21,3 +22,14 @@ export const getConfigurationOptions = createAsyncThunk( return isDataValid ? response.data : undefined; }, ); + +export const getConfiguration = createAsyncThunk( + 'configuration/getConfiguration', + async (): Promise<Configuration | undefined> => { + const response = await axiosInstance.get<Configuration>(apiPath.getConfiguration()); + + const isDataValid = validateDataUsingZodSchema(response.data, configurationSchema); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/configuration/configuration.types.ts b/src/redux/configuration/configuration.types.ts new file mode 100644 index 00000000..086619ae --- /dev/null +++ b/src/redux/configuration/configuration.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Configuration } from '@/types/models'; + +export type ConfigurationMainState = FetchDataState<Configuration>; diff --git a/src/redux/drawer/drawer.constants.ts b/src/redux/drawer/drawer.constants.ts index c04c0296..f1035f97 100644 --- a/src/redux/drawer/drawer.constants.ts +++ b/src/redux/drawer/drawer.constants.ts @@ -12,4 +12,7 @@ export const DRAWER_INITIAL_STATE: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 8ecb8328..8a60b60f 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -31,6 +31,13 @@ export const openSubmapsDrawerReducer = (state: DrawerState): void => { export const openOverlaysDrawerReducer = (state: DrawerState): void => { state.isOpen = true; state.drawerName = 'overlays'; + state.overlayDrawerState.currentStep = STEP.FIRST; +}; + +export const displayAddOverlaysDrawerReducer = (state: DrawerState): void => { + state.isOpen = true; + state.drawerName = 'overlays'; + state.overlayDrawerState.currentStep = STEP.SECOND; }; export const selectTabReducer = ( diff --git a/src/redux/drawer/drawer.selectors.ts b/src/redux/drawer/drawer.selectors.ts index 060a652a..26e9a90b 100644 --- a/src/redux/drawer/drawer.selectors.ts +++ b/src/redux/drawer/drawer.selectors.ts @@ -98,3 +98,13 @@ export const currentDrawerReactionIdSelector = createSelector( reactionDrawerStateSelector, state => state?.reactionId, ); + +export const overlayDrawerStateSelector = createSelector( + drawerSelector, + state => state.overlayDrawerState, +); + +export const currentStepOverlayDrawerStateSelector = createSelector( + overlayDrawerStateSelector, + state => state.currentStep, +); diff --git a/src/redux/drawer/drawer.slice.ts b/src/redux/drawer/drawer.slice.ts index 769b5cf0..98e073ae 100644 --- a/src/redux/drawer/drawer.slice.ts +++ b/src/redux/drawer/drawer.slice.ts @@ -13,6 +13,7 @@ import { openSearchDrawerWithSelectedTabReducer, openSubmapsDrawerReducer, selectTabReducer, + displayAddOverlaysDrawerReducer, } from './drawer.reducers'; import { DRAWER_INITIAL_STATE } from './drawer.constants'; @@ -24,6 +25,7 @@ const drawerSlice = createSlice({ openSearchDrawerWithSelectedTab: openSearchDrawerWithSelectedTabReducer, openSubmapsDrawer: openSubmapsDrawerReducer, openOverlaysDrawer: openOverlaysDrawerReducer, + displayAddOverlaysDrawer: displayAddOverlaysDrawerReducer, selectTab: selectTabReducer, closeDrawer: closeDrawerReducer, displayDrugsList: displayDrugsListReducer, @@ -41,6 +43,7 @@ export const { openSearchDrawerWithSelectedTab, openSubmapsDrawer, openOverlaysDrawer, + displayAddOverlaysDrawer, selectTab, closeDrawer, displayDrugsList, diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index 44ed6516..075fcd19 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -10,6 +10,10 @@ export type SearchDrawerState = { selectedSearchElement: string; }; +export type OverlayDrawerState = { + currentStep: number; +}; + export type ReactionDrawerState = { reactionId?: number; }; @@ -24,6 +28,7 @@ export type DrawerState = { searchDrawerState: SearchDrawerState; reactionDrawerState: ReactionDrawerState; bioEntityDrawerState: BioEntityDrawerState; + overlayDrawerState: OverlayDrawerState; }; export type OpenSearchDrawerWithSelectedTabReducerPayload = string; diff --git a/src/redux/drawer/drawerFixture.ts b/src/redux/drawer/drawerFixture.ts index 818a545c..29b32d3a 100644 --- a/src/redux/drawer/drawerFixture.ts +++ b/src/redux/drawer/drawerFixture.ts @@ -12,6 +12,9 @@ export const initialStateFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const openedDrawerSubmapsFixture: DrawerState = { @@ -26,6 +29,9 @@ export const openedDrawerSubmapsFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchStepOneFixture: DrawerState = { @@ -40,6 +46,9 @@ export const drawerSearchStepOneFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchDrugsStepTwoFixture: DrawerState = { @@ -54,6 +63,9 @@ export const drawerSearchDrugsStepTwoFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; export const drawerSearchChemicalsStepTwoFixture: DrawerState = { @@ -68,4 +80,41 @@ export const drawerSearchChemicalsStepTwoFixture: DrawerState = { }, reactionDrawerState: {}, bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, +}; + +export const drawerOverlaysStepOneFixture: DrawerState = { + isOpen: true, + drawerName: 'overlays', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + listOfBioEnitites: [], + selectedSearchElement: '', + }, + reactionDrawerState: {}, + bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 2, + }, +}; + +export const openedExportDrawerFixture: DrawerState = { + isOpen: true, + drawerName: 'export', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + listOfBioEnitites: [], + selectedSearchElement: '', + }, + reactionDrawerState: {}, + bioEntityDrawerState: {}, + overlayDrawerState: { + currentStep: 0, + }, }; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts index 00803c03..f3dbd72c 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts @@ -1,6 +1,9 @@ import { createSelector } from '@reduxjs/toolkit'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; import { rootSelector } from '../root/root.selectors'; import { currentModelIdSelector } from '../models/models.selectors'; +import { overlaysIdsAndOrderSelector } from '../overlays/overlays.selectors'; +import { calculateOvarlaysOrder } from './overlayBioEntity.utils'; export const overlayBioEntitySelector = createSelector( rootSelector, @@ -17,17 +20,36 @@ export const activeOverlaysIdSelector = createSelector( state => state.overlaysId, ); -const FIRST_ENTITY_INDEX = 0; -// TODO, improve selector when multioverlay algorithm comes in place export const overlayBioEntitiesForCurrentModelSelector = createSelector( overlayBioEntityDataSelector, + activeOverlaysIdSelector, currentModelIdSelector, - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - (data, currentModelId) => data[Object.keys(data)[FIRST_ENTITY_INDEX]]?.[currentModelId] ?? [], // temporary solution untill multioverlay algorithm comes in place + (data, activeOverlaysIds, currentModelId) => { + const result: OverlayBioEntityRender[] = []; + + activeOverlaysIds.forEach(overlayId => { + if (data[overlayId]?.[currentModelId]) { + result.push(...data[overlayId][currentModelId]); + } + }); + + return result; + }, ); export const isOverlayActiveSelector = createSelector( [activeOverlaysIdSelector, (_, overlayId: number): number => overlayId], (overlaysId, overlayId) => overlaysId.includes(overlayId), ); + +export const getOverlayOrderSelector = createSelector( + overlaysIdsAndOrderSelector, + activeOverlaysIdSelector, + (overlaysIdsAndOrder, activeOverlaysIds) => { + const activeOverlaysIdsAndOrder = overlaysIdsAndOrder.filter(({ idObject }) => + activeOverlaysIds.includes(idObject), + ); + + return calculateOvarlaysOrder(activeOverlaysIdsAndOrder); + }, +); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 21aecf10..2ba83189 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -9,6 +9,8 @@ import { parseOverlayBioEntityToOlRenderingFormat } from './overlayBioEntity.uti import { apiPath } from '../apiPath'; import { modelsIdsSelector } from '../models/models.selectors'; import type { RootState } from '../store'; +import { setMapBackground } from '../map/map.slice'; +import { emptyBackgroundIdSelector } from '../backgrounds/background.selectors'; type GetOverlayBioEntityThunkProps = { overlayId: number; @@ -54,3 +56,19 @@ export const getOverlayBioEntityForAllModels = createAsyncThunk< await Promise.all(asyncGetOverlayBioEntityFunctions); }, ); + +type GetInitOverlaysProps = { overlaysId: number[] }; + +export const getInitOverlays = createAsyncThunk<void, GetInitOverlaysProps, { state: RootState }>( + 'appInit/getInitOverlays', + async ({ overlaysId }, { dispatch, getState }): Promise<void> => { + const state = getState(); + + const emptyBackgroundId = emptyBackgroundIdSelector(state); + if (emptyBackgroundId) { + dispatch(setMapBackground(emptyBackgroundId)); + } + + overlaysId.forEach(id => dispatch(getOverlayBioEntityForAllModels({ overlayId: id }))); + }, +); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts new file mode 100644 index 00000000..5714a9a7 --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts @@ -0,0 +1,64 @@ +import { calculateOvarlaysOrder } from './overlayBioEntity.utils'; + +describe('calculateOverlaysOrder', () => { + const cases = [ + { + data: [ + { idObject: 1, order: 11 }, + { idObject: 2, order: 12 }, + { idObject: 3, order: 13 }, + ], + expected: [ + { id: 1, order: 11, calculatedOrder: 1, index: 0 }, + { id: 2, order: 12, calculatedOrder: 2, index: 1 }, + { id: 3, order: 13, calculatedOrder: 3, index: 2 }, + ], + }, + // different order + { + data: [ + { idObject: 2, order: 12 }, + { idObject: 3, order: 13 }, + { idObject: 1, order: 11 }, + ], + expected: [ + { id: 1, order: 11, calculatedOrder: 1, index: 0 }, + { id: 2, order: 12, calculatedOrder: 2, index: 1 }, + { id: 3, order: 13, calculatedOrder: 3, index: 2 }, + ], + }, + { + data: [ + { idObject: 1, order: 11 }, + { idObject: 2, order: 11 }, + { idObject: 3, order: 11 }, + ], + expected: [ + { id: 1, order: 11, calculatedOrder: 1, index: 0 }, + { id: 2, order: 11, calculatedOrder: 2, index: 1 }, + { id: 3, order: 11, calculatedOrder: 3, index: 2 }, + ], + }, + // different order + { + data: [ + { idObject: 2, order: 11 }, + { idObject: 3, order: 11 }, + { idObject: 1, order: 11 }, + ], + expected: [ + { id: 1, order: 11, calculatedOrder: 1, index: 0 }, + { id: 2, order: 11, calculatedOrder: 2, index: 1 }, + { id: 3, order: 11, calculatedOrder: 3, index: 2 }, + ], + }, + { + data: [], + expected: [], + }, + ]; + + it.each(cases)('should return valid overlays order', ({ data, expected }) => { + expect(calculateOvarlaysOrder(data)).toStrictEqual(expected); + }); +}); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts index b875e1bb..e632f31a 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -1,3 +1,4 @@ +import { ONE } from '@/constants/common'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { OverlayBioEntity } from '@/types/models'; @@ -23,3 +24,45 @@ export const parseOverlayBioEntityToOlRenderingFormat = ( } return acc; }, []); + +export type OverlayIdAndOrder = { + idObject: number; + order: number; +}; + +export type OverlayOrder = { + id: number; + order: number; + calculatedOrder: number; + index: number; +}; + +const byOrderOrId = (a: OverlayOrder, b: OverlayOrder): number => { + if (a.order === b.order) { + return a.id - b.id; + } + return a.order - b.order; +}; + +/** function calculates order of the function based on "order" property in ovarlay data. */ +export const calculateOvarlaysOrder = ( + overlaysIdsAndOrder: OverlayIdAndOrder[], +): OverlayOrder[] => { + const overlaysOrder = overlaysIdsAndOrder.map(({ idObject, order }, index) => ({ + id: idObject, + order, + calculatedOrder: 0, + index, + })); + + overlaysOrder.sort(byOrderOrId); + + overlaysOrder.forEach((overlay, index) => { + const updatedOverlay = { ...overlay }; + updatedOverlay.calculatedOrder = index + ONE; + updatedOverlay.index = index; + overlaysOrder[index] = updatedOverlay; + }); + + return overlaysOrder; +}; diff --git a/src/redux/overlays/overlays.constants.ts b/src/redux/overlays/overlays.constants.ts new file mode 100644 index 00000000..dda564cd --- /dev/null +++ b/src/redux/overlays/overlays.constants.ts @@ -0,0 +1,2 @@ +/* eslint-disable no-magic-numbers */ +export const CHUNK_SIZE = 65535 * 8; diff --git a/src/redux/overlays/overlays.mock.ts b/src/redux/overlays/overlays.mock.ts index cdb5593c..3e1557ba 100644 --- a/src/redux/overlays/overlays.mock.ts +++ b/src/redux/overlays/overlays.mock.ts @@ -6,6 +6,10 @@ export const OVERLAYS_INITIAL_STATE_MOCK: OverlaysState = { data: [], loading: 'idle', error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, }; export const PUBLIC_OVERLAYS_MOCK: MapOverlay[] = [ @@ -77,4 +81,17 @@ export const OVERLAYS_PUBLIC_FETCHED_STATE_MOCK: OverlaysState = { data: PUBLIC_OVERLAYS_MOCK, loading: 'succeeded', error: DEFAULT_ERROR, + addOverlay: { + loading: 'idle', + error: DEFAULT_ERROR, + }, +}; + +export const ADD_OVERLAY_MOCK = { + content: 'test', + description: 'test', + filename: 'unknown.txt', + name: 'test', + projectId: 'pd', + type: 'GENERIC', }; diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts index d0116134..2fe92673 100644 --- a/src/redux/overlays/overlays.reducers.test.ts +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -1,15 +1,23 @@ +/* eslint-disable no-magic-numbers */ import { PROJECT_ID } from '@/constants'; -import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { + createdOverlayFileFixture, + createdOverlayFixture, + overlaysFixture, + uploadedOverlayFileContentFixture, +} from '@/models/fixtures/overlaysFixture'; import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { waitFor } from '@testing-library/react'; import { apiPath } from '../apiPath'; import overlaysReducer from './overlays.slice'; -import { getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; import { OverlaysState } from './overlays.types'; +import { ADD_OVERLAY_MOCK } from './overlays.mock'; const mockedAxiosClient = mockNetworkResponse(); @@ -17,6 +25,10 @@ const INITIAL_STATE: OverlaysState = { data: [], loading: 'idle', error: { name: '', message: '' }, + addOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; describe('overlays reducer', () => { @@ -30,7 +42,7 @@ describe('overlays reducer', () => { expect(overlaysReducer(undefined, action)).toEqual(INITIAL_STATE); }); - it('should update store after succesfull getAllPublicOverlaysByProjectId query', async () => { + it('should update store after successful getAllPublicOverlaysByProjectId query', async () => { mockedAxiosClient .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) .reply(HttpStatusCode.Ok, overlaysFixture); @@ -76,4 +88,58 @@ describe('overlays reducer', () => { expect(promiseFulfilled).toEqual('succeeded'); }); }); + it('should update store when addOverlay is pending', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading } = store.getState().overlays.addOverlay; + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + }); + + it('should update store after successful addOverlay', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay('pd')) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading, error } = store.getState().overlays.addOverlay; + + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + }); + it('should update store after failed addOverlay', async () => { + mockedAxiosClient.onPost(apiPath.createOverlayFile()).reply(HttpStatusCode.NotFound, undefined); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(123)) + .reply(HttpStatusCode.NotFound, undefined); + + mockedAxiosClient.onPost(apiPath.createOverlay('pd')).reply(HttpStatusCode.NotFound, undefined); + + await store.dispatch(addOverlay(ADD_OVERLAY_MOCK)); + const { loading } = store.getState().overlays.addOverlay; + + expect(loading).toEqual('failed'); + }); }); diff --git a/src/redux/overlays/overlays.reducers.ts b/src/redux/overlays/overlays.reducers.ts index 99e493ea..d8f12eef 100644 --- a/src/redux/overlays/overlays.reducers.ts +++ b/src/redux/overlays/overlays.reducers.ts @@ -1,5 +1,5 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { addOverlay, getAllPublicOverlaysByProjectId } from './overlays.thunks'; import { OverlaysState } from './overlays.types'; export const getAllPublicOverlaysByProjectIdReducer = ( @@ -17,3 +17,16 @@ export const getAllPublicOverlaysByProjectIdReducer = ( // TODO to discuss manage state of failure }); }; + +export const addOverlayReducer = (builder: ActionReducerMapBuilder<OverlaysState>): void => { + builder.addCase(addOverlay.pending, state => { + state.addOverlay.loading = 'pending'; + }); + builder.addCase(addOverlay.fulfilled, state => { + state.addOverlay.loading = 'succeeded'; + }); + builder.addCase(addOverlay.rejected, state => { + state.addOverlay.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/overlays/overlays.selectors.ts b/src/redux/overlays/overlays.selectors.ts index f9d36cad..03ee76e6 100644 --- a/src/redux/overlays/overlays.selectors.ts +++ b/src/redux/overlays/overlays.selectors.ts @@ -7,3 +7,12 @@ export const overlaysDataSelector = createSelector( overlaysSelector, overlays => overlays?.data || [], ); + +export const overlaysIdsAndOrderSelector = createSelector(overlaysDataSelector, overlays => + overlays.map(({ idObject, order }) => ({ idObject, order })), +); + +export const loadingAddOverlay = createSelector( + overlaysSelector, + state => state.addOverlay.loading, +); diff --git a/src/redux/overlays/overlays.slice.ts b/src/redux/overlays/overlays.slice.ts index 8d259288..5f49156a 100644 --- a/src/redux/overlays/overlays.slice.ts +++ b/src/redux/overlays/overlays.slice.ts @@ -1,11 +1,15 @@ import { createSlice } from '@reduxjs/toolkit'; -import { getAllPublicOverlaysByProjectIdReducer } from './overlays.reducers'; +import { addOverlayReducer, getAllPublicOverlaysByProjectIdReducer } from './overlays.reducers'; import { OverlaysState } from './overlays.types'; const initialState: OverlaysState = { data: [], loading: 'idle', error: { name: '', message: '' }, + addOverlay: { + loading: 'idle', + error: { name: '', message: '' }, + }, }; const overlaysState = createSlice({ @@ -14,6 +18,7 @@ const overlaysState = createSlice({ reducers: {}, extraReducers: builder => { getAllPublicOverlaysByProjectIdReducer(builder); + addOverlayReducer(builder); }, }); diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 6a019333..330e5ee9 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -1,10 +1,17 @@ -import { mapOverlay } from '@/models/mapOverlay'; +/* eslint-disable no-magic-numbers */ +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '@/models/mapOverlay'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; -import { MapOverlay } from '@/types/models'; +import { CreatedOverlay, CreatedOverlayFile, MapOverlay } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { apiPath } from '../apiPath'; +import { CHUNK_SIZE } from './overlays.constants'; export const getAllPublicOverlaysByProjectId = createAsyncThunk( 'overlays/getAllPublicOverlaysByProjectId', @@ -18,3 +25,136 @@ export const getAllPublicOverlaysByProjectId = createAsyncThunk( return isDataValid ? response.data : []; }, ); + +/** UTILS */ + +type CreateFileArgs = { + filename: string; + content: string; +}; + +const createFile = async ({ filename, content }: CreateFileArgs): Promise<CreatedOverlayFile> => { + const fileParams = { + filename: `C:\\fakepath\\${filename}`, + length: content.length.toString(), + }; + + const response = await axiosInstance.post( + apiPath.createOverlayFile(), + new URLSearchParams(fileParams), + { + withCredentials: true, + }, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, createdOverlayFileSchema); + return isDataValid ? response.data : undefined; +}; + +type UploadContentArgs = { + createdFile: CreatedOverlayFile; + overlayContent: string; +}; +const uploadContent = async ({ createdFile, overlayContent }: UploadContentArgs): Promise<void> => { + const data = new Uint8Array(new TextEncoder().encode(overlayContent)); + let uploadedLength = 0; + + const sendChunk = async (): Promise<void> => { + if (uploadedLength >= data.length) { + return; + } + + const chunk = data.slice(uploadedLength, uploadedLength + CHUNK_SIZE); + + const responeJSON = await fetch( + `${process.env.NEXT_PUBLIC_BASE_API_URL}/${apiPath.uploadOverlayFileContent(createdFile.id)}`, + { + method: 'POST', + credentials: 'include', + body: chunk, + }, + ); + + const response = await responeJSON.json(); + validateDataUsingZodSchema(response, uploadedOverlayFileContentSchema); + + uploadedLength += chunk.length; + sendChunk(); + }; + + await sendChunk(); +}; + +type CreatedOverlayArgs = { + createdFile: CreatedOverlayFile; + description: string; + name: string; + type: string; + projectId: string; +}; + +const creteOverlay = async ({ + createdFile, + description, + type, + name, + projectId, +}: CreatedOverlayArgs): Promise<CreatedOverlay> => { + const data = { + name, + description, + filename: createdFile.filename, + googleLicenseConsent: false.toString(), + type, + fileId: createdFile.id.toString(), + }; + + const overlay = new URLSearchParams(data); + + const response = await axiosInstance.post(apiPath.createOverlay(projectId), overlay, { + withCredentials: true, + }); + + const isDataValid = validateDataUsingZodSchema(response.data, createdOverlaySchema); + + return isDataValid ? response.data : undefined; +}; + +type AddOverlayArgs = { + filename: string; + content: string; + description: string; + type: string; + name: string; + projectId: string; +}; + +export const addOverlay = createAsyncThunk( + 'overlays/addOverlay', + async ({ + filename, + content, + description, + name, + type, + projectId, + }: AddOverlayArgs): Promise<void> => { + const createdFile = await createFile({ + filename, + content, + }); + + await uploadContent({ + createdFile, + overlayContent: content, + }); + + await creteOverlay({ + createdFile, + description, + name, + type, + projectId, + }); + }, +); diff --git a/src/redux/overlays/overlays.types.ts b/src/redux/overlays/overlays.types.ts index ee00e945..15d4d813 100644 --- a/src/redux/overlays/overlays.types.ts +++ b/src/redux/overlays/overlays.types.ts @@ -1,4 +1,12 @@ import { FetchDataState } from '@/types/fetchDataState'; +import { Loading } from '@/types/loadingState'; import { MapOverlay } from '@/types/models'; -export type OverlaysState = FetchDataState<MapOverlay[] | []>; +export type AddOverlayState = { + addOverlay: { + loading: Loading; + error: Error; + }; +}; + +export type OverlaysState = FetchDataState<MapOverlay[] | []> & AddOverlayState; diff --git a/src/redux/project/project.selectors.ts b/src/redux/project/project.selectors.ts index 610a6cce..c5ac3403 100644 --- a/src/redux/project/project.selectors.ts +++ b/src/redux/project/project.selectors.ts @@ -31,3 +31,8 @@ export const projectDirectorySelector = createSelector( projectDataSelector, projectData => projectData?.directory, ); + +export const projectIdSelector = createSelector( + projectDataSelector, + projectData => projectData?.projectId, +); diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index 4591391b..c4ac1927 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -17,7 +17,9 @@ import { import { getSearchData } from '../search/search.thunks'; import { setPerfectMatch } from '../search/search.slice'; import { getSessionValid } from '../user/user.thunks'; -import { getConfigurationOptions } from '../configuration/configuration.thunks'; +import { getInitOverlays } from '../overlayBioEntity/overlayBioEntity.thunk'; +import { getConfiguration, getConfigurationOptions } from '../configuration/configuration.thunks'; +import { getStatisticsById } from '../statistics/statistics.thunks'; interface InitializeAppParams { queryData: QueryData; @@ -28,7 +30,7 @@ export const fetchInitialAppData = createAsyncThunk< InitializeAppParams, { dispatch: AppDispatch } >('appInit/fetchInitialAppData', async ({ queryData }, { dispatch }): Promise<void> => { - /** Fetch all data required for renderin map */ + /** Fetch all data required for rendering map */ await Promise.all([ dispatch(getConfigurationOptions()), dispatch(getProjectById(PROJECT_ID)), @@ -48,6 +50,10 @@ export const fetchInitialAppData = createAsyncThunk< // Check if auth token is valid dispatch(getSessionValid()); + // Fetch data needed for export + dispatch(getStatisticsById(PROJECT_ID)); + dispatch(getConfiguration()); + /** Trigger search */ if (queryData.searchValue) { dispatch(setPerfectMatch(queryData.perfectMatch)); @@ -59,4 +65,9 @@ export const fetchInitialAppData = createAsyncThunk< ); dispatch(openSearchDrawerWithSelectedTab(getDefaultSearchTab(queryData.searchValue))); } + + /** fetch overlays */ + if (queryData.overlaysId) { + dispatch(getInitOverlays({ overlaysId: queryData.overlaysId })); + } }); diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts index 55929fca..98660410 100644 --- a/src/redux/root/query.selectors.ts +++ b/src/redux/root/query.selectors.ts @@ -1,17 +1,33 @@ import { QueryDataParams } from '@/types/query'; import { createSelector } from '@reduxjs/toolkit'; +import { ZERO } from '@/constants/common'; import { mapDataSelector } from '../map/map.selectors'; import { perfectMatchSelector, searchValueSelector } from '../search/search.selectors'; +import { activeOverlaysIdSelector } from '../overlayBioEntity/overlayBioEntity.selector'; export const queryDataParamsSelector = createSelector( searchValueSelector, perfectMatchSelector, mapDataSelector, - (searchValue, perfectMatch, { modelId, backgroundId, position }): QueryDataParams => ({ - searchValue: searchValue.join(';'), + activeOverlaysIdSelector, + ( + searchValue, perfectMatch, - modelId, - backgroundId, - ...position.last, - }), + { modelId, backgroundId, position }, + activeOverlaysId, + ): QueryDataParams => { + const queryDataParams: QueryDataParams = { + searchValue: searchValue.join(';'), + perfectMatch, + modelId, + backgroundId, + ...position.last, + }; + + if (activeOverlaysId.length > ZERO) { + queryDataParams.overlaysId = activeOverlaysId.join(','); + } + + return queryDataParams; + }, ); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 966bb1d5..ba66c5ef 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -17,6 +17,7 @@ import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock'; import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; +import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -37,4 +38,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { cookieBanner: COOKIE_BANNER_INITIAL_STATE_MOCK, user: USER_INITIAL_STATE_MOCK, legend: LEGEND_INITIAL_STATE_MOCK, + statistics: STATISTICS_STATE_INITIAL_MOCK, }; diff --git a/src/redux/statistics/statistics.mock.ts b/src/redux/statistics/statistics.mock.ts new file mode 100644 index 00000000..9c753dcd --- /dev/null +++ b/src/redux/statistics/statistics.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { StatisticsState } from './statistics.types'; + +export const STATISTICS_STATE_INITIAL_MOCK: StatisticsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/statistics/statistics.reducers.test.ts b/src/redux/statistics/statistics.reducers.test.ts new file mode 100644 index 00000000..af16b53b --- /dev/null +++ b/src/redux/statistics/statistics.reducers.test.ts @@ -0,0 +1,70 @@ +import { PROJECT_ID } from '@/constants'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { waitFor } from '@testing-library/react'; +import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { StatisticsState } from './statistics.types'; +import statisticsReducer from './statistics.slice'; +import { apiPath } from '../apiPath'; +import { getStatisticsById } from './statistics.thunks'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: StatisticsState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('statistics reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<StatisticsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('statistics', statisticsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(statisticsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after successful getStatisticById query', async () => { + mockedAxiosClient + .onGet(apiPath.getStatisticsById(PROJECT_ID)) + .reply(HttpStatusCode.Ok, statisticsFixture); + + const { type } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { data, loading, error } = store.getState().statistics; + + expect(type).toBe('statistics/getStatisticsById/fulfilled'); + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(statisticsFixture); + }); + + it('should update store after failed getStatisticById query', async () => { + mockedAxiosClient + .onGet(apiPath.getStatisticsById(PROJECT_ID)) + .reply(HttpStatusCode.NotFound, undefined); + + const { type } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { loading } = store.getState().statistics; + + expect(type).toBe('statistics/getStatisticsById/rejected'); + + waitFor(() => { + expect(loading).toEqual('pending'); + }); + + expect(loading).toEqual('failed'); + }); +}); diff --git a/src/redux/statistics/statistics.reducers.ts b/src/redux/statistics/statistics.reducers.ts new file mode 100644 index 00000000..0829625f --- /dev/null +++ b/src/redux/statistics/statistics.reducers.ts @@ -0,0 +1,18 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { StatisticsState } from './statistics.types'; +import { getStatisticsById } from './statistics.thunks'; + +export const getStatisticsByIdReducer = ( + builder: ActionReducerMapBuilder<StatisticsState>, +): void => { + builder.addCase(getStatisticsById.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getStatisticsById.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getStatisticsById.rejected, state => { + state.loading = 'failed'; + }); +}; diff --git a/src/redux/statistics/statistics.selectors.ts b/src/redux/statistics/statistics.selectors.ts new file mode 100644 index 00000000..e0bb3259 --- /dev/null +++ b/src/redux/statistics/statistics.selectors.ts @@ -0,0 +1,16 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const statisticsSelector = createSelector(rootSelector, state => state.statistics); + +export const loadingStatisticsSelector = createSelector(statisticsSelector, state => state.loading); + +export const statisticsDataSelector = createSelector( + statisticsSelector, + statistics => statistics?.data, +); + +export const elementAnnotationsSelector = createSelector( + statisticsDataSelector, + statistics => statistics?.elementAnnotations, +); diff --git a/src/redux/statistics/statistics.slice.ts b/src/redux/statistics/statistics.slice.ts new file mode 100644 index 00000000..f2cf9f80 --- /dev/null +++ b/src/redux/statistics/statistics.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { StatisticsState } from './statistics.types'; +import { getStatisticsByIdReducer } from './statistics.reducers'; + +const initialState: StatisticsState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const statisticsSlice = createSlice({ + name: 'statistics', + initialState, + reducers: {}, + extraReducers: builder => { + getStatisticsByIdReducer(builder); + }, +}); + +export default statisticsSlice.reducer; diff --git a/src/redux/statistics/statistics.thunks.ts b/src/redux/statistics/statistics.thunks.ts new file mode 100644 index 00000000..df5b6589 --- /dev/null +++ b/src/redux/statistics/statistics.thunks.ts @@ -0,0 +1,17 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Statistics } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { statisticsSchema } from '@/models/statisticsSchema'; +import { apiPath } from '../apiPath'; + +export const getStatisticsById = createAsyncThunk( + 'statistics/getStatisticsById', + async (id: string): Promise<Statistics | undefined> => { + const response = await axiosInstance.get<Statistics>(apiPath.getStatisticsById(id)); + + const isDataValid = validateDataUsingZodSchema(response.data, statisticsSchema); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/statistics/statistics.types.ts b/src/redux/statistics/statistics.types.ts new file mode 100644 index 00000000..077d4df1 --- /dev/null +++ b/src/redux/statistics/statistics.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Statistics } from '@/types/models'; + +export type StatisticsState = FetchDataState<Statistics>; diff --git a/src/redux/store.ts b/src/redux/store.ts index 663bc180..b79cf2b2 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -24,6 +24,7 @@ import { } from '@reduxjs/toolkit'; import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; +import statisticsReducer from './statistics/statistics.slice'; export const reducers = { search: searchReducer, @@ -44,6 +45,7 @@ export const reducers = { configuration: configurationReducer, overlayBioEntity: overlayBioEntityReducer, legend: legendReducer, + statistics: statisticsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/shared/Input/Input.component.test.tsx b/src/shared/Input/Input.component.test.tsx new file mode 100644 index 00000000..a26f46fc --- /dev/null +++ b/src/shared/Input/Input.component.test.tsx @@ -0,0 +1,70 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { Input } from './Input.component'; + +describe('Input - component', () => { + it('should render with proper testid', () => { + render(<Input data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toBeInTheDocument(); + }); + + it('should apply the default style and size variants when none are provided', () => { + render(<Input data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('bg-cultured'); + expect(inputElement).toHaveClass('rounded-s'); + }); + + it('should apply the specified style variant', () => { + render(<Input styleVariant="primary" data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('bg-cultured'); + }); + + it('should apply the specified size variant', () => { + render(<Input sizeVariant="medium" data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('rounded-lg'); + expect(inputElement).toHaveClass('h-12'); + expect(inputElement).toHaveClass('text-sm'); + }); + + it('should merge custom class with style and size variant classes', () => { + render( + <Input + className="text-red-500" + styleVariant="primary" + sizeVariant="medium" + data-testid="input" + />, + ); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveClass('text-red-500'); + expect(inputElement).toHaveClass('h-12'); + expect(inputElement).toHaveClass('bg-cultured'); + }); + + it(' should handle onChange event', () => { + const handleChange = jest.fn(); + render(<Input onChange={handleChange} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + fireEvent.change(inputElement, { target: { value: 'Hello, World!' } }); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should render with a placeholder', () => { + const placeholderText = 'Type here...'; + render(<Input placeholder={placeholderText} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveAttribute('placeholder', placeholderText); + }); + + it('should render with a default value', () => { + const defaultValue = 'Initial value'; + render(<Input defaultValue={defaultValue} data-testid="input" />); + const inputElement = screen.getByTestId('input'); + expect(inputElement).toHaveValue(defaultValue); + }); +}); diff --git a/src/shared/Input/Input.component.tsx b/src/shared/Input/Input.component.tsx new file mode 100644 index 00000000..68cf9097 --- /dev/null +++ b/src/shared/Input/Input.component.tsx @@ -0,0 +1,33 @@ +import React, { InputHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type StyleVariant = 'primary'; +type SizeVariant = 'small' | 'medium'; + +type InputProps = { + className?: string; + styleVariant?: StyleVariant; + sizeVariant?: SizeVariant; +} & InputHTMLAttributes<HTMLInputElement>; + +const styleVariants = { + primary: + 'w-full border border-transparent bg-cultured px-2 py-2.5 font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', +} as const; + +const sizeVariants = { + small: 'rounded-s h-10 text-xs', + medium: 'rounded-lg h-12 text-sm', +} as const; + +export const Input = ({ + className = '', + sizeVariant = 'small', + styleVariant = 'primary', + ...props +}: InputProps): React.ReactNode => ( + <input + {...props} + className={twMerge(styleVariants[styleVariant], sizeVariants[sizeVariant], className)} + /> +); diff --git a/src/shared/Input/index.ts b/src/shared/Input/index.ts new file mode 100644 index 00000000..dfcccdc6 --- /dev/null +++ b/src/shared/Input/index.ts @@ -0,0 +1 @@ +export { Input } from './Input.component'; diff --git a/src/shared/Textarea/Textarea.component.test.tsx b/src/shared/Textarea/Textarea.component.test.tsx new file mode 100644 index 00000000..e337f637 --- /dev/null +++ b/src/shared/Textarea/Textarea.component.test.tsx @@ -0,0 +1,53 @@ +/* eslint-disable no-magic-numbers */ +import React from 'react'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { Textarea } from './Textarea.component'; + +describe('Textarea - Component', () => { + it('should render with proper testid', () => { + render(<Textarea data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toBeInTheDocument(); + }); + + it('should apply the default style variant when none is provided', () => { + render(<Textarea data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should apply the specified style variant', () => { + render(<Textarea styleVariant="primary" data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should merge custom class with style variant classes', () => { + render(<Textarea className="text-red-500" styleVariant="primary" data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveClass('text-red-500'); + expect(textareaElement).toHaveClass('bg-cultured'); + }); + + it('should handle onChange event', () => { + const handleChange = jest.fn(); + render(<Textarea onChange={handleChange} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + fireEvent.change(textareaElement, { target: { value: 'Hello, World!' } }); + expect(handleChange).toHaveBeenCalledTimes(1); + }); + + it('should render with a placeholder', () => { + const placeholderText = 'Type here...'; + render(<Textarea placeholder={placeholderText} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveAttribute('placeholder', placeholderText); + }); + + it('should render with a default value', () => { + const defaultValue = 'Initial value'; + render(<Textarea defaultValue={defaultValue} data-testid="textarea" />); + const textareaElement = screen.getByTestId('textarea'); + expect(textareaElement).toHaveValue(defaultValue); + }); +}); diff --git a/src/shared/Textarea/Textarea.component.tsx b/src/shared/Textarea/Textarea.component.tsx new file mode 100644 index 00000000..b300f21a --- /dev/null +++ b/src/shared/Textarea/Textarea.component.tsx @@ -0,0 +1,22 @@ +import React, { TextareaHTMLAttributes } from 'react'; +import { twMerge } from 'tailwind-merge'; + +type StyleVariant = 'primary'; + +type TextareaProps = { + className?: string; + styleVariant?: StyleVariant; +} & TextareaHTMLAttributes<HTMLTextAreaElement>; + +const styleVariants = { + primary: + 'w-full resize-none rounded-lg border border-transparent bg-cultured px-2 py-2.5 text-xs font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', +} as const; + +export const Textarea = ({ + className = '', + styleVariant = 'primary', + ...props +}: TextareaProps): React.ReactNode => ( + <textarea {...props} className={twMerge(styleVariants[styleVariant], className)} /> +); diff --git a/src/shared/Textarea/index.ts b/src/shared/Textarea/index.ts new file mode 100644 index 00000000..11d2fe00 --- /dev/null +++ b/src/shared/Textarea/index.ts @@ -0,0 +1 @@ +export { Textarea } from './Textarea.component'; diff --git a/src/types/OLrendering.ts b/src/types/OLrendering.ts index 3a651659..11ab030c 100644 --- a/src/types/OLrendering.ts +++ b/src/types/OLrendering.ts @@ -3,9 +3,13 @@ import { Color } from './models'; export type OverlayBioEntityRender = { id: number; modelId: number; + /** bottom left corner of whole element, Xmin */ x1: number; + /** bottom left corner of whole element, Ymin */ y1: number; + /** top righ corner of whole element, xMax */ x2: number; + /** top righ corner of whole element, yMax */ y2: number; width: number; height: number; diff --git a/src/types/models.ts b/src/types/models.ts index a1e01c3d..8f4582e5 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -4,12 +4,18 @@ import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { colorSchema } from '@/models/colorSchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; +import { configurationSchema } from '@/models/configurationSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; import { loginSchema } from '@/models/loginSchema'; import { mapBackground } from '@/models/mapBackground'; -import { mapOverlay } from '@/models/mapOverlay'; +import { + createdOverlayFileSchema, + createdOverlaySchema, + mapOverlay, + uploadedOverlayFileContentSchema, +} from '@/models/mapOverlay'; import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; @@ -24,6 +30,7 @@ import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; import { referenceSchema } from '@/models/referenceSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; +import { statisticsSchema } from '@/models/statisticsSchema'; import { targetSchema } from '@/models/targetSchema'; import { z } from 'zod'; @@ -51,5 +58,10 @@ export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; export type SessionValid = z.infer<typeof sessionSchemaValid>; export type Login = z.infer<typeof loginSchema>; export type ConfigurationOption = z.infer<typeof configurationOptionSchema>; +export type Configuration = z.infer<typeof configurationSchema>; export type OverlayBioEntity = z.infer<typeof overlayBioEntitySchema>; +export type CreatedOverlayFile = z.infer<typeof createdOverlayFileSchema>; +export type UploadedOverlayFileContent = z.infer<typeof uploadedOverlayFileContentSchema>; +export type CreatedOverlay = z.infer<typeof createdOverlaySchema>; export type Color = z.infer<typeof colorSchema>; +export type Statistics = z.infer<typeof statisticsSchema>; diff --git a/src/types/query.ts b/src/types/query.ts index a6696aa9..98309123 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -6,6 +6,7 @@ export interface QueryData { modelId?: number; backgroundId?: number; initialPosition?: Partial<Point>; + overlaysId?: number[]; } export interface QueryDataParams { @@ -16,6 +17,7 @@ export interface QueryDataParams { x?: number; y?: number; z?: number; + overlaysId?: string; } export interface QueryDataRouterParams { @@ -26,4 +28,5 @@ export interface QueryDataRouterParams { x?: string; y?: string; z?: string; + overlaysId?: string; } diff --git a/src/utils/number/roundToTwoDigits.test.ts b/src/utils/number/roundToTwoDigits.test.ts new file mode 100644 index 00000000..68fa6ecf --- /dev/null +++ b/src/utils/number/roundToTwoDigits.test.ts @@ -0,0 +1,32 @@ +/* eslint-disable no-magic-numbers */ +import { roundToTwoDigits } from './roundToTwoDigits'; + +describe('roundToTwoDiggits', () => { + it('should round a positive number with more than two decimal places to two decimal places', () => { + expect(roundToTwoDigits(3.14159265359)).toBe(3.14); + expect(roundToTwoDigits(2.71828182845)).toBe(2.72); + expect(roundToTwoDigits(1.23456789)).toBe(1.23); + }); + + it('should round a negative number with more than two decimal places to two decimal places', () => { + expect(roundToTwoDigits(-3.14159265359)).toBe(-3.14); + expect(roundToTwoDigits(-2.71828182845)).toBe(-2.72); + expect(roundToTwoDigits(-1.23456789)).toBe(-1.23); + }); + + it('should round a number with exactly two decimal places to two decimal places', () => { + expect(roundToTwoDigits(3.14)).toBe(3.14); + expect(roundToTwoDigits(2.72)).toBe(2.72); + expect(roundToTwoDigits(1.23)).toBe(1.23); + }); + + it('should round a number with less than two decimal places to two decimal places', () => { + expect(roundToTwoDigits(3)).toBe(3.0); + expect(roundToTwoDigits(2.7)).toBe(2.7); + expect(roundToTwoDigits(1.2)).toBe(1.2); + }); + + it('should round zero to two decimal places', () => { + expect(roundToTwoDigits(0)).toBe(0); + }); +}); diff --git a/src/utils/number/roundToTwoDigits.ts b/src/utils/number/roundToTwoDigits.ts new file mode 100644 index 00000000..0a3aa52d --- /dev/null +++ b/src/utils/number/roundToTwoDigits.ts @@ -0,0 +1,2 @@ +const TWO_DIGITS = 100; +export const roundToTwoDigits = (x: number): number => Math.round(x * TWO_DIGITS) / TWO_DIGITS; diff --git a/src/utils/parseQueryToTypes.test.ts b/src/utils/parseQueryToTypes.test.ts index 83d99107..113111d7 100644 --- a/src/utils/parseQueryToTypes.test.ts +++ b/src/utils/parseQueryToTypes.test.ts @@ -10,6 +10,7 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ perfectMatch: 'true' })).toEqual({ @@ -18,6 +19,7 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ perfectMatch: 'false' })).toEqual({ @@ -26,6 +28,7 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ modelId: '666' })).toEqual({ @@ -34,6 +37,7 @@ describe('parseQueryToTypes', () => { modelId: 666, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ x: '2137' })).toEqual({ searchValue: undefined, @@ -41,6 +45,7 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: 2137, y: undefined, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ y: '1372' })).toEqual({ searchValue: undefined, @@ -48,6 +53,7 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: 1372, z: undefined }, + overlaysId: undefined, }); expect(parseQueryToTypes({ z: '3721' })).toEqual({ searchValue: undefined, @@ -55,6 +61,16 @@ describe('parseQueryToTypes', () => { modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: 3721 }, + overlaysId: undefined, + }); + expect(parseQueryToTypes({ overlaysId: '1,2,3' })).toEqual({ + searchValue: undefined, + perfectMatch: false, + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: undefined, y: undefined, z: undefined }, + // eslint-disable-next-line no-magic-numbers + overlaysId: [1, 2, 3], }); }); }); diff --git a/src/utils/parseQueryToTypes.ts b/src/utils/parseQueryToTypes.ts index 9dc659c0..ee744037 100644 --- a/src/utils/parseQueryToTypes.ts +++ b/src/utils/parseQueryToTypes.ts @@ -10,4 +10,5 @@ export const parseQueryToTypes = (query: QueryDataRouterParams): QueryData => ({ y: Number(query.y) || undefined, z: Number(query.z) || undefined, }, + overlaysId: query.overlaysId?.split(',').map(Number), }); diff --git a/src/utils/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts index dc4bc4b6..77244318 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.test.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.test.ts @@ -25,6 +25,10 @@ describe('useReduxBusQueryManager - util', () => { data: [], loading: 'succeeded' as Loading, error: { name: '', message: '' }, + addOverlay: { + loading: 'idle' as Loading, + error: { name: '', message: '' }, + }, }; const { Wrapper } = getReduxWrapperWithStore({ -- GitLab