diff --git a/.eslintrc.json b/.eslintrc.json index 3ed8a0dac26c9f37e4753346fdf8856d5351cabe..8712576ff44b991298e686588f6cb343c1bf9ab6 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 d01b9d680fd9d7dee7d71e9c42bc7bda5299573b..cfe86d9dc467bb90db330ae1be467580fe11170e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,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", @@ -3056,6 +3057,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", @@ -6377,10 +6386,16 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/file-saver": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", - "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" + "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", @@ -11519,6 +11534,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", @@ -16089,6 +16120,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", @@ -18506,6 +18542,14 @@ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-1.3.8.tgz", "integrity": "sha512-spKHSBQIxxS81N/O21WmuXA2F6wppUCsutpzenOeZzOCCJ5gEfcbqJP983IrpLXzYmXnMUa6J03SubcNPdKrlg==" }, + "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", @@ -20987,7 +21031,8 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true }, "lodash.camelcase": { "version": "4.3.0", @@ -22120,6 +22165,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 b567f06f4d78b70ea111cee67dca18ef5cc89b37..8a754dc0eed77ccbde955e6e3a335e05258a463e 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,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 e8c65391c7967eb34c7359010aaef4e93a7dd653..1d944c81f3e4f47f19d599c4f702725886518791 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/ContextMenu/ContextMenu.component.tsx b/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx index 0446bc33d5f3269e723113672e6d9876f1f12799..a6811e654b1c3cb140eced363877b627cdb4ea6d 100644 --- a/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx +++ b/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx @@ -1,11 +1,11 @@ +import { searchedBioEntityElementUniProtIdSelector } from '@/redux/bioEntity/bioEntity.selectors'; import { contextMenuSelector } from '@/redux/contextMenu/contextMenu.selector'; +import { closeContextMenu } from '@/redux/contextMenu/contextMenu.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { openMolArtModalById } from '@/redux/modal/modal.slice'; import React from 'react'; import { twMerge } from 'tailwind-merge'; -import { searchedBioEntityElementUniProtIdSelector } from '@/redux/bioEntity/bioEntity.selectors'; -import { openMolArtModalById } from '@/redux/modal/modal.slice'; -import { closeContextMenu } from '@/redux/contextMenu/contextMenu.slice'; import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT } from '@/constants/common'; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx index 354b53377e7fa13900bc8f38b872099caf622514..40ac94361133d1a08a9d31260dd277a28c7f35d6 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/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index 6783d04b7a443687caba8e69a88d753c5a865738..29ce0fb0c6e9bf9d6c4f15fbc75fa6ef13ecf1e6 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -2,6 +2,7 @@ import logoImg from '@/assets/images/logo.png'; import luxembourgLogoImg from '@/assets/images/luxembourg-logo.png'; import { openDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openLegend } from '@/redux/legend/legend.slice'; import { IconButton } from '@/shared/IconButton'; import Image from 'next/image'; @@ -21,7 +22,7 @@ export const NavBar = (): JSX.Element => { }; const openDrawerLegend = (): void => { - dispatch(openDrawer('legend')); + dispatch(openLegend()); }; return ( diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 8bf424afb4661848e7bd3a3b29204b4fe305eb1f..2d60852eae90f9bb1e2912beca4ba75806913b93 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 ace83985ff36e0cad91467f50ae0a16c2a6d0c4d..b55022ff3b3690835076976d2f7f443cf374689e 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 0000000000000000000000000000000000000000..df05c8e0c5e7fcef43da5d5085d34a3c6ea14f43 --- /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 0000000000000000000000000000000000000000..a68fd3894887e2c5fa30733e4388d8f8f76927eb --- /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 0000000000000000000000000000000000000000..3b82aaf76f1b363c9cc2429bbe05d17938bdac29 --- /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 0000000000000000000000000000000000000000..1fb3437a0f779358067827ff463df4d3f8ca76c2 --- /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 0000000000000000000000000000000000000000..68dbe9c6ecf1ea715852925c85e2f2d18b3d4ad2 --- /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 0000000000000000000000000000000000000000..45a47c9fa7af24f99f70a7c691a30c03a185a509 --- /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 0000000000000000000000000000000000000000..99569fa0dc72b5da8578a79faa5187e81cc55483 --- /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 0000000000000000000000000000000000000000..b0d478bba09f05a0b119a0b3ce84163684055a70 --- /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 0000000000000000000000000000000000000000..7d4a61e43bafadb39c60bce53019003b0ab58675 --- /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 0000000000000000000000000000000000000000..df19cb66cbdd04c7511105db937cf4f5f41be111 --- /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 0000000000000000000000000000000000000000..f3795e9b9f5c9828957e6691c5427fd42deacd2d --- /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 0000000000000000000000000000000000000000..3b82aaf76f1b363c9cc2429bbe05d17938bdac29 --- /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 0000000000000000000000000000000000000000..381ba5cc9c7fb3f22761c28516e469633a262a85 --- /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 0000000000000000000000000000000000000000..c6d8084fe24299b8f13a6fc07dc1aa6019188646 --- /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 0000000000000000000000000000000000000000..e2ece6b51ec445bd3c3b172120ce8679d5fe795c --- /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 0000000000000000000000000000000000000000..167db8672847d14dac8a6cc038be63cfe105a582 --- /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 0000000000000000000000000000000000000000..c4d5d6f495b4bd37ea3286f85f600b56e173a380 --- /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 0000000000000000000000000000000000000000..4d228509706c27621afef1d5620a23dc03d647ab --- /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 0000000000000000000000000000000000000000..0c37bdf6907d676eeaed5182c5002710327ff13c --- /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 0000000000000000000000000000000000000000..34e10ae6cf11eba8a045e3929738f636f2a03620 --- /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 0000000000000000000000000000000000000000..a8a7cc990d683cad01bd17ca5f8007f4bce4e86b --- /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 0000000000000000000000000000000000000000..ce8a0cc157c89e6d8b723d3b67d9479b8a1df515 --- /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 0000000000000000000000000000000000000000..4a0d339af86ef2eb78cdabd3a2206c38d70ef7a1 --- /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 0000000000000000000000000000000000000000..cec6029b9679d30b241a675d1b3d9b222b610303 --- /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 0000000000000000000000000000000000000000..068348d1f068bbe22bd14e16b430a1948fee3453 --- /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 0000000000000000000000000000000000000000..e4c872bd0699c34362d8f435c04ecc72d48d54a0 --- /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 0000000000000000000000000000000000000000..c30a1082e25adc6f5dab141a2fa22318a128f853 --- /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 0000000000000000000000000000000000000000..f22cacf63959907edbf47f9771dc5023e8c74d7a --- /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 0000000000000000000000000000000000000000..c604f80e4bc77ddedf4ef437a4ff6bb7e3fa0f19 --- /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 0000000000000000000000000000000000000000..e8714166f2e29bb4f16e8d7ada335b877d6cc2de --- /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 0000000000000000000000000000000000000000..3eda3a5493ce18cb7f02d1d2e2a499a40c212a15 --- /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 0000000000000000000000000000000000000000..cd0ee383835fee684240c6c080c9cdb2e82eefec --- /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 0000000000000000000000000000000000000000..b471dcc5b228c340d01ff78be795ed31e3fd2a16 --- /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 0000000000000000000000000000000000000000..313d407de007a97d9e037c4c06d803d1e7a05b28 --- /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/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx index dd3cc6eaab9b49e7ce522f2b28f833d29d3f1bca..1c2e5658c78a16867bf6ec52ebbad5634fa27128 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.test.tsx @@ -47,30 +47,56 @@ describe('OverlayListItem - component', () => { expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument(); }); - it('should trigger view overlays on view button click and switch background to Empty if available', async () => { - const OVERLAY_ID = 21; - const { store } = renderComponent({ - map: initialMapStateFixture, - backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, - overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, - models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, - }); - mockedAxiosNewClient - .onGet(apiPath.getOverlayBioEntity({ overlayId: OVERLAY_ID, modelId: 5053 })) - .reply(HttpStatusCode.Ok, overlayBioEntityFixture); + describe('view overlays', () => { + it('should trigger view overlays on view button click and switch background to Empty if available', async () => { + const OVERLAY_ID = 21; + const MODEL_ID = 5053; + const { store } = renderComponent({ + map: initialMapStateFixture, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, + }); + mockedAxiosNewClient + .onGet(apiPath.getOverlayBioEntity({ overlayId: OVERLAY_ID, modelId: MODEL_ID })) + .reply(HttpStatusCode.Ok, overlayBioEntityFixture); + + expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); - expect(store.getState().map.data.backgroundId).toBe(DEFAULT_BACKGROUND_ID); + const ViewButton = screen.getByRole('button', { name: 'View' }); + await act(() => { + ViewButton.click(); + }); - const ViewButton = screen.getByRole('button', { name: 'View' }); - await act(() => { - ViewButton.click(); + expect(store.getState().map.data.backgroundId).toBe(EMPTY_BACKGROUND_ID); + expect(store.getState().overlayBioEntity.data).toEqual({ + [OVERLAY_ID]: { + [MODEL_ID]: parseOverlayBioEntityToOlRenderingFormat(overlayBioEntityFixture, OVERLAY_ID), + }, + }); }); + it('should disable overlay on view button click if overlay is active', async () => { + const OVERLAY_ID = 21; + const { store } = renderComponent({ + map: { + ...initialMapStateFixture, + data: { ...initialMapStateFixture.data, backgroundId: EMPTY_BACKGROUND_ID }, + }, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + overlayBioEntity: { ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, overlaysId: [OVERLAY_ID] }, + models: { ...MODELS_INITIAL_STATE_MOCK, data: [CORE_PD_MODEL_MOCK] }, + }); + + const ViewButton = screen.getByRole('button', { name: 'Disable' }); + await act(() => { + ViewButton.click(); + }); - expect(store.getState().map.data.backgroundId).toBe(EMPTY_BACKGROUND_ID); - expect(store.getState().overlayBioEntity.data).toEqual( - parseOverlayBioEntityToOlRenderingFormat(overlayBioEntityFixture, OVERLAY_ID), - ); + expect(store.getState().overlayBioEntity.data).toEqual([]); + expect(store.getState().overlayBioEntity.overlaysId).toEqual([]); + }); }); + // TODO implement when connecting logic to component it.skip('should trigger download overlay to PC on download button click', () => {}); }); diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx index 20f173fe7eb91fc7cf480e7cab43a30bc0a0187e..ab40e7cfecb3b1e240e269cb7fa79148f76fbef0 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx @@ -1,7 +1,5 @@ -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { getOverlayBioEntityForAllModels } from '@/redux/overlayBioEntity/overlayBioEntity.thunk'; import { Button } from '@/shared/Button'; -import { useEmptyBackground } from './hooks/useEmptyBackground'; +import { useOverlay } from './hooks/useOverlay'; interface OverlayListItemProps { name: string; @@ -10,20 +8,14 @@ interface OverlayListItemProps { export const OverlayListItem = ({ name, overlayId }: OverlayListItemProps): JSX.Element => { const onDownloadOverlay = (): void => {}; - const dispatch = useAppDispatch(); - const { setBackgroundtoEmptyIfAvailable } = useEmptyBackground(); - - const onViewOverlay = (): void => { - setBackgroundtoEmptyIfAvailable(); - dispatch(getOverlayBioEntityForAllModels({ overlayId })); - }; + const { toggleOverlay, isOverlayActive } = useOverlay(overlayId); return ( <li className="flex flex-row flex-nowrap justify-between pl-5 [&:not(:last-of-type)]:mb-4"> <span>{name}</span> <div className="flex flex-row flex-nowrap"> - <Button variantStyles="ghost" className="mr-4 max-h-8" onClick={onViewOverlay}> - View + <Button variantStyles="ghost" className="mr-4 max-h-8" onClick={toggleOverlay}> + {isOverlayActive ? 'Disable' : 'View'} </Button> <Button className="max-h-8" variantStyles="ghost" onClick={onDownloadOverlay}> Download diff --git a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts new file mode 100644 index 0000000000000000000000000000000000000000..89f65ee8a2651509149b0ee34070799405e6879a --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts @@ -0,0 +1,28 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { isOverlayActiveSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { removeOverlayBioEntityForGivenOverlay } from '@/redux/overlayBioEntity/overlayBioEntity.slice'; +import { getOverlayBioEntityForAllModels } from '@/redux/overlayBioEntity/overlayBioEntity.thunk'; +import { useEmptyBackground } from './useEmptyBackground'; + +type UseOverlay = { + toggleOverlay: () => void; + isOverlayActive: boolean; +}; + +export const useOverlay = (overlayId: number): UseOverlay => { + const dispatch = useAppDispatch(); + const isOverlayActive = useAppSelector(state => isOverlayActiveSelector(state, overlayId)); + const { setBackgroundtoEmptyIfAvailable } = useEmptyBackground(); + + const toggleOverlay = (): void => { + if (isOverlayActive) { + dispatch(removeOverlayBioEntityForGivenOverlay({ overlayId })); + } else { + setBackgroundtoEmptyIfAvailable(); + dispatch(getOverlayBioEntityForAllModels({ overlayId })); + } + }; + + return { toggleOverlay, isOverlayActive }; +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx index c64ecf7408b301eb3f5b77ed21ee963d748d5fb3..13083c2cb94cf8d66be270f3079880c84b106fed 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx @@ -1,15 +1,26 @@ +import { STEP } from '@/constants/searchDrawer'; +import { currentStepOverlayDrawerStateSelector } from '@/redux/drawer/drawer.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { DrawerHeading } from '@/shared/DrawerHeading'; import { GeneralOverlays } from './GeneralOverlays'; +import { UserOverlayForm } from './UserOverlayForm'; import { UserOverlays } from './UserOverlays'; 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/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5d88155049e41751da4ead6de5ec0b85e309b5ee --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.test.tsx @@ -0,0 +1,34 @@ +import { BASE_API_URL, PROJECT_ID } from '@/constants'; +import { overlayFixture } from '@/models/fixtures/overlaysFixture'; +import { MapOverlay } from '@/types/models'; +import { render, screen } from '@testing-library/react'; +import { OverlaySingleLegend } from './OverlaySingleLegend.component'; + +const renderComponent = ({ overlay }: { overlay: MapOverlay }): void => { + render(<OverlaySingleLegend overlay={overlay} />); +}; + +describe('OverlaySingleLegend - component', () => { + beforeEach(() => { + renderComponent({ + overlay: { + ...overlayFixture, + name: 'overlay name', + idObject: 1234, + }, + }); + }); + + it('should render title with overlay name', () => { + expect(screen.getByText('overlay name')).toBeInTheDocument(); + }); + + it('should render image with valid src and alt', () => { + const image = screen.getByAltText('overlay name legend'); + + expect(image).toBeInTheDocument(); + expect(image.getAttribute('src')).toBe( + `${BASE_API_URL}/projects/${PROJECT_ID}/overlays/1234:downloadLegend`, + ); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..77169eea65e94944b27a80aa205a8c8f6fbd3d7c --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/OverlaySingleLegend.component.tsx @@ -0,0 +1,19 @@ +/* eslint-disable @next/next/no-img-element */ +import { BASE_API_URL, PROJECT_ID } from '@/constants'; +import { MapOverlay } from '@/types/models'; + +interface Props { + overlay: Pick<MapOverlay, 'name' | 'idObject'>; +} + +export const OverlaySingleLegend = ({ overlay }: Props): JSX.Element => { + const overlayName = overlay.name; + const overlayImageSrc = `${BASE_API_URL}/projects/${PROJECT_ID}/overlays/${overlay.idObject}:downloadLegend`; + + return ( + <div> + <p className="mb-5 text-sm font-semibold">{overlayName}</p> + <img src={overlayImageSrc} alt={`${overlayName} legend`} /> + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/index.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/index.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f15cfb77b0c708addbecb8dcf496545904a5d2d9 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaySingleLegend/index.tsx @@ -0,0 +1 @@ +export { OverlaySingleLegend } from './OverlaySingleLegend.component'; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94449da6f983f047afa94be4094996d4eab8ae7c --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.test.tsx @@ -0,0 +1,67 @@ +import { BASE_API_URL, PROJECT_ID } from '@/constants'; +import { OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK } from '@/redux/overlayBioEntity/overlayBioEntity.mock'; +import { + OVERLAYS_PUBLIC_FETCHED_STATE_MOCK, + PUBLIC_OVERLAYS_MOCK, +} from '@/redux/overlays/overlays.mock'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { OverlaysLegends } from './OverlaysLegends.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <OverlaysLegends /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('OverlaysLegends - component', () => { + describe('when active overlays are empty', () => { + beforeEach(() => { + renderComponent({ + overlays: OVERLAYS_PUBLIC_FETCHED_STATE_MOCK, + overlayBioEntity: { + ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + }, + }); + }); + + it('should not render list of overlays legends', () => { + expect(screen.getByTestId('overlays-legends')).toBeEmptyDOMElement(); + }); + }); + + describe('when active overlays are present', () => { + beforeEach(() => { + renderComponent({ + overlays: OVERLAYS_PUBLIC_FETCHED_STATE_MOCK, + overlayBioEntity: { + ...OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, + overlaysId: PUBLIC_OVERLAYS_MOCK.map(o => o.idObject), + }, + }); + }); + + it.each(PUBLIC_OVERLAYS_MOCK)('should render overlay legend', overlay => { + const image = screen.getByAltText(`${overlay.name} legend`); + + expect(screen.getByText(overlay.name)).toBeInTheDocument(); + expect(image).toBeInTheDocument(); + expect(image.getAttribute('src')).toBe( + `${BASE_API_URL}/projects/${PROJECT_ID}/overlays/${overlay.idObject}:downloadLegend`, + ); + }); + }); +}); diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b690ff07c72826f0286739194653d6547f7f45d2 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx @@ -0,0 +1,15 @@ +import { activeOverlaysSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { useSelector } from 'react-redux'; +import { OverlaySingleLegend } from './OverlaySingleLegend'; + +export const OverlaysLegends = (): JSX.Element => { + const overlays = useSelector(activeOverlaysSelector); + + return ( + <div className="p-6" data-testid="overlays-legends"> + {overlays.map(overlay => ( + <OverlaySingleLegend key={overlay.idObject} overlay={overlay} /> + ))} + </div> + ); +}; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/index.ts b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..75ba6111015a96203b61869be4465b6d8b5c3572 --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/index.ts @@ -0,0 +1 @@ +export { OverlaysLegends } from './OverlaysLegends.component'; 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 0000000000000000000000000000000000000000..83a68bfa1714e773b550a7c46552e14963b77832 --- /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 0000000000000000000000000000000000000000..4370a451657d8e912a245669f31b93877346e7d2 --- /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 0000000000000000000000000000000000000000..07a87a8db76e41abacdb0f0b31bdd5f2402de7a2 --- /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 0000000000000000000000000000000000000000..acc140ebd165aa26853145692ff6bdaa17eced0f --- /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 0000000000000000000000000000000000000000..0e29ed5fdd9870377c00ac323caf421e0fc597d0 --- /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 0000000000000000000000000000000000000000..147e48942ee3dbd6730acc1ad69fe13bc1f5057a --- /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 0000000000000000000000000000000000000000..b7c754c8f76d782d67e55a0beec1f0c2f1a3021c --- /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 0000000000000000000000000000000000000000..ed8f04b70e8c38a2107665c1a3957c9c862aa83c --- /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 0000000000000000000000000000000000000000..3b4622dbcb86e2a818d40f7c2a29cf3556f19fa6 --- /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 0000000000000000000000000000000000000000..531a4e204a62d2bdb9742ce950ee84041c9cd92f --- /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 0000000000000000000000000000000000000000..9f3baf2ac99c9e6a9cca8831cbe4ce35bdbf2ac8 --- /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 0000000000000000000000000000000000000000..e3c499996a57dc078523f9177b6a58799d2198ac --- /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 0000000000000000000000000000000000000000..988e9ea0989902028959116c91971b6dcf26e984 --- /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 0000000000000000000000000000000000000000..dda281bf7dc92145f1902d283f40a7521a34ee25 --- /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 0000000000000000000000000000000000000000..e51db0f06b59bd7e0dec1b5460263e3f47e5efc2 --- /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 0000000000000000000000000000000000000000..e5e7267c317de2608eead6063a5149f397b2dcad --- /dev/null +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx @@ -0,0 +1,84 @@ +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: {}, + molArtState: {}, + }, + }); + 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 d594f01698bd6bc04fc19d67b02dc196a7e22221..2db18b4201bb8ce15a36ea59994a5238e011cb79 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 bc8a61dd7415b6d6eb0dd483cfe127505814db94..b16f506928a7236df0d2f84d783eecd727667fdf 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 a533a5b83bc7ca1eb96e6d1210df63868420d05d..94bc618d7d632fd681c45a9f96b2523fd6b8dffc 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 9477d59d8d24cfd5d3af9bec571e13694a482409..c36eb3a97ab71c8c936439e223f01849b459a275 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/Legend/Legend.component.test.tsx b/src/components/Map/Legend/Legend.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ad65a5a609f1c6843970b68345781f9bed62000 --- /dev/null +++ b/src/components/Map/Legend/Legend.component.test.tsx @@ -0,0 +1,75 @@ +import { currentLegendImagesSelector, legendSelector } from '@/redux/legend/legend.selectors'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen, within } from '@testing-library/react'; +import { Legend } from './Legend.component'; +import { LEGEND_ROLE } from './Legend.constants'; + +jest.mock('../../../redux/legend/legend.selectors', () => ({ + legendSelector: jest.fn(), + currentLegendImagesSelector: jest.fn(), +})); + +const legendSelectorMock = legendSelector as unknown as jest.Mock; +const currentLegendImagesSelectorMock = currentLegendImagesSelector as unknown as jest.Mock; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <Legend /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Legend - component', () => { + beforeAll(() => { + currentLegendImagesSelectorMock.mockImplementation(() => []); + }); + + describe('when is closed', () => { + beforeEach(() => { + legendSelectorMock.mockImplementation(() => ({ + isOpen: false, + })); + renderComponent(); + }); + + it('should render the component without translation', () => { + expect(screen.getByRole(LEGEND_ROLE)).not.toHaveClass('translate-y-0'); + }); + }); + + describe('when is open', () => { + beforeEach(() => { + legendSelectorMock.mockImplementation(() => ({ + isOpen: true, + })); + renderComponent(); + }); + + it('should render the component with translation', () => { + expect(screen.getByRole(LEGEND_ROLE)).toHaveClass('translate-y-0'); + }); + + it('should render legend header', async () => { + const legendContainer = screen.getByRole(LEGEND_ROLE); + const legendHeader = await within(legendContainer).getByTestId('legend-header'); + expect(legendHeader).toBeInTheDocument(); + }); + + it('should render legend images', async () => { + const legendContainer = screen.getByRole(LEGEND_ROLE); + const legendImages = await within(legendContainer).getByTestId('legend-images'); + expect(legendImages).toBeInTheDocument(); + }); + }); +}); diff --git a/src/components/Map/Legend/Legend.component.tsx b/src/components/Map/Legend/Legend.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7414765ec96c9b42908538ff07ef67dd91fa2434 --- /dev/null +++ b/src/components/Map/Legend/Legend.component.tsx @@ -0,0 +1,24 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { legendSelector } from '@/redux/legend/legend.selectors'; +import * as React from 'react'; +import { twMerge } from 'tailwind-merge'; +import { LEGEND_ROLE } from './Legend.constants'; +import { LegendHeader } from './LegendHeader'; +import { LegendImages } from './LegendImages'; + +export const Legend: React.FC = () => { + const { isOpen } = useAppSelector(legendSelector); + + return ( + <div + className={twMerge( + 'absolute bottom-0 left-[88px] z-10 w-[calc(100%-88px)] -translate-y-[-100%] transform border border-divide bg-white-pearl text-font-500 transition-all duration-500', + isOpen && 'translate-y-0', + )} + role={LEGEND_ROLE} + > + <LegendHeader /> + <LegendImages /> + </div> + ); +}; diff --git a/src/components/Map/Legend/Legend.constants.ts b/src/components/Map/Legend/Legend.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0b04e860672f967addbfb294577e5a8068db22f --- /dev/null +++ b/src/components/Map/Legend/Legend.constants.ts @@ -0,0 +1 @@ +export const LEGEND_ROLE = 'legend'; diff --git a/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx b/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1cf0291787b2633cd9c2c66ebf5785551be2d161 --- /dev/null +++ b/src/components/Map/Legend/LegendHeader/LegendHeader.component.test.tsx @@ -0,0 +1,49 @@ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { AppDispatch, RootState } from '@/redux/store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { InitialStoreState } from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { CLOSE_BUTTON_ROLE, LegendHeader } from './LegendHeader.component'; + +const renderComponent = ( + initialStore?: InitialStoreState, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore); + return ( + render( + <Wrapper> + <LegendHeader /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LegendHeader - component', () => { + it('should render legend title', () => { + renderComponent(); + + const legendTitle = screen.getByText('Legend'); + expect(legendTitle).toBeInTheDocument(); + }); + + it('should render legend close button', () => { + renderComponent(); + + const closeButton = screen.getByRole(CLOSE_BUTTON_ROLE); + expect(closeButton).toBeInTheDocument(); + }); + + it('should close legend on close button click', async () => { + const { store } = renderComponent(); + + const closeButton = screen.getByRole(CLOSE_BUTTON_ROLE); + closeButton.click(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT].type).toBe('legend/closeLegend'); + }); +}); diff --git a/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx b/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..19e52d7ad39a82c01785d3f0b3e3fa6dd5f2b100 --- /dev/null +++ b/src/components/Map/Legend/LegendHeader/LegendHeader.component.tsx @@ -0,0 +1,31 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { closeLegend } from '@/redux/legend/legend.slice'; +import { IconButton } from '@/shared/IconButton'; + +export const CLOSE_BUTTON_ROLE = 'close-legend-button'; + +export const LegendHeader: React.FC = () => { + const dispatch = useAppDispatch(); + + const handleCloseLegend = (): void => { + dispatch(closeLegend()); + }; + + return ( + <div + data-testid="legend-header" + className="flex items-center justify-between border-b border-b-divide px-6" + > + <div className="py-8 text-xl"> + <span className="font-semibold">Legend</span> + </div> + <IconButton + className="bg-white-pearl" + classNameIcon="fill-font-500" + icon="close" + role={CLOSE_BUTTON_ROLE} + onClick={handleCloseLegend} + /> + </div> + ); +}; diff --git a/src/components/Map/Legend/LegendHeader/index.ts b/src/components/Map/Legend/LegendHeader/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..8cd3710709da007b0d0ea5c1756d350dd1e9b650 --- /dev/null +++ b/src/components/Map/Legend/LegendHeader/index.ts @@ -0,0 +1 @@ +export { LegendHeader } from './LegendHeader.component'; diff --git a/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx b/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e6d2681f440694b773c135e4fd303df4788c7516 --- /dev/null +++ b/src/components/Map/Legend/LegendImages/LegendImages.component.test.tsx @@ -0,0 +1,66 @@ +import { BASE_MAP_IMAGES_URL } from '@/constants'; +import { currentLegendImagesSelector, legendSelector } from '@/redux/legend/legend.selectors'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { LegendImages } from './LegendImages.component'; + +jest.mock('../../../../redux/legend/legend.selectors', () => ({ + legendSelector: jest.fn(), + currentLegendImagesSelector: jest.fn(), +})); + +const legendSelectorMock = legendSelector as unknown as jest.Mock; +const currentLegendImagesSelectorMock = currentLegendImagesSelector as unknown as jest.Mock; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <LegendImages /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('LegendImages - component', () => { + beforeAll(() => { + legendSelectorMock.mockImplementation(() => ({ + isOpen: true, + })); + }); + + describe('when current images are empty', () => { + beforeEach(() => { + currentLegendImagesSelectorMock.mockImplementation(() => []); + renderComponent(); + }); + + it('should render empty container', () => { + expect(screen.getByTestId('legend-images')).toBeEmptyDOMElement(); + }); + }); + + describe('when current images are present', () => { + const imagesPartialUrls = ['url1/image.png', 'url2/image.png', 'url3/image.png']; + + beforeEach(() => { + currentLegendImagesSelectorMock.mockImplementation(() => imagesPartialUrls); + renderComponent(); + }); + + it.each(imagesPartialUrls)('should render img element, partialUrl=%s', partialUrl => { + const imgElement = screen.getByAltText(partialUrl); + + expect(imgElement).toBeInTheDocument(); + expect(imgElement.getAttribute('src')).toBe(`${BASE_MAP_IMAGES_URL}/minerva/${partialUrl}`); + }); + }); +}); diff --git a/src/components/Map/Legend/LegendImages/LegendImages.component.tsx b/src/components/Map/Legend/LegendImages/LegendImages.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..04516e4450660af1b005f40926612fb2a7eb108b --- /dev/null +++ b/src/components/Map/Legend/LegendImages/LegendImages.component.tsx @@ -0,0 +1,24 @@ +/* eslint-disable @next/next/no-img-element */ +import { BASE_MAP_IMAGES_URL } from '@/constants'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { currentLegendImagesSelector } from '@/redux/legend/legend.selectors'; + +export const LegendImages: React.FC = () => { + const imageUrls = useAppSelector(currentLegendImagesSelector); + + return ( + <div + data-testid="legend-images" + className="flex items-center justify-between overflow-x-auto border-b border-b-divide px-6 py-8" + > + {imageUrls.map(imageUrl => ( + <img + key={imageUrl} + src={`${BASE_MAP_IMAGES_URL}/minerva/${imageUrl}`} + alt={imageUrl} + className="h-[400px]" + /> + ))} + </div> + ); +}; diff --git a/src/components/Map/Legend/LegendImages/index.ts b/src/components/Map/Legend/LegendImages/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e038a2800e63725935742ed89b1e19571581305d --- /dev/null +++ b/src/components/Map/Legend/LegendImages/index.ts @@ -0,0 +1 @@ +export { LegendImages } from './LegendImages.component'; diff --git a/src/components/Map/Legend/index.ts b/src/components/Map/Legend/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2237b30db05b2ef291e96d91b55de2bf3d712b12 --- /dev/null +++ b/src/components/Map/Legend/index.ts @@ -0,0 +1 @@ +export { Legend } from './Legend.component'; diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 90492655373b3e87dd2a97428207bd356f5c6525..ea5a18e2ef3a95b5001d4873022ee8c220ae829f 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -1,11 +1,16 @@ import { Drawer } from '@/components/Map/Drawer'; +import { Legend } from '@/components/Map/Legend'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { MapViewer } from './MapViewer/MapViewer.component'; export const Map = (): JSX.Element => ( - <div className="relative z-0 h-screen w-full bg-black" data-testid="map-container"> + <div + className="relative z-0 h-screen w-full overflow-hidden bg-black" + data-testid="map-container" + > <MapAdditionalOptions /> <Drawer /> <MapViewer /> + <Legend /> </div> ); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts index 90887721e354c6af5a68d1368cc74ee74d86181f..9f27f8567185a59c2a6bffb5a94a56ca857d8ec4 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 a2a7fb430edee5f165119643802bda01c8477cce..45723ea8732116db8d006f6ad510d4c5f36f5646 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 0000000000000000000000000000000000000000..abb97f156bf3ede2a7ffec1edbff01222478b99f --- /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 0000000000000000000000000000000000000000..bd9977393790b6d31539cb97a4f7b7b017c259a2 --- /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 830dc4984d814080ea1cf5e9e55104a43dad3406..71247c54a4bc0ae7ac0091de51336ac64c5d73cc 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer.ts @@ -1,18 +1,36 @@ -import Geometry from 'ol/geom/Geometry'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import { useMemo } from 'react'; -import { usePointToProjection } from '@/utils/map/usePointToProjection'; 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 { Feature } from 'ol'; +import { Geometry } from 'ol/geom'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { useMemo } from 'react'; import { getOverlayFeatures } from './getOverlayFeatures'; +/** + * 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( () => @@ -21,8 +39,15 @@ export const useOlMapOverlaysLayer = (): VectorLayer<VectorSource<Feature<Geomet 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 8b784426df6c6838d87d60b32cc7921543e8764f..37bb871b4f61222e962bc4295482964514fc689e 100644 --- a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -4,7 +4,8 @@ import { allReactionsSelectorOfCurrentMap } from '@/redux/reactions/reactions.se import { Reaction } from '@/types/models'; import { LinePoint } from '@/types/reactions'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import Geometry from 'ol/geom/Geometry'; +import { Feature } from 'ol'; +import { Geometry } from 'ol/geom'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import Fill from 'ol/style/Fill'; @@ -12,7 +13,6 @@ import Stroke from 'ol/style/Stroke'; import Style from 'ol/style/Style'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { Feature } from 'ol'; import { getLineFeature } from './getLineFeature'; const getReactionsLines = (reactions: Reaction[]): LinePoint[] => diff --git a/src/models/bioEntitySchema.ts b/src/models/bioEntitySchema.ts index 9014bc87adf7f13953309a6bb3e094cc9e39359b..5a66d6127dfd206538d4a971782c5aea5fed4125 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 0000000000000000000000000000000000000000..db44bb5651921d33a73a75029205a2b30f5291eb --- /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 c0a26efd4daccf2dbd062e7b37a67ef6e2d1033a..f8766b2ee6b6d473312dd6361cf95cfc07e5e89e 100644 --- a/src/models/fixtures/overlaysFixture.ts +++ b/src/models/fixtures/overlaysFixture.ts @@ -2,9 +2,30 @@ 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 overlayFixture = createFixture(mapOverlay, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); + +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 0000000000000000000000000000000000000000..92500578ac750b6320df4d8da6a069e8ba0864e4 --- /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 a22b65aa5eed7751bc3033c30998972dadadd9c5..16da571736fd815bb63a9c6bef833c5ab2102c3a 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 0000000000000000000000000000000000000000..8cb37fac64eb8f250d73bb1ccb74a53a433fbd10 --- /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 cc657a713650a32153b3ae4f994a707da2b747b3..433b6c499e2a2cd01c02670873976f46dd40d3db 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 cb3c59beabe94d7b0fd1ef707ccc90c03342cecd..d99fb14c680d6532f321aff23f532eb0a9a77c88 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.constants.ts b/src/redux/configuration/configuration.constants.ts index 765ad32a6b1fb7a9a64649cb41bd2188a951d81e..ebf80efa62cfa09dab7aa8a2b4d94167ab0b16c3 100644 --- a/src/redux/configuration/configuration.constants.ts +++ b/src/redux/configuration/configuration.constants.ts @@ -3,3 +3,10 @@ export const MAX_COLOR_VAL_NAME_ID = 'MAX_COLOR_VAL'; export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL'; export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL'; export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY'; + +export const LEGEND_FILE_NAMES_IDS = [ + 'LEGEND_FILE_1', + 'LEGEND_FILE_2', + 'LEGEND_FILE_3', + 'LEGEND_FILE_4', +]; diff --git a/src/redux/configuration/configuration.mock.ts b/src/redux/configuration/configuration.mock.ts index ce8f052d426a153b14230093988426ea8d3c25f0..d371990411b2f4fc2e88b66f44c411da248d346e 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 01cd1fe5a6a869b6707bb12c6d2f4917a2bc25a1..57e60a05a28eda8721332f80adbf4abeda5c56bd 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 7a694a44779048afc22d05ab9b406f8f970fd153..3fc6d2e7118f93ef7c17d575fbdfb1ec6dd17b1b 100644 --- a/src/redux/configuration/configuration.selectors.ts +++ b/src/redux/configuration/configuration.selectors.ts @@ -1,7 +1,8 @@ import { createSelector } from '@reduxjs/toolkit'; -import { configurationAdapter } from './configuration.adapter'; import { rootSelector } from '../root/root.selectors'; +import { configurationAdapter } from './configuration.adapter'; import { + LEGEND_FILE_NAMES_IDS, MAX_COLOR_VAL_NAME_ID, MIN_COLOR_VAL_NAME_ID, NEUTRAL_COLOR_VAL_NAME_ID, @@ -10,30 +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(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 4bf43488b0dbf265504a24370e7607a90c1f82b5..7758564a93148b091a89035997757284c363e5ca 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 ad3812bbf4d28e9093e64ca80bc92dc43b1be770..012e8b1c5184c9ed39c948d76b4124d29dac57d4 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 0000000000000000000000000000000000000000..086619ae0b02e56f9a8d4a9a21f26beaebfa4545 --- /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 c04c02963c448309b61fa0d854c93875012d8514..f1035f976654ddf96e73684f3e23181e1b2722e4 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 8ecb83281c39220a3909308e0c902e518f68842b..8a60b60fab8b52fdd93845ff79470c2f480d794d 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 060a652a62b8ffb78d487d9cc81d0fc530169368..26e9a90b8f01ba8f2e353c8aa8310ff9917f9fcd 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 769b5cf0a23560b03b607ccfff67beff81e429f4..98e073aea596150c61886bba0af7636abb6af68e 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 44ed65164ace31db7a1ca07561382e8c6d61ffa7..075fcd1961a96c79e242b41a9a81273e4f56f9a5 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 818a545cc6c8ca209660932a423b9196fac90de2..29b32d3aeb1d5f53ff6ee34abd86d325d6a71e8b 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/legend/legend.constants.ts b/src/redux/legend/legend.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..5719c74e0eb641b05f3fdaefc19e1f7ae16bd072 --- /dev/null +++ b/src/redux/legend/legend.constants.ts @@ -0,0 +1,7 @@ +import { LegendState } from './legend.types'; + +export const LEGEND_INITIAL_STATE: LegendState = { + isOpen: false, + pluginLegend: {}, + selectedPluginId: undefined, +}; diff --git a/src/redux/legend/legend.mock.ts b/src/redux/legend/legend.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..6874d3a607591109d29e32f33b7cbe7908c36a90 --- /dev/null +++ b/src/redux/legend/legend.mock.ts @@ -0,0 +1,7 @@ +import { LegendState } from './legend.types'; + +export const LEGEND_INITIAL_STATE_MOCK: LegendState = { + isOpen: false, + pluginLegend: {}, + selectedPluginId: undefined, +}; diff --git a/src/redux/legend/legend.reducers.ts b/src/redux/legend/legend.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..be1f0572cb8a95f18862049d88cc6b2a91132ed0 --- /dev/null +++ b/src/redux/legend/legend.reducers.ts @@ -0,0 +1,21 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { LegendState, PluginId } from './legend.types'; + +export const openLegendReducer = (state: LegendState): void => { + state.isOpen = true; +}; + +export const closeLegendReducer = (state: LegendState): void => { + state.isOpen = false; +}; + +export const selectLegendPluginIdReducer = ( + state: LegendState, + action: PayloadAction<PluginId>, +): void => { + state.selectedPluginId = action.payload; +}; + +export const selectDefaultLegendReducer = (state: LegendState): void => { + state.selectedPluginId = undefined; +}; diff --git a/src/redux/legend/legend.selectors.ts b/src/redux/legend/legend.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..fdf927f1de82b4af6c4b40cd5ea0432d3ec30ec9 --- /dev/null +++ b/src/redux/legend/legend.selectors.ts @@ -0,0 +1,20 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { defaultLegendImagesSelector } from '../configuration/configuration.selectors'; +import { rootSelector } from '../root/root.selectors'; + +export const legendSelector = createSelector(rootSelector, state => state.legend); + +export const isLegendOpenSelector = createSelector(legendSelector, state => state.isOpen); + +// TODO: add filter for active plugins +export const currentLegendImagesSelector = createSelector( + legendSelector, + defaultLegendImagesSelector, + ({ selectedPluginId, pluginLegend }, defaultImages) => { + if (selectedPluginId) { + return pluginLegend?.[selectedPluginId] || []; + } + + return defaultImages; + }, +); diff --git a/src/redux/legend/legend.slice.ts b/src/redux/legend/legend.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..fbb62f007c61d9a67ebda75f3cd563b68045147f --- /dev/null +++ b/src/redux/legend/legend.slice.ts @@ -0,0 +1,16 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { LEGEND_INITIAL_STATE } from './legend.constants'; +import { closeLegendReducer, openLegendReducer } from './legend.reducers'; + +const legendSlice = createSlice({ + name: 'legend', + initialState: LEGEND_INITIAL_STATE, + reducers: { + openLegend: openLegendReducer, + closeLegend: closeLegendReducer, + }, +}); + +export const { openLegend, closeLegend } = legendSlice.actions; + +export default legendSlice.reducer; diff --git a/src/redux/legend/legend.types.ts b/src/redux/legend/legend.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..80314aecede0f4c20d452eccdb9f8b4dbdad56a6 --- /dev/null +++ b/src/redux/legend/legend.types.ts @@ -0,0 +1,8 @@ +export type PluginId = number; +export type ImageUrl = string; + +export type LegendState = { + isOpen: boolean; + pluginLegend: Record<PluginId, ImageUrl[]>; + selectedPluginId: PluginId | undefined; +}; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index 69c6e4667ab81f37bd8d5f55524509a75784f8c2..a6ddf286297afd30e9c97a00edb1b531ad5fccf1 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -5,7 +5,7 @@ export type OverviewImagesModalState = { }; export type MolArtModalState = { - uniprotId: string | undefined; + uniprotId?: string | undefined; }; export interface ModalState { diff --git a/src/redux/overlayBioEntity/overlayBioEntity.reducers.test.ts b/src/redux/overlayBioEntity/overlayBioEntity.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba1740c3587cdb6393760afb66a422bb9d52feab --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.reducers.test.ts @@ -0,0 +1,42 @@ +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { overlayBioEntityFixture } from '@/models/fixtures/overlayBioEntityFixture'; +import overlayBioEntityReducer from './overlayBioEntity.slice'; +import { getOverlayBioEntity } from './overlayBioEntity.thunk'; +import { parseOverlayBioEntityToOlRenderingFormat } from './overlayBioEntity.utils'; +import { OverlaysBioEntityState } from './overlayBioEntity.types'; +import { apiPath } from '../apiPath'; + +const mockedNewAxiosClient = mockNetworkNewAPIResponse(); + +describe('Overlay Bio Entity Reducers', () => { + const OVERLAY_ID = 21; + const MODEL_ID = 27; + + let store = {} as ToolkitStoreWithSingleSlice<OverlaysBioEntityState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('overlayBioEntity', overlayBioEntityReducer); + }); + + describe('getOverlayBioEntityReducer', () => { + it('should update the state correctly when getOverlayBioEntity action is dispatched', async () => { + mockedNewAxiosClient + .onGet(apiPath.getOverlayBioEntity({ overlayId: OVERLAY_ID, modelId: MODEL_ID })) + .reply(HttpStatusCode.Ok, overlayBioEntityFixture); + + const { type } = await store.dispatch( + getOverlayBioEntity({ overlayId: OVERLAY_ID, modelId: MODEL_ID }), + ); + const { data } = store.getState().overlayBioEntity; + + expect(type).toBe('overlayBioEntity/getOverlayBioEntity/fulfilled'); + expect(data[OVERLAY_ID][MODEL_ID]).toEqual( + parseOverlayBioEntityToOlRenderingFormat(overlayBioEntityFixture, OVERLAY_ID), + ); + }); + }); +}); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts b/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts index da76054be13a2b6f4de7576bdf948f59ad3c38b3..797bacd78b59026e59fa45e21d2d551bcad84569 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.reducers.ts @@ -1,14 +1,21 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; import { getOverlayBioEntity, getOverlayBioEntityForAllModels } from './overlayBioEntity.thunk'; -import { OverlaysBioEntityState } from './overlayBioEntity.types'; +import { + OverlaysBioEntityState, + RemoveOverlayBioEntityForGivenOverlayAction, +} from './overlayBioEntity.types'; export const getOverlayBioEntityReducer = ( builder: ActionReducerMapBuilder<OverlaysBioEntityState>, ): void => { builder.addCase(getOverlayBioEntity.fulfilled, (state, action) => { if (action.payload) { - state.overlaysId = [action.meta.arg.overlayId]; - state.data.push(...action.payload); + const { overlayId, modelId } = action.meta.arg; + if (!state.data[action.meta.arg.overlayId]) { + state.data[overlayId] = {}; + } + + state.data[overlayId][modelId] = action.payload; } }); }; @@ -16,7 +23,21 @@ export const getOverlayBioEntityReducer = ( export const getOverlayBioEntityForAllModelsReducer = ( builder: ActionReducerMapBuilder<OverlaysBioEntityState>, ): void => { - builder.addCase(getOverlayBioEntityForAllModels.pending, state => { - state.data = []; + builder.addCase(getOverlayBioEntityForAllModels.pending, (state, action) => { + const { overlayId } = action.meta.arg; + state.overlaysId.push(overlayId); + state.data = { + ...state.data, // this is expection to the rule of immutability from redux-toolkit. state.data[overlayId] = {} would add null values up to overlayId value witch leads to mess in the store + [overlayId]: {}, + }; }); }; + +export const removeOverlayBioEntityForGivenOverlayReducer = ( + state: OverlaysBioEntityState, + action: RemoveOverlayBioEntityForGivenOverlayAction, +): void => { + const { overlayId } = action.payload; + state.overlaysId = state.overlaysId.filter(id => id !== overlayId); + delete state.data[overlayId]; +}; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts index 72c3b359fdbb43ecff2a59617090397b59e90852..0e87f44e0487a3a2050a598f1bb30f942e3dd9ef 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts @@ -1,6 +1,9 @@ +import { OverlayBioEntityRender } from '@/types/OLrendering'; import { createSelector } from '@reduxjs/toolkit'; -import { rootSelector } from '../root/root.selectors'; import { currentModelIdSelector } from '../models/models.selectors'; +import { overlaysDataSelector, overlaysIdsAndOrderSelector } from '../overlays/overlays.selectors'; +import { rootSelector } from '../root/root.selectors'; +import { calculateOvarlaysOrder } from './overlayBioEntity.utils'; export const overlayBioEntitySelector = createSelector( rootSelector, @@ -12,8 +15,48 @@ export const overlayBioEntityDataSelector = createSelector( overlayBioEntity => overlayBioEntity.data, ); +export const activeOverlaysIdSelector = createSelector( + overlayBioEntitySelector, + state => state.overlaysId, +); + export const overlayBioEntitiesForCurrentModelSelector = createSelector( overlayBioEntityDataSelector, + activeOverlaysIdSelector, currentModelIdSelector, - (data, currentModelId) => data.filter(entity => entity.modelId === currentModelId), + (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 activeOverlaysSelector = createSelector( + rootSelector, + overlaysDataSelector, + (state, overlaysData) => + overlaysData.filter(overlay => isOverlayActiveSelector(state, overlay.idObject)), +); + +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.slice.ts b/src/redux/overlayBioEntity/overlayBioEntity.slice.ts index f25d3ed6e81ac34f12cad62d428f694865ff5e46..ae21322ce6147194a9a4c0c7ac056fbad33a972c 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.slice.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.slice.ts @@ -2,6 +2,7 @@ import { createSlice } from '@reduxjs/toolkit'; import { getOverlayBioEntityForAllModelsReducer, getOverlayBioEntityReducer, + removeOverlayBioEntityForGivenOverlayReducer, } from './overlayBioEntity.reducers'; import { OverlaysBioEntityState } from './overlayBioEntity.types'; @@ -13,11 +14,14 @@ const initialState: OverlaysBioEntityState = { export const overlayBioEntitySlice = createSlice({ name: 'overlayBioEntity', initialState, - reducers: {}, + reducers: { + removeOverlayBioEntityForGivenOverlay: removeOverlayBioEntityForGivenOverlayReducer, + }, extraReducers: builder => { getOverlayBioEntityReducer(builder); getOverlayBioEntityForAllModelsReducer(builder); }, }); +export const { removeOverlayBioEntityForGivenOverlay } = overlayBioEntitySlice.actions; export default overlayBioEntitySlice.reducer; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 21aecf106db07d5bc44a4701f544dee1e5d490bf..2ba83189feb03c61dd36bb018a9519b485a81772 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.types.ts b/src/redux/overlayBioEntity/overlayBioEntity.types.ts index 43eeb895696b8af7397be5013249843aa10d649a..4074058bae71559b4c40de5a3adedaa0be381049 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.types.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.types.ts @@ -1,6 +1,15 @@ import { OverlayBioEntityRender } from '@/types/OLrendering'; +import { PayloadAction } from '@reduxjs/toolkit'; export type OverlaysBioEntityState = { overlaysId: number[]; - data: OverlayBioEntityRender[]; + data: { + [overlayId: number]: { + [modelId: number]: OverlayBioEntityRender[]; + }; + }; }; + +export type RemoveOverlayBioEntityForGivenOverlayPayload = { overlayId: number }; +export type RemoveOverlayBioEntityForGivenOverlayAction = + PayloadAction<RemoveOverlayBioEntityForGivenOverlayPayload>; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5714a9a799a8b56f2d6ae54ccac8055261fa613c --- /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 b875e1bba425bc9f7f9a08617d120cdd8ed4a4c8..e632f31a4fd5cb75b966d33868cddf9f0acf1777 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 0000000000000000000000000000000000000000..dda564cda0098e449d5adadb219d86b8320378e4 --- /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 cdb5593cba6857acbbcafbb9d1886f93457e33cf..3e1557ba4e7604dd58aa294eeba2bd13ebced06e 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 d0116134dfce094433f2fd9a9948ee98541cd1eb..2fe92673346f59194fc6f78a5a85443d31d0b273 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 99e493ea4f4f7b6d9288b0c28cfb9234057e9bc9..d8f12eef16e426ef9c7aef040030fd20963a1842 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 f9d36cad0a63c93ae55565e795b897c6bbd4db3e..03ee76e6f318c3e65bba58f0b8902e972eda95a8 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 8d259288d5d8eb69d15d48a8408d4e83d6342573..5f49156af3e1b54b2074c7ae9b653e80f7488027 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 6a01933378559ff68685808752a45c4cc11ce9aa..330e5ee98aba5a26299f21b8d56c9b29e16d2d1e 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 ee00e94527ebcf6d68a453c26e0808f94874b1fa..15d4d813a5879a8e5986e1daf411eceda9f5ef55 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 610a6cce94495eb86214761dc84c5bc185a56609..c5ac340314f157036c59a0cc3cd648ae701582bd 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 4591391b0de75ecfe2cfa99bc66b98deec784987..c4ac19274381cdffa49913eff2a4e40ed79960e7 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 55929fcae655ffdf2eb0e668bf105286f508b39c..98660410716f25c5e3f663f7f22cd7073667ceee 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 e2c17d64bfa7570a884852a4dd96f0ef3a78fe24..aaca59eb7cb7d8ed9178ba958b07ca9cb8b05f9f 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -1,11 +1,12 @@ import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; +import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter'; import { CONTEXT_MENU_INITIAL_STATE } from '../contextMenu/contextMenu.constants'; import { COOKIE_BANNER_INITIAL_STATE_MOCK } from '../cookieBanner/cookieBanner.mock'; -import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter'; import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture'; import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock'; +import { LEGEND_INITIAL_STATE_MOCK } from '../legend/legend.mock'; import { initialMapStateFixture } from '../map/map.fixtures'; import { MODAL_INITIAL_STATE_MOCK } from '../modal/modal.mock'; import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; @@ -14,6 +15,7 @@ import { OVERLAYS_INITIAL_STATE_MOCK } from '../overlays/overlays.mock'; import { PROJECT_STATE_INITIAL_MOCK } from '../project/project.mock'; import { REACTIONS_STATE_INITIAL_MOCK } from '../reactions/reactions.mock'; import { SEARCH_STATE_INITIAL_MOCK } from '../search/search.mock'; +import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; import { RootState } from '../store'; import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; @@ -35,4 +37,6 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { contextMenu: CONTEXT_MENU_INITIAL_STATE, 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 0000000000000000000000000000000000000000..9c753dcd683c0f1522c16c2199d77ba15a42287e --- /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 0000000000000000000000000000000000000000..af16b53be20193600a546122f08bba5e3d6f9b85 --- /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 0000000000000000000000000000000000000000..0829625f8f0028a0e2d2c0d8d113e721f6a13ab6 --- /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 0000000000000000000000000000000000000000..e0bb325940adba73600a3378ffff6d5ae979df8b --- /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 0000000000000000000000000000000000000000..f2cf9f80ee8ba6efd2b49aaa7ae33a48e647d284 --- /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 0000000000000000000000000000000000000000..df5b6589b37a03472c231a24a0ea8fa4b0b7bbd2 --- /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 0000000000000000000000000000000000000000..077d4df17390b947dc99dbd2f4253a947c9c12ed --- /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 c001652684bb1f395000b4724f98168704d5c04e..b79cf2b2c1ef600a4697cf2027c8ba2348a00477 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,20 +1,20 @@ import backgroundsReducer from '@/redux/backgrounds/backgrounds.slice'; import bioEntityReducer from '@/redux/bioEntity/bioEntity.slice'; import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; +import configurationReducer from '@/redux/configuration/configuration.slice'; +import contextMenuReducer from '@/redux/contextMenu/contextMenu.slice'; +import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; import drugsReducer from '@/redux/drugs/drugs.slice'; import mapReducer from '@/redux/map/map.slice'; import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; +import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; -import contextMenuReducer from '@/redux/contextMenu/contextMenu.slice'; import searchReducer from '@/redux/search/search.slice'; -import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice'; import userReducer from '@/redux/user/user.slice'; -import configurationReducer from '@/redux/configuration/configuration.slice'; -import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import { AnyAction, ListenerEffectAPI, @@ -22,7 +22,9 @@ import { TypedStartListening, configureStore, } 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, @@ -42,6 +44,8 @@ export const reducers = { user: userReducer, 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 0000000000000000000000000000000000000000..a26f46fcb939eb0c8e25756218715db9b95d4af8 --- /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 0000000000000000000000000000000000000000..68cf9097dbb05b652a539d52cb0ccdefdc9901d1 --- /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 0000000000000000000000000000000000000000..dfcccdc632e6f3970118276e0d157d0b4cef2d7b --- /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 0000000000000000000000000000000000000000..e337f637ad4575a1b3d334b1d7f13d7abfaa3759 --- /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 0000000000000000000000000000000000000000..b300f21a1f13b750181fa14b32def0fa24802853 --- /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 0000000000000000000000000000000000000000..11d2fe00cef1e12fbe84e4fe247fc6cf436b4fcd --- /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 3a651659743e46e286dda16845ea676fcfa4e314..11ab030c2ca0dd67874f94da6b29309e81f52e1c 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 a1e01c3dae9d2478907f9de7d29fd4fec251f9d0..8f4582e557ce84606857b048b60bb9d7518ca18a 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 a6696aa9ab6676ade0473673db26b60f8d2e9004..98309123aeea5a80626fca86870beb56c6561ec3 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 0000000000000000000000000000000000000000..68fa6ecf4bf54cab52b4ad816f7effa2007d65e3 --- /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 0000000000000000000000000000000000000000..0a3aa52d831800ce44c6efd5746f05b96dfdcca6 --- /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 83d991077b5544c69fac4e36f0f423a6129e0a41..113111d7d64cb750ec6724bbaa1ff477718bc97b 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 9dc659c0eaada11d7dae247f577a4a7df87f6d00..ee7440375a4834e13cf217b9af5fd714889ec56b 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 dc4bc4b6595a1966de2cabb7c3a54143e033ef42..77244318601a6d608b143772ca813e7e390e4eaf 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({