diff --git a/package.json b/package.json index 8a754dc0eed77ccbde955e6e3a335e05258a463e..dc91a99f08a1b59060495dda7f7fe532ec30109e 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,9 @@ "check-types": "tsc --pretty --noEmit", "prepare": "husky install", "postinstall": "husky install", - "test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!@toolz/allow-react)/'", - "test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'", + "test": "jest --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", "test:ci": "jest --config ./jest.config.ts --collectCoverage --coverageDirectory=\"./coverage\" --ci --reporters=default --reporters=jest-junit --watchAll=false --passWithNoTests --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", + "test:watch": "jest --watch --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|color-space|color-rgba|color-parse|.*\\.mjs$))'", "test:coverage": "jest --watchAll --coverage --config ./jest.config.ts --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'", "test:coveragee": "jest --coverage --transformIgnorePatterns 'node_modules/(?!(ol|geotiff|quick-lru|.*\\.mjs$))'", "coverage": "open ./coverage/lcov-report/index.html", diff --git a/src/assets/vectors/icons/spinner.svg b/src/assets/vectors/icons/spinner.svg new file mode 100644 index 0000000000000000000000000000000000000000..c977723f9a19b709e7a8c4899d24243ddfb91cf0 --- /dev/null +++ b/src/assets/vectors/icons/spinner.svg @@ -0,0 +1,3 @@ +<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M10.2427 1.75736L9.2998 2.70017C8.45533 1.85567 7.28867 1.33333 6 1.33333C3.42267 1.33333 1.33333 3.42267 1.33333 6C1.33333 8.57733 3.42267 10.6667 6 10.6667C8.57733 10.6667 10.6667 8.57733 10.6667 6H12C12 9.31373 9.31373 12 6 12C2.68629 12 0 9.31373 0 6C0 2.68629 2.68629 0 6 0C7.65687 0 9.15687 0.671574 10.2427 1.75736Z" fill="black"/> +</svg> diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 2bb7e192c61b37fef97dafaf58cd0bfc1d94395d..3b59e82bec77dc93528d14168014000abd095242 100644 --- a/src/components/AppWrapper/AppWrapper.component.tsx +++ b/src/components/AppWrapper/AppWrapper.component.tsx @@ -1,11 +1,14 @@ +import { store } from '@/redux/store'; +import { MapInstanceProvider } from '@/utils/context/mapInstanceContext'; import { ReactNode } from 'react'; import { Provider } from 'react-redux'; -import { store } from '@/redux/store'; interface AppWrapperProps { children: ReactNode; } export const AppWrapper = ({ children }: AppWrapperProps): JSX.Element => ( - <Provider store={store}>{children}</Provider> + <MapInstanceProvider> + <Provider store={store}>{children}</Provider> + </MapInstanceProvider> ); diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index 29ce0fb0c6e9bf9d6c4f15fbc75fa6ef13ecf1e6..fbc5c000ee41e1dd810219d5d9cf181263fc02a7 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -3,6 +3,7 @@ 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 { openLoginModal } from '@/redux/modal/modal.slice'; import { IconButton } from '@/shared/IconButton'; import Image from 'next/image'; @@ -25,6 +26,10 @@ export const NavBar = (): JSX.Element => { dispatch(openLegend()); }; + const openModalLogin = (): void => { + dispatch(openLoginModal()); + }; + return ( <div className="flex min-h-full w-[88px] flex-col items-center justify-between bg-cultured py-8"> <div data-testid="nav-buttons"> @@ -35,7 +40,7 @@ export const NavBar = (): JSX.Element => { <IconButton icon="export" onClick={openDrawerExport} /> </div> <div className="flex flex-col gap-[10px]"> - <IconButton icon="admin" /> + <IconButton icon="admin" onClick={openModalLogin} /> <IconButton icon="legend" onClick={openDrawerLegend} /> </div> </div> diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx deleted file mode 100644 index a68fd3894887e2c5fa30733e4388d8f8f76927eb..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/* 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/CheckboxFilter/CheckboxFilter.component.tsx b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx index 68dbe9c6ecf1ea715852925c85e2f2d18b3d4ad2..54ac8df4fc0f8634d21a474a3d4cfca07b239434 100644 --- a/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/CheckboxFilter/CheckboxFilter.component.tsx @@ -4,7 +4,7 @@ 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 }; +export type CheckboxItem = { id: string; label: string }; type CheckboxFilterProps = { options: CheckboxItem[]; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts deleted file mode 100644 index 3b82aaf76f1b363c9cc2429bbe05d17938bdac29..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Annotations } from './Annotations.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx deleted file mode 100644 index c6d8084fe24299b8f13a6fc07dc1aa6019188646..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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/Elements.component.tsx b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx index c4d5d6f495b4bd37ea3286f85f600b56e173a380..fcf01b6a0c8fd6cae6b847e245f70e3d072508e7 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.component.tsx @@ -1,13 +1,16 @@ -import { Types } from './Types'; -import { Annotations } from '../Annotations'; -import { Columns } from './Columns'; +import { Export } from '../ExportCompound'; export const Elements = (): React.ReactNode => { return ( <div data-testid="elements-tab"> - <Types /> - <Columns /> - <Annotations /> + <Export> + <Export.Types /> + <Export.Columns /> + <Export.Annotations /> + <Export.IncludedCompartmentPathways /> + <Export.ExcludedCompartmentPathways /> + <Export.DownloadElements /> + </Export> </div> ); }; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts b/src/components/Map/Drawer/ExportDrawer/Elements/Elements.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.test.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.test.tsx diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx similarity index 73% rename from src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx index f3795e9b9f5c9828957e6691c5427fd42deacd2d..6f7034f871d9034f2a038493d426d6784d552b02 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Annotations/Annotations.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/Annotations.component.tsx @@ -1,13 +1,16 @@ -/* eslint-disable no-magic-numbers */ +import { useContext } from 'react'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { elementAnnotationsSelector, loadingStatisticsSelector, } from '@/redux/statistics/statistics.selectors'; +import { ZERO } from '@/constants/common'; import { CheckboxFilter } from '../../CheckboxFilter'; import { CollapsibleSection } from '../../CollapsibleSection'; +import { ExportContext } from '../ExportCompound.context'; export const Annotations = (): React.ReactNode => { + const { setAnnotations } = useContext(ExportContext); const loadingStatistics = useAppSelector(loadingStatisticsSelector); const elementAnnotations = useAppSelector(elementAnnotationsSelector); const isPending = loadingStatistics === 'pending'; @@ -19,8 +22,8 @@ export const Annotations = (): React.ReactNode => { return ( <CollapsibleSection title="Select annotations"> {isPending && <p>Loading...</p>} - {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > 0 && ( - <CheckboxFilter options={mappedElementAnnotations} /> + {!isPending && mappedElementAnnotations && mappedElementAnnotations.length > ZERO && ( + <CheckboxFilter options={mappedElementAnnotations} onCheckedChange={setAnnotations} /> )} </CollapsibleSection> ); diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/index.ts similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Annotations/index.ts rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Annotations/index.ts diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.test.tsx similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.component.test.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.test.tsx diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..954a4c60a4354f675d4c7bab265f45d69c384039 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.component.tsx @@ -0,0 +1,15 @@ +import { useContext } from 'react'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { COLUMNS } from './Columns.constants'; +import { ExportContext } from '../ExportCompound.context'; + +export const Columns = (): React.ReactNode => { + const { setColumns } = useContext(ExportContext); + + return ( + <CollapsibleSection title="Select column"> + <CheckboxFilter options={COLUMNS} isSearchEnabled={false} onCheckedChange={setColumns} /> + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.constants.tsx similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Columns/Columns.constants.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/Columns.constants.tsx diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/index.ts similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Columns/index.ts rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Columns/index.ts diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadElements/DownloadElements.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadElements/DownloadElements.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53276caa74a5bd4b0851f3b189df50e79a2a53f3 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadElements/DownloadElements.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { Button } from '@/shared/Button'; +import { ExportContext } from '../ExportCompound.context'; + +export const DownloadElements = (): React.ReactNode => { + const { handleDownloadElements } = useContext(ExportContext); + + return ( + <div className="mt-6"> + <Button onClick={handleDownloadElements}>Download</Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadElements/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadElements/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fbe769f0877561ab755049749e12577e13c1b005 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/DownloadNetwork.tsx @@ -0,0 +1,13 @@ +import { useContext } from 'react'; +import { Button } from '@/shared/Button'; +import { ExportContext } from '../ExportCompound.context'; + +export const DownloadElements = (): React.ReactNode => { + const { handleDownloadNetwork } = useContext(ExportContext); + + return ( + <div className="mt-6"> + <Button onClick={handleDownloadNetwork}>Download</Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/DownloadNetwork/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx similarity index 58% rename from src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx index df05c8e0c5e7fcef43da5d5085d34a3c6ea14f43..ded8e6806f29182fd4ed22c6b8d8e0dc1cfa506d 100644 --- a/src/components/Map/Drawer/ExportDrawer/Annotations/Annotations.component.test.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.test.tsx @@ -1,12 +1,13 @@ +/* eslint-disable no-magic-numbers */ 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'; +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways.component'; const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); @@ -14,7 +15,7 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St return ( render( <Wrapper> - <Annotations /> + <ExcludedCompartmentPathways /> </Wrapper>, ), { @@ -23,17 +24,13 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St ); }; -describe('Annotations - component', () => { - it('should display annotations checkboxes when fetching data is successful', async () => { +const CHECKBOX_ELEMENT_NAME = compartmentPathwaysDetailsFixture[0].name; + +describe('ExcludedCompartmentPathways - component', () => { + it('should display compartment / pathways checkboxes when fetching data is successful', async () => { renderComponent({ - statistics: { - data: { - ...statisticsFixture, - elementAnnotations: { - compartment: 1, - pathway: 0, - }, - }, + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, loading: 'succeeded', error: { message: '', @@ -41,24 +38,27 @@ describe('Annotations - component', () => { }, }, }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeVisible(); + const navigationButton = screen.getByTestId('accordion-item-button'); act(() => { navigationButton.click(); }); - expect(screen.getByText('Select annotations')).toBeInTheDocument(); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); await waitFor(() => { expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); - expect(screen.getByLabelText('compartment')).toBeInTheDocument(); expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + expect(screen.getByLabelText(CHECKBOX_ELEMENT_NAME)).toBeInTheDocument(); }); }); - it('should not display annotations checkboxes when fetching data fails', async () => { + it('should not display compartment / pathways checkboxes when fetching data fails', async () => { renderComponent({ - statistics: { - data: undefined, + compartmentPathways: { + data: [], loading: 'failed', error: { message: '', @@ -66,7 +66,7 @@ describe('Annotations - component', () => { }, }, }); - expect(screen.getByText('Select annotations')).toBeInTheDocument(); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); const navigationButton = screen.getByTestId('accordion-item-button'); act(() => { navigationButton.click(); @@ -74,21 +74,18 @@ describe('Annotations - component', () => { expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); }); - it('should not display annotations checkboxes when fetched data is empty object', async () => { + it('should not display compartment / pathways checkboxes when fetched data is empty', async () => { renderComponent({ - statistics: { - data: { - ...statisticsFixture, - elementAnnotations: {}, - }, - loading: 'failed', + compartmentPathways: { + data: [], + loading: 'succeeded', error: { message: '', name: '', }, }, }); - expect(screen.getByText('Select annotations')).toBeInTheDocument(); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); const navigationButton = screen.getByTestId('accordion-item-button'); act(() => { navigationButton.click(); @@ -99,8 +96,8 @@ describe('Annotations - component', () => { it('should display loading message when fetching data is pending', async () => { renderComponent({ - statistics: { - data: undefined, + compartmentPathways: { + data: [], loading: 'pending', error: { message: '', @@ -108,7 +105,7 @@ describe('Annotations - component', () => { }, }, }); - expect(screen.getByText('Select annotations')).toBeInTheDocument(); + expect(screen.getByText('Select excluded compartment / pathways')).toBeInTheDocument(); const navigationButton = screen.getByTestId('accordion-item-button'); act(() => { navigationButton.click(); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a1de0816bef5e657fee1ee41ca4ed3f0937d0160 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/ExcludedCompartmentPathways.component.tsx @@ -0,0 +1,32 @@ +import { useContext } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + compartmentPathwaysDataSelector, + loadingCompartmentPathwaysSelector, +} from '@/redux/compartmentPathways/compartmentPathways.selectors'; +import { ZERO } from '@/constants/common'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { ExportContext } from '../ExportCompound.context'; +import { getCompartmentPathwaysCheckboxElements } from '../utils/getCompartmentPathwaysCheckboxElements'; + +export const ExcludedCompartmentPathways = (): React.ReactNode => { + const { setExcludedCompartmentPathways } = useContext(ExportContext); + const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); + const isPending = loadingCompartmentPathways === 'pending'; + const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); + const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + const isCheckboxFilterVisible = !isPending && checkboxElements && checkboxElements.length > ZERO; + + return ( + <CollapsibleSection title="Select excluded compartment / pathways"> + {isPending && <p>Loading...</p>} + {isCheckboxFilterVisible && ( + <CheckboxFilter + options={checkboxElements} + onCheckedChange={setExcludedCompartmentPathways} + /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1e759c910b04996c4dc806bf6bd449c1a3c4964 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExcludedCompartmentPathways/index.ts @@ -0,0 +1 @@ +export { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..15f4767e22864d74979a577f62314f5af2d828de --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.component.tsx @@ -0,0 +1,74 @@ +import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modelsIdsSelector } from '@/redux/models/models.selectors'; +import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; +import { Types } from './Types'; +import { Columns } from './Columns'; +import { Annotations } from './Annotations'; +import { ExcludedCompartmentPathways } from './ExcludedCompartmentPathways'; +import { IncludedCompartmentPathways } from './IncludedCompartmentPathways '; +import { DownloadElements } from './DownloadElements/DownloadElements'; +import { ExportContext } from './ExportCompound.context'; +import { getNetworkDownloadBodyRequest } from './utils/getNetworkBodyRequest'; +import { getDownloadElementsBodyRequest } from './utils/getDownloadElementsBodyRequest'; + +type ExportProps = { + children: ReactNode; +}; + +export const Export = ({ children }: ExportProps): JSX.Element => { + const [types, setTypes] = useState<CheckboxItem[]>([]); + const [columns, setColumns] = useState<CheckboxItem[]>([]); + const [annotations, setAnnotations] = useState<CheckboxItem[]>([]); + const modelIds = useAppSelector(modelsIdsSelector); + const [includedCompartmentPathways, setIncludedCompartmentPathways] = useState<CheckboxItem[]>( + [], + ); + const [excludedCompartmentPathways, setExcludedCompartmentPathways] = useState<CheckboxItem[]>( + [], + ); + + const handleDownloadElements = useCallback(() => { + getDownloadElementsBodyRequest({ + types, + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + }, [ + types, + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + ]); + + const handleDownloadNetwork = useCallback(() => { + getNetworkDownloadBodyRequest(); + }, []); + + const globalContextValue = useMemo( + () => ({ + setTypes, + setColumns, + setAnnotations, + setIncludedCompartmentPathways, + setExcludedCompartmentPathways, + handleDownloadElements, + handleDownloadNetwork, + }), + [handleDownloadElements, handleDownloadNetwork], + ); + + return <ExportContext.Provider value={globalContextValue}>{children}</ExportContext.Provider>; +}; + +Export.Types = Types; +Export.Columns = Columns; +Export.Annotations = Annotations; +Export.IncludedCompartmentPathways = IncludedCompartmentPathways; +Export.ExcludedCompartmentPathways = ExcludedCompartmentPathways; +Export.DownloadElements = DownloadElements; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts new file mode 100644 index 0000000000000000000000000000000000000000..3490162eb61f13c9ddbf4f2a13d732693e98d003 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/ExportCompound.context.ts @@ -0,0 +1,22 @@ +import { createContext } from 'react'; +import { CheckboxItem } from '../CheckboxFilter/CheckboxFilter.component'; + +export type ExportContextType = { + setTypes: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setColumns: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setAnnotations: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setIncludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + setExcludedCompartmentPathways: React.Dispatch<React.SetStateAction<CheckboxItem[]>>; + handleDownloadElements: () => void; + handleDownloadNetwork: () => void; +}; + +export const ExportContext = createContext<ExportContextType>({ + setTypes: () => {}, + setColumns: () => {}, + setAnnotations: () => {}, + setIncludedCompartmentPathways: () => {}, + setExcludedCompartmentPathways: () => {}, + handleDownloadElements: () => {}, + handleDownloadNetwork: () => {}, +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..59a59c9957d597e9f46b4b38704f736e386cc477 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.test.tsx @@ -0,0 +1,116 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, waitFor } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { act } from 'react-dom/test-utils'; +import { compartmentPathwaysDetailsFixture } from '@/models/fixtures/compartmentPathways'; +import { IncludedCompartmentPathways } from './IncludedCompartmentPathways.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <IncludedCompartmentPathways /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const CHECKBOX_ELEMENT_NAME = compartmentPathwaysDetailsFixture[0].name; + +describe('IncludedCompartmentPathways - component', () => { + it('should display compartment / pathways checkboxes when fetching data is successful', async () => { + renderComponent({ + compartmentPathways: { + data: compartmentPathwaysDetailsFixture, + 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 included compartment / pathways')).toBeInTheDocument(); + + await waitFor(() => { + expect(screen.getByTestId('checkbox-filter')).toBeInTheDocument(); + expect(screen.getByLabelText('search-input')).toBeInTheDocument(); + expect(screen.getByLabelText(CHECKBOX_ELEMENT_NAME)).toBeInTheDocument(); + }); + }); + it('should not display compartment / pathways checkboxes when fetching data fails', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'failed', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.queryByTestId('checkbox-filter')).not.toBeInTheDocument(); + }); + it('should not display compartment / pathways checkboxes when fetched data is empty', async () => { + renderComponent({ + compartmentPathways: { + data: [], + loading: 'succeeded', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).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({ + compartmentPathways: { + data: [], + loading: 'pending', + error: { + message: '', + name: '', + }, + }, + }); + expect(screen.getByText('Select included compartment / pathways')).toBeInTheDocument(); + const navigationButton = screen.getByTestId('accordion-item-button'); + act(() => { + navigationButton.click(); + }); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..39164e58904ff7d2667b9573c82ad1d5a38e58f1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /IncludedCompartmentPathways.component.tsx @@ -0,0 +1,31 @@ +import { useContext } from 'react'; +import { + compartmentPathwaysDataSelector, + loadingCompartmentPathwaysSelector, +} from '@/redux/compartmentPathways/compartmentPathways.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { ZERO } from '@/constants/common'; +import { CheckboxFilter } from '../../CheckboxFilter'; +import { CollapsibleSection } from '../../CollapsibleSection'; +import { ExportContext } from '../ExportCompound.context'; +import { getCompartmentPathwaysCheckboxElements } from '../utils/getCompartmentPathwaysCheckboxElements'; + +export const IncludedCompartmentPathways = (): React.ReactNode => { + const { setIncludedCompartmentPathways } = useContext(ExportContext); + const loadingCompartmentPathways = useAppSelector(loadingCompartmentPathwaysSelector); + const isPending = loadingCompartmentPathways === 'pending'; + const compartmentPathways = useAppSelector(compartmentPathwaysDataSelector); + const checkboxElements = getCompartmentPathwaysCheckboxElements(compartmentPathways); + + return ( + <CollapsibleSection title="Select included compartment / pathways"> + {isPending && <p>Loading...</p>} + {!isPending && checkboxElements && checkboxElements.length > ZERO && ( + <CheckboxFilter + options={checkboxElements} + onCheckedChange={setIncludedCompartmentPathways} + /> + )} + </CollapsibleSection> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /index.ts new file mode 100644 index 0000000000000000000000000000000000000000..56b78aea9ea41f70ccd90ea31ba02d22002de716 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/IncludedCompartmentPathways /index.ts @@ -0,0 +1 @@ +export { IncludedCompartmentPathways } from './IncludedCompartmentPathways.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.test.tsx similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.test.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.test.tsx diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx similarity index 64% rename from src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx index 0c37bdf6907d676eeaed5182c5002710327ff13c..9398790028d9a1e60cbaeee9bf85014523839570 100644 --- a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.component.tsx @@ -1,16 +1,25 @@ +import { useContext } from 'react'; 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'; +import { ExportContext } from '../ExportCompound.context'; export const Types = (): React.ReactNode => { + const { setTypes } = useContext(ExportContext); const elementTypes = useAppSelector(elementTypesSelector); const checkboxElements = getCheckboxElements(elementTypes); return ( <CollapsibleSection title="Select types"> - {checkboxElements && <CheckboxFilter options={checkboxElements} isSearchEnabled={false} />} + {checkboxElements && ( + <CheckboxFilter + options={checkboxElements} + isSearchEnabled={false} + onCheckedChange={setTypes} + /> + )} </CollapsibleSection> ); }; diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.test.ts similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.test.ts rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.test.ts diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.ts similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Types/Types.utils.ts rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/Types.utils.ts diff --git a/src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/index.ts similarity index 100% rename from src/components/Map/Drawer/ExportDrawer/Elements/Types/index.ts rename to src/components/Map/Drawer/ExportDrawer/ExportCompound/Types/index.ts diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/index.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc9ebec49c0de9036d71209be308feaecd41229c --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/index.ts @@ -0,0 +1 @@ +export { Export } from './ExportCompound.component'; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0a7806ce4d62ea5210b1249087b97345affa819 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.test.ts @@ -0,0 +1,46 @@ +/* eslint-disable no-magic-numbers */ +import { CompartmentPathwayDetails } from '@/types/models'; +import { getCompartmentPathwaysCheckboxElements } from './getCompartmentPathwaysCheckboxElements'; + +describe('getCompartmentPathwaysCheckboxElements', () => { + it('should return an empty array when given an empty items array', () => { + const items: CompartmentPathwayDetails[] = []; + const result = getCompartmentPathwaysCheckboxElements(items); + expect(result).toEqual([]); + }); + + it('should correctly extract unique names and corresponding ids from items', () => { + const items = [ + { id: 1, name: 'Compartment A' }, + { id: 2, name: 'Compartment B' }, + { id: 3, name: 'Compartment A' }, + { id: 4, name: 'Compartment C' }, + ] as CompartmentPathwayDetails[]; + + const result = getCompartmentPathwaysCheckboxElements(items); + + expect(result).toEqual([ + { id: '1', label: 'Compartment A' }, + { id: '2', label: 'Compartment B' }, + { id: '4', label: 'Compartment C' }, + ]); + }); + it('should correctly extract unique names and corresponding ids from items and sorts them alphabetically', () => { + const items = [ + { id: 1, name: 'Compartment C' }, + { id: 2, name: 'Compartment A' }, + { id: 3, name: 'Compartment B' }, + { id: 4, name: 'Compartment A' }, + { id: 5, name: 'Compartment D' }, + ] as CompartmentPathwayDetails[]; + + const result = getCompartmentPathwaysCheckboxElements(items); + + expect(result).toEqual([ + { id: '2', label: 'Compartment A' }, + { id: '3', label: 'Compartment B' }, + { id: '1', label: 'Compartment C' }, + { id: '5', label: 'Compartment D' }, + ]); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0f4bf81a14c4fece41eff986e4b3685b2506f16 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getCompartmentPathwaysCheckboxElements.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-magic-numbers */ +import { CompartmentPathwayDetails } from '@/types/models'; + +type AddedNames = { [key: string]: number }; + +type CheckboxElement = { id: string; label: string }; + +type CheckboxElements = CheckboxElement[]; + +export const getCompartmentPathwaysCheckboxElements = ( + items: CompartmentPathwayDetails[], +): CheckboxElements => { + const addedNames: AddedNames = {}; + + const setNameToIdIfUndefined = (item: CompartmentPathwayDetails): void => { + if (addedNames[item.name] === undefined) { + addedNames[item.name] = item.id; + } + }; + + items.forEach(setNameToIdIfUndefined); + + const parseIdAndLabel = ([name, id]: [name: string, id: number]): CheckboxElement => ({ + id: id.toString(), + label: name, + }); + + const sortByLabel = (a: CheckboxElement, b: CheckboxElement): number => { + if (a.label > b.label) return 1; + return -1; + }; + + const elements = Object.entries(addedNames).map(parseIdAndLabel).sort(sortByLabel); + + return elements; +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..68d1e8f0ae0858439f4ed21fc681eef713d921b1 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.test.ts @@ -0,0 +1,47 @@ +import { getDownloadElementsBodyRequest } from './getDownloadElementsBodyRequest'; + +describe('getDownloadElementsBodyRequest', () => { + it('should return the correct DownloadBodyRequest object', () => { + const types = [ + { id: '1', label: 'Type 1' }, + { id: '2', label: 'Type 2' }, + ]; + const columns = [ + { id: '1', label: 'Column 1' }, + { id: '2', label: 'Column 2' }, + ]; + // eslint-disable-next-line no-magic-numbers + const modelIds = [1, 2, 3]; + const annotations = [ + { id: '1', label: 'Annotation 1' }, + { id: '2', label: 'Annotation 2' }, + ]; + const includedCompartmentPathways = [ + { id: '1', label: 'Compartment 1' }, + { id: '2', label: 'Compartment 2' }, + ]; + const excludedCompartmentPathways = [ + { id: '1', label: 'Compartment 3' }, + { id: '2', label: 'Compartment 4' }, + ]; + + const result = getDownloadElementsBodyRequest({ + types, + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, + }); + + expect(result).toEqual({ + types: ['Type 1', 'Type 2'], + columns: ['Column 1', 'Column 2'], + // eslint-disable-next-line no-magic-numbers + submaps: [1, 2, 3], + annotations: ['Annotation 1', 'Annotation 2'], + includedCompartmentIds: ['Compartment 1', 'Compartment 2'], + excludedCompartmentIds: ['Compartment 3', 'Compartment 4'], + }); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a262a95703df1efc1e0baba873acf11cc50e13a --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getDownloadElementsBodyRequest.ts @@ -0,0 +1,35 @@ +import { CheckboxItem } from '../../CheckboxFilter/CheckboxFilter.component'; + +type DownloadBodyRequest = { + types: string[]; + columns: string[]; + submaps: number[]; + annotations: string[]; + includedCompartmentIds: string[]; + excludedCompartmentIds: string[]; +}; + +type GetDownloadBodyRequestProps = { + types: CheckboxItem[]; + columns: CheckboxItem[]; + modelIds: number[]; + annotations: CheckboxItem[]; + includedCompartmentPathways: CheckboxItem[]; + excludedCompartmentPathways: CheckboxItem[]; +}; + +export const getDownloadElementsBodyRequest = ({ + types, + columns, + modelIds, + annotations, + includedCompartmentPathways, + excludedCompartmentPathways, +}: GetDownloadBodyRequestProps): DownloadBodyRequest => ({ + types: types.map(type => type.label), + columns: columns.map(column => column.label), + submaps: modelIds, + annotations: annotations.map(annotation => annotation.label), + includedCompartmentIds: includedCompartmentPathways.map(compartment => compartment.label), + excludedCompartmentIds: excludedCompartmentPathways.map(compartment => compartment.label), +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1aa3d73b227fb3f5ba7ce6a3fd69e70b161ac58e --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.test.ts @@ -0,0 +1,8 @@ +import { getNetworkDownloadBodyRequest } from './getNetworkBodyRequest'; + +describe('getNetworkDownloadBodyRequest', () => { + it('should return an empty object', () => { + const result = getNetworkDownloadBodyRequest(); + expect(result).toEqual({}); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts new file mode 100644 index 0000000000000000000000000000000000000000..6613aea72d35cc71d858350896d3d8ea79121e73 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportCompound/utils/getNetworkBodyRequest.ts @@ -0,0 +1 @@ +export const getNetworkDownloadBodyRequest = (): object => ({}); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx index 068348d1f068bbe22bd14e16b430a1948fee3453..1d98f663a79aae7c2dbe4b2e954c58c32536794f 100644 --- a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.tsx @@ -1,23 +1,36 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modelsIdsSelector } from '@/redux/models/models.selectors'; import { DrawerHeading } from '@/shared/DrawerHeading'; -import { useState } from 'react'; +import { getCompartmentPathways } from '@/redux/compartmentPathways/compartmentPathways.thunks'; +import { useEffect, useState } from 'react'; import { TabNavigator } from './TabNavigator'; import { Elements } from './Elements'; import { TAB_NAMES } from './TabNavigator/TabNavigator.constants'; import { TabNames } from './TabNavigator/TabNavigator.types'; +import { Network } from './Network'; export const ExportDrawer = (): React.ReactNode => { + const modelsIds = useAppSelector(modelsIdsSelector); + const dispatch = useAppDispatch(); const [activeTab, setActiveTab] = useState<TabNames>(TAB_NAMES.ELEMENTS); const handleTabChange = (tabName: TabNames): void => { setActiveTab(tabName); }; + useEffect(() => { + dispatch(getCompartmentPathways(modelsIds)); + }, [dispatch, modelsIds]); + 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 />} + {activeTab === TAB_NAMES.NETWORK && <Network />} + {activeTab === TAB_NAMES.GRAPHICS && <div>Graphics</div>} </div> </div> ); diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils.tsx b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..20e5e8711bc36f2a2c962bf1a599296c59e6f408 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils.tsx @@ -0,0 +1,7 @@ +import { MapModel } from '@/types/models'; + +export const getModelsIds = (models: MapModel[] | undefined): number[] => { + if (!models) return []; + + return models.map(model => model.idObject); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/ExportDrawer.utils.test.ts b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee1aa52f9e3194b17cbdfddc9b963357211afc37 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/ExportDrawer.utils.test.ts @@ -0,0 +1,16 @@ +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { getModelsIds } from './ExportDrawer.component.utils'; + +const MODELS_IDS = modelsFixture.map(item => item.idObject); + +describe('getModelsIds', () => { + it('should return an empty array if models are not provided', () => { + const result = getModelsIds(undefined); + expect(result).toEqual([]); + }); + + it('should return an array of model IDs', () => { + const result = getModelsIds(modelsFixture); + expect(result).toEqual(MODELS_IDS); + }); +}); diff --git a/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1438a0851f3581298f226532ff874bf44821a1b6 --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Network/Network.component.tsx @@ -0,0 +1,16 @@ +import { Export } from '../ExportCompound'; + +export const Network = (): React.ReactNode => { + return ( + <div data-testid="export-tab"> + <Export> + <Export.Types /> + <Export.Columns /> + <Export.Annotations /> + <Export.IncludedCompartmentPathways /> + <Export.ExcludedCompartmentPathways /> + <Export.DownloadElements /> + </Export> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ExportDrawer/Network/index.ts b/src/components/Map/Drawer/ExportDrawer/Network/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d64c4bfc39b924fccc516b5138431f7034a53ba --- /dev/null +++ b/src/components/Map/Drawer/ExportDrawer/Network/index.ts @@ -0,0 +1 @@ +export { Network } from './Network.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 1c2e5658c78a16867bf6ec52ebbad5634fa27128..37ede68975b5ade8a9631fc4d13d80d1ecfd4d2c 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 @@ -95,6 +95,20 @@ describe('OverlayListItem - component', () => { expect(store.getState().overlayBioEntity.data).toEqual([]); expect(store.getState().overlayBioEntity.overlaysId).toEqual([]); }); + it('should display spinner icon if overlay is loading', () => { + const OVERLAY_ID = 21; + renderComponent({ + overlayBioEntity: { + data: { + [OVERLAY_ID]: {}, + }, + overlaysId: [OVERLAY_ID], + }, + }); + + expect(screen.getByAltText('spinner icon')).toBeVisible(); + expect(screen.getByText('Disable')).toBeVisible(); + }); }); // TODO implement when connecting logic to component 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 ab40e7cfecb3b1e240e269cb7fa79148f76fbef0..21eefae1d72c6821d494efd7ed29c8b34553bee9 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/OverlayListItem.component.tsx @@ -1,4 +1,6 @@ import { Button } from '@/shared/Button'; +import Image from 'next/image'; +import spinnerIcon from '@/assets/vectors/icons/spinner.svg'; import { useOverlay } from './hooks/useOverlay'; interface OverlayListItemProps { @@ -8,14 +10,27 @@ interface OverlayListItemProps { export const OverlayListItem = ({ name, overlayId }: OverlayListItemProps): JSX.Element => { const onDownloadOverlay = (): void => {}; - const { toggleOverlay, isOverlayActive } = useOverlay(overlayId); + const { toggleOverlay, isOverlayActive, isOverlayLoading } = 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={toggleOverlay}> - {isOverlayActive ? 'Disable' : 'View'} + <Button + variantStyles="ghost" + className="mr-4 max-h-8 flex-none gap-1.5" + onClick={toggleOverlay} + > + {isOverlayLoading && ( + <Image + src={spinnerIcon} + alt="spinner icon" + height={12} + width={12} + className="animate-spin" + /> + )} + {isOverlayActive || 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 index 89f65ee8a2651509149b0ee34070799405e6879a..d69c8df3d5755a839d24ef06a3cbca6f2d3f3e66 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts +++ b/src/components/Map/Drawer/OverlaysDrawer/GeneralOverlays/OverlayListItem/hooks/useOverlay.ts @@ -1,6 +1,9 @@ import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { isOverlayActiveSelector } from '@/redux/overlayBioEntity/overlayBioEntity.selector'; +import { + isOverlayActiveSelector, + isOverlayLoadingSelector, +} from '@/redux/overlayBioEntity/overlayBioEntity.selector'; import { removeOverlayBioEntityForGivenOverlay } from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import { getOverlayBioEntityForAllModels } from '@/redux/overlayBioEntity/overlayBioEntity.thunk'; import { useEmptyBackground } from './useEmptyBackground'; @@ -8,11 +11,13 @@ import { useEmptyBackground } from './useEmptyBackground'; type UseOverlay = { toggleOverlay: () => void; isOverlayActive: boolean; + isOverlayLoading: boolean; }; export const useOverlay = (overlayId: number): UseOverlay => { const dispatch = useAppDispatch(); const isOverlayActive = useAppSelector(state => isOverlayActiveSelector(state, overlayId)); + const isOverlayLoading = useAppSelector(state => isOverlayLoadingSelector(state, overlayId)); const { setBackgroundtoEmptyIfAvailable } = useEmptyBackground(); const toggleOverlay = (): void => { @@ -24,5 +29,5 @@ export const useOverlay = (overlayId: number): UseOverlay => { } }; - return { toggleOverlay, isOverlayActive }; + return { toggleOverlay, isOverlayActive, isOverlayLoading }; }; diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx index 13083c2cb94cf8d66be270f3079880c84b106fed..d3e660f2e6e6f9b6e3ffa922b8b3e9902921067a 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysDrawer.component.tsx @@ -3,6 +3,7 @@ import { currentStepOverlayDrawerStateSelector } from '@/redux/drawer/drawer.sel import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { DrawerHeading } from '@/shared/DrawerHeading'; import { GeneralOverlays } from './GeneralOverlays'; +import { OverlaysLegends } from './OverlaysLegends'; import { UserOverlayForm } from './UserOverlayForm'; import { UserOverlays } from './UserOverlays'; @@ -17,6 +18,7 @@ export const OverlaysDrawer = (): JSX.Element => { <div className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto"> <GeneralOverlays /> <UserOverlays /> + <OverlaysLegends /> </div> </> )} diff --git a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx index b690ff07c72826f0286739194653d6547f7f45d2..b9f5567587faa9b6b025287ef243c52ebbf8ef64 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/OverlaysLegends/OverlaysLegends.component.tsx @@ -6,7 +6,7 @@ export const OverlaysLegends = (): JSX.Element => { const overlays = useSelector(activeOverlaysSelector); return ( - <div className="p-6" data-testid="overlays-legends"> + <div className="border-t border-t-divide p-6" data-testid="overlays-legends"> {overlays.map(overlay => ( <OverlaySingleLegend key={overlay.idObject} overlay={overlay} /> ))} diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index ea5a18e2ef3a95b5001d4873022ee8c220ae829f..36ac1a1f14fe6f6f65ec250f55ee29c396c69128 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -1,5 +1,6 @@ import { Drawer } from '@/components/Map/Drawer'; import { Legend } from '@/components/Map/Legend'; +import { MapAdditionalActions } from './MapAdditionalActions'; import { MapAdditionalOptions } from './MapAdditionalOptions'; import { MapViewer } from './MapViewer/MapViewer.component'; @@ -12,5 +13,6 @@ export const Map = (): JSX.Element => ( <Drawer /> <MapViewer /> <Legend /> + <MapAdditionalActions /> </div> ); diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f65f5b605510ad4afb566e763c886fddbc9b1225 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx @@ -0,0 +1,159 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { AppDispatch, RootState, StoreType } from '@/redux/store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { act, render, screen } from '@testing-library/react'; +import Map from 'ol/Map'; +import { MockStoreEnhanced } from 'redux-mock-store'; +import { MapAdditionalActions } from './MapAdditionalActions.component'; +import { useVisibleBioEntitiesPolygonCoordinates } from './utils/useVisibleBioEntitiesPolygonCoordinates'; + +const setBounds = jest.fn(); + +jest.mock('../../../utils/map/useSetBounds', () => ({ + _esModule: true, + useSetBounds: (): jest.Mock => setBounds, +})); + +jest.mock('./utils/useVisibleBioEntitiesPolygonCoordinates', () => ({ + _esModule: true, + useVisibleBioEntitiesPolygonCoordinates: jest.fn(), +})); + +const useVisibleBioEntitiesPolygonCoordinatesMock = + useVisibleBioEntitiesPolygonCoordinates as jest.Mock; + +setBounds.mockImplementation(() => {}); + +const renderComponent = ( + initialStore?: InitialStoreState, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStore); + return ( + render( + <Wrapper> + <MapAdditionalActions /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const renderComponentWithMapInstance = (initialStore?: InitialStoreState): { store: StoreType } => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + + const { Wrapper, store } = getReduxWrapperWithStore(initialStore, { + mapInstanceContextValue: { + mapInstance, + setMapInstance: () => {}, + }, + }); + + return ( + render( + <Wrapper> + <MapAdditionalActions /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('MapAdditionalActions - component', () => { + describe('when always', () => { + beforeEach(() => { + renderComponent(INITIAL_STORE_STATE_MOCK); + }); + + it('should render zoom in button', () => { + const button = screen.getByTestId('zoom-in-button'); + expect(button).toBeInTheDocument(); + }); + + it('should render zoom out button', () => { + const button = screen.getByTestId('zoom-out-button'); + expect(button).toBeInTheDocument(); + }); + + it('should render location button', () => { + const button = screen.getByTestId('location-button'); + expect(button).toBeInTheDocument(); + }); + }); + + describe('when clicked on zoom in button', () => { + it('should dispatch varyPositionZoom action with valid delta', () => { + const { store } = renderComponent(INITIAL_STORE_STATE_MOCK); + const button = screen.getByTestId('zoom-in-button'); + button!.click(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { delta: 1 }, + type: 'map/varyPositionZoom', + }); + }); + }); + + describe('when clicked on zoom in button', () => { + it('should dispatch varyPositionZoom action with valid delta', () => { + const { store } = renderComponent(INITIAL_STORE_STATE_MOCK); + const button = screen.getByTestId('zoom-out-button'); + button!.click(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { delta: -1 }, + type: 'map/varyPositionZoom', + }); + }); + }); + + describe('when clicked on location button', () => { + it('setBounds should be called', () => { + useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => [ + [128, 128], + [192, 192], + ]); + + renderComponentWithMapInstance({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + }); + + const button = screen.getByTestId('location-button'); + act(() => { + button!.click(); + }); + + expect(setBounds).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c0b7368f0b9ab106806ac58284a9e0a87f6f669d --- /dev/null +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx @@ -0,0 +1,44 @@ +import { Icon } from '@/shared/Icon'; +import { twMerge } from 'tailwind-merge'; +import { useAddtionalActions } from './utils/useAdditionalActions'; + +export const MapAdditionalActions = (): JSX.Element => { + const { zoomIn, zoomOut, zoomInToBioEntities } = useAddtionalActions(); + + return ( + <div + className={twMerge( + 'absolute bottom-6 right-6 z-10 flex flex-col gap-4', + 'drop-shadow-primary', + )} + > + <button + type="button" + className="flex h-12 w-12 items-center justify-center rounded-full bg-white" + onClick={zoomInToBioEntities} + data-testid="location-button" + > + <Icon className="h-[28px] w-[28px]" name="location" /> + </button> + <div className="flex h-auto w-12 flex-col items-center justify-center rounded-full bg-white py-2"> + <button + type="button" + className="flex h-12 w-12 items-center justify-center" + onClick={zoomIn} + data-testid="zoom-in-button" + > + <Icon className="h-[24px] w-[24px]" name="magnifier-zoom-in" /> + </button> + <div className="h-px w-12 bg-[#F1F1F1]" /> + <button + type="button" + className="flex h-12 w-12 items-center justify-center" + onClick={zoomOut} + data-testid="zoom-out-button" + > + <Icon className="h-[24px] w-[24px]" name="magnifier-zoom-out" /> + </button> + </div> + </div> + ); +}; diff --git a/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts b/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..0803794471425c623acb0dd038ef4f4389e83898 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts @@ -0,0 +1,3 @@ +export const MAP_ZOOM_IN_DELTA = 1; + +export const MAP_ZOOM_OUT_DELTA = -1; diff --git a/src/components/Map/MapAdditionalActions/index.ts b/src/components/Map/MapAdditionalActions/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b2260185302e8c2d4376e0390693594ffd951b0 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/index.ts @@ -0,0 +1 @@ +export { MapAdditionalActions } from './MapAdditionalActions.component'; diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d296729a6fde1d04dec7fa42f3b4f8a313b54a7 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable no-magic-numbers */ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import Map from 'ol/Map'; +import { useAddtionalActions } from './useAdditionalActions'; +import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; + +jest.mock('./useVisibleBioEntitiesPolygonCoordinates', () => ({ + _esModule: true, + useVisibleBioEntitiesPolygonCoordinates: jest.fn(), +})); + +const useVisibleBioEntitiesPolygonCoordinatesMock = + useVisibleBioEntitiesPolygonCoordinates as jest.Mock; + +describe('useAddtionalActions - hook', () => { + describe('on zoomIn', () => { + it('should dispatch varyPositionZoom action with valid delta', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + const { + result: { + current: { zoomIn }, + }, + } = renderHook(() => useAddtionalActions(), { + wrapper: Wrapper, + }); + + zoomIn(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { delta: 1 }, + type: 'map/varyPositionZoom', + }); + }); + }); + + describe('on zoomOut', () => { + it('should dispatch varyPositionZoom action with valid delta', () => { + const { Wrapper, store } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + const { + result: { + current: { zoomOut }, + }, + } = renderHook(() => useAddtionalActions(), { + wrapper: Wrapper, + }); + + zoomOut(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: { delta: -1 }, + type: 'map/varyPositionZoom', + }); + }); + }); + + describe('on zoomInToBioEntities', () => { + describe('when there are valid polygon coordinates', () => { + beforeEach(() => { + useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => [ + [128, 128], + [192, 192], + ]); + }); + + it('should return valid results', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + + const { Wrapper } = getReduxWrapperWithStore( + { + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + }, + { + mapInstanceContextValue: { + mapInstance, + setMapInstance: () => {}, + }, + }, + ); + const { + result: { + current: { zoomInToBioEntities }, + }, + } = renderHook(() => useAddtionalActions(), { + wrapper: Wrapper, + }); + + expect(zoomInToBioEntities()).toStrictEqual({ + extent: [128, 128, 192, 192], + options: { maxZoom: 1, padding: [128, 128, 128, 128], size: undefined }, + // size is real size on the screen, so it'll be undefined in the jest + }); + }); + }); + + describe('when there are no polygon coordinates', () => { + beforeEach(() => { + useVisibleBioEntitiesPolygonCoordinatesMock.mockImplementation(() => undefined); + }); + + it('should return undefined', () => { + const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + const { + result: { + current: { zoomInToBioEntities }, + }, + } = renderHook(() => useAddtionalActions(), { + wrapper: Wrapper, + }); + + expect(zoomInToBioEntities()).toBeUndefined(); + }); + }); + }); +}); diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b93fc761920b36c99d3a51426a9bbed0f25f6512 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.ts @@ -0,0 +1,39 @@ +import { varyPositionZoom } from '@/redux/map/map.slice'; +import { SetBoundsResult, useSetBounds } from '@/utils/map/useSetBounds'; +import { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import { MAP_ZOOM_IN_DELTA, MAP_ZOOM_OUT_DELTA } from '../MappAdditionalActions.constants'; +import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; + +interface UseAddtionalActionsResult { + zoomIn(): void; + zoomOut(): void; + zoomInToBioEntities(): void; +} + +export const useAddtionalActions = (): UseAddtionalActionsResult => { + const dispatch = useDispatch(); + const setBounds = useSetBounds(); + const polygonCoordinates = useVisibleBioEntitiesPolygonCoordinates(); + + const zoomInToBioEntities = (): SetBoundsResult | undefined => { + if (!polygonCoordinates) { + return undefined; + } + + return setBounds(polygonCoordinates); + }; + + const varyZoomByDelta = useCallback( + (delta: number) => { + dispatch(varyPositionZoom({ delta })); + }, + [dispatch], + ); + + return { + zoomIn: () => varyZoomByDelta(MAP_ZOOM_IN_DELTA), + zoomOut: () => varyZoomByDelta(MAP_ZOOM_OUT_DELTA), + zoomInToBioEntities, + }; +}; diff --git a/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f84e5d6f681e7d13ea4f2e0ecd3f5326fd4b665 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.test.ts @@ -0,0 +1,221 @@ +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +/* eslint-disable no-magic-numbers */ +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { BIOENTITY_INITIAL_STATE_MOCK } from '@/redux/bioEntity/bioEntity.mock'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; +import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; +import { RootState } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { CHEMICALS_INITIAL_STATE_MOCK } from '../../../../redux/chemicals/chemicals.mock'; +import { DRUGS_INITIAL_STATE_MOCK } from '../../../../redux/drugs/drugs.mock'; +import { DEFAULT_POSITION, MAIN_MAP, MAP_INITIAL_STATE } from '../../../../redux/map/map.constants'; +import { MODELS_INITIAL_STATE_MOCK } from '../../../../redux/models/models.mock'; +import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; + +/* key elements of the state: + - this state simulates situation where there is: + -- one searched element + -- of currently selected map + -- for each content/chemicals/drugs data set + + - the key differences in this states are x/y/z coordinates of element's bioEntities +*/ + +const getInitalState = ( + { hideElements }: { hideElements: boolean } = { hideElements: false }, +): RootState => { + const elementsLimit = hideElements ? 0 : 1; + + return { + ...INITIAL_STORE_STATE_MOCK, + drawer: { + ...DRAWER_INITIAL_STATE, + searchDrawerState: { + ...DRAWER_INITIAL_STATE.searchDrawerState, + selectedSearchElement: 'search', + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + data: [ + { + ...modelsFixture[0], + idObject: 5052, + }, + ], + }, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + openedMaps: [{ modelId: 5052, modelName: MAIN_MAP, lastPosition: DEFAULT_POSITION }], + }, + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: [ + { + searchQueryElement: 'search', + data: [ + { + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + model: 5052, + x: 16, + y: 16, + z: 1, + }, + }, + ].slice(0, elementsLimit), + loading: 'succeeded', + error: { message: '', name: '' }, + }, + ], + }, + chemicals: { + ...CHEMICALS_INITIAL_STATE_MOCK, + data: [ + { + searchQueryElement: 'search', + data: [ + { + ...chemicalsFixture[0], + targets: [ + { + ...chemicalsFixture[0].targets[0], + targetElements: [ + { + ...chemicalsFixture[0].targets[0].targetElements[0], + model: 5052, + x: 32, + y: 32, + z: 1, + }, + ], + }, + ], + }, + ].slice(0, elementsLimit), + loading: 'succeeded', + error: { message: '', name: '' }, + }, + { + searchQueryElement: 'not-search', + data: [ + { + ...chemicalsFixture[0], + targets: [ + { + ...chemicalsFixture[0].targets[0], + targetElements: [ + { + ...chemicalsFixture[0].targets[0].targetElements[0], + model: 5052, + x: 8, + y: 2, + z: 9, + }, + ], + }, + ], + }, + ].slice(0, elementsLimit), + loading: 'succeeded', + error: { message: '', name: '' }, + }, + ], + }, + drugs: { + ...DRUGS_INITIAL_STATE_MOCK, + data: [ + { + searchQueryElement: 'search', + data: [ + { + ...drugsFixture[0], + targets: [ + { + ...drugsFixture[0].targets[0], + targetElements: [ + { + ...drugsFixture[0].targets[0].targetElements[0], + model: 5052, + x: 128, + y: 128, + z: 1, + }, + ], + }, + ], + }, + ].slice(0, elementsLimit), + loading: 'succeeded', + error: { message: '', name: '' }, + }, + { + searchQueryElement: 'not-search', + data: [ + { + ...drugsFixture[0], + targets: [ + { + ...drugsFixture[0].targets[0], + targetElements: [ + { + ...drugsFixture[0].targets[0].targetElements[0], + model: 5052, + x: 100, + y: 50, + z: 4, + }, + ], + }, + ], + }, + ].slice(0, elementsLimit), + loading: 'succeeded', + error: { message: '', name: '' }, + }, + ], + }, + }; +}; + +describe('useVisibleBioEntitiesPolygonCoordinates - hook', () => { + describe('when allVisibleBioEntities is empty', () => { + const { Wrapper } = getReduxWrapperWithStore(getInitalState({ hideElements: true })); + const { result } = renderHook(() => useVisibleBioEntitiesPolygonCoordinates(), { + wrapper: Wrapper, + }); + + it('should return undefined', () => { + expect(result.current).toBe(undefined); + }); + }); + + describe('when allVisibleBioEntities has data', () => { + const { Wrapper } = getReduxWrapperWithStore(getInitalState()); + const { result } = renderHook(() => useVisibleBioEntitiesPolygonCoordinates(), { + wrapper: Wrapper, + }); + + it('should return undefined', () => { + expect(result.current).toStrictEqual([ + [-17532820, -0], + [0, 17532820], + ]); + }); + }); +}); diff --git a/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fbcd551ac4a6e2856951352d999529494446903 --- /dev/null +++ b/src/components/Map/MapAdditionalActions/utils/useVisibleBioEntitiesPolygonCoordinates.ts @@ -0,0 +1,49 @@ +import { allVisibleBioEntitiesSelector } from '@/redux/bioEntity/bioEntity.selectors'; +import { Point } from '@/types/map'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { isPointValid } from '@/utils/point/isPointValid'; +import { Coordinate } from 'ol/coordinate'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +const VALID_POLYGON_COORDINATES_LENGTH = 2; + +export const useVisibleBioEntitiesPolygonCoordinates = (): Coordinate[] | undefined => { + const allVisibleBioEntities = useSelector(allVisibleBioEntitiesSelector); + const pointToProjection = usePointToProjection(); + + const polygonPoints = useMemo((): Point[] => { + const allX = allVisibleBioEntities.map(({ x }) => x); + const allY = allVisibleBioEntities.map(({ y }) => y); + + const minX = Math.min(...allX); + const maxX = Math.max(...allX); + + const minY = Math.min(...allY); + const maxY = Math.max(...allY); + + const points = [ + { + x: minX, + y: maxY, + }, + { + x: maxX, + y: minY, + }, + ]; + + return points.filter(isPointValid); + }, [allVisibleBioEntities]); + + const polygonCoordinates = useMemo( + () => polygonPoints.map(point => pointToProjection(point)), + [polygonPoints, pointToProjection], + ); + + if (polygonCoordinates.length !== VALID_POLYGON_COORDINATES_LENGTH) { + return undefined; + } + + return polygonCoordinates; +}; diff --git a/src/components/Map/MapViewer/MapViewer.types.ts b/src/components/Map/MapViewer/MapViewer.types.ts index 2cc15d5da01ea0a5ab0cec43e9f0abacc762f790..f6750e5cb1b7db0b38c29ba97fc7ca3c5cbd7b26 100644 --- a/src/components/Map/MapViewer/MapViewer.types.ts +++ b/src/components/Map/MapViewer/MapViewer.types.ts @@ -1,9 +1,6 @@ -import Map from 'ol/Map'; import View from 'ol/View'; import BaseLayer from 'ol/layer/Base'; -export type MapInstance = Map | undefined; - export type MapConfig = { view: View; layers: BaseLayer[]; diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts index 0c2079d04320fc6daf02b194402aa5b2bbddb32b..ceecf01f0ad220d2637283d80ae564b650f13f53 100644 --- a/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts @@ -3,14 +3,15 @@ import { searchedBioEntitesSelectorOfCurrentMap } from '@/redux/bioEntity/bioEnt import { searchedChemicalsBioEntitesOfCurrentMapSelector } from '@/redux/chemicals/chemicals.selectors'; import { searchedDrugsBioEntitesOfCurrentMapSelector } from '@/redux/drugs/drugs.selectors'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; -import BaseLayer from 'ol/layer/Base'; +import Feature from 'ol/Feature'; +import { Geometry } from 'ol/geom'; import VectorLayer from 'ol/layer/Vector'; import VectorSource from 'ol/source/Vector'; import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { getBioEntitiesFeatures } from './getBioEntitiesFeatures'; -export const useOlMapPinsLayer = (): BaseLayer => { +export const useOlMapPinsLayer = (): VectorLayer<VectorSource<Feature<Geometry>>> => { const pointToProjection = usePointToProjection(); const contentBioEntites = useSelector(searchedBioEntitesSelectorOfCurrentMap); const chemicalsBioEntities = useSelector(searchedChemicalsBioEntitesOfCurrentMapSelector); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index b4db459df1c08630eae039f9f5539855cf4964ac..ff40ba9134e054c7dd1cb444337071f2a7aef2ed 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -1,10 +1,11 @@ /* eslint-disable no-magic-numbers */ +import { MapInstance } from '@/types/map'; import { useEffect } from 'react'; -import { MapConfig, MapInstance } from '../../MapViewer.types'; +import { MapConfig } from '../../MapViewer.types'; +import { useOlMapOverlaysLayer } from './overlaysLayer/useOlMapOverlaysLayer'; import { useOlMapPinsLayer } from './pinsLayer/useOlMapPinsLayer'; import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; import { useOlMapTileLayer } from './useOlMapTileLayer'; -import { useOlMapOverlaysLayer } from './overlaysLayer/useOlMapOverlaysLayer'; interface UseOlMapLayersInput { mapInstance: MapInstance; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.ts index 9dc00a261aaed3dfc9c4f3dda0187755d0ae9c7e..4a4d9dc18032cb862f0bd6a88f96ab199f5bff47 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.ts @@ -1,12 +1,12 @@ /* eslint-disable no-magic-numbers */ import { OPTIONS } from '@/constants/map'; import { mapDataInitialPositionSelector } from '@/redux/map/map.selectors'; -import { Point } from '@/types/map'; +import { MapInstance, Point } from '@/types/map'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; import { View } from 'ol'; import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; -import { MapConfig, MapInstance } from '../../MapViewer.types'; +import { MapConfig } from '../../MapViewer.types'; interface UseOlMapViewInput { mapInstance: MapInstance; diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 5be3fd4cbf657d07bc3e958bda4e2c8b1205d812..5d7631ff007bc82ddcc675b81e67bd5eb384fdf4 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -2,16 +2,16 @@ import { OPTIONS } from '@/constants/map'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { mapDataSizeSelector } from '@/redux/map/map.selectors'; import { currentModelIdSelector } from '@/redux/models/models.selectors'; +import { MapInstance } from '@/types/map'; import { View } from 'ol'; import { unByKey } from 'ol/Observable'; +import { Coordinate } from 'ol/coordinate'; +import { Pixel } from 'ol/pixel'; import { useEffect, useRef } from 'react'; import { useSelector } from 'react-redux'; import { useDebouncedCallback } from 'use-debounce'; -import { Pixel } from 'ol/pixel'; -import { Coordinate } from 'ol/coordinate'; -import { MapInstance } from '../../MapViewer.types'; -import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapRightClick } from './mapRightClick/onMapRightClick'; +import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapPositionChange } from './onMapPositionChange'; interface UseOlMapListenersInput { diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts index a7ffb39839e07fea132e5b3fbb7093aadbc03ff4..326e8ec8822585de8fe9397ec380143a7f040016 100644 --- a/src/components/Map/MapViewer/utils/useOlMap.ts +++ b/src/components/Map/MapViewer/utils/useOlMap.ts @@ -1,6 +1,8 @@ +import { MapInstance } from '@/types/map'; +import { useMapInstance } from '@/utils/context/mapInstanceContext'; import Map from 'ol/Map'; -import React, { MutableRefObject, useEffect, useState } from 'react'; -import { MapInstance } from '../MapViewer.types'; +import { Zoom } from 'ol/control'; +import React, { MutableRefObject, useEffect } from 'react'; import { useOlMapLayers } from './config/useOlMapLayers'; import { useOlMapView } from './config/useOlMapView'; import { useOlMapListeners } from './listeners/useOlMapListeners'; @@ -17,7 +19,7 @@ type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput; export const useOlMap: UseOlMap = ({ target } = {}) => { const mapRef = React.useRef<null | HTMLDivElement>(null); - const [mapInstance, setMapInstance] = useState<MapInstance>(undefined); + const { mapInstance, setMapInstance } = useMapInstance(); const view = useOlMapView({ mapInstance }); useOlMapLayers({ mapInstance }); useOlMapListeners({ view, mapInstance }); @@ -32,8 +34,15 @@ export const useOlMap: UseOlMap = ({ target } = {}) => { target: target || mapRef.current, }); + // remove zoom controls as we are using our own + map.getControls().forEach(mapControl => { + if (mapControl instanceof Zoom) { + map.removeControl(mapControl); + } + }); + setMapInstance(currentMap => currentMap || map); - }, [target]); + }, [target, setMapInstance]); return { mapRef, diff --git a/src/models/compartmentPathwaySchema.ts b/src/models/compartmentPathwaySchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7f3cdc4e4939565b564c4961086db3f26879bd1 --- /dev/null +++ b/src/models/compartmentPathwaySchema.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; + +export const compartmentPathwaySchema = z.object({ + id: z.number(), +}); + +export const boundsSchema = z.object({ + height: z.number(), + width: z.number(), + x: z.number(), + y: z.number(), + z: z.number(), +}); + +export const otherSchema = z.object({ + modifications: z.array(z.unknown()), + structuralState: z.null(), + structures: z.object({}), +}); + +export const compartmentPathwayDetailsSchema = z.object({ + abbreviation: z.null(), + activity: z.null(), + boundaryCondition: z.null(), + bounds: boundsSchema, + compartmentId: z.number().nullable(), + complexId: z.null(), + constant: z.null(), + elementId: z.string(), + formerSymbols: z.array(z.unknown()), + formula: z.null(), + fullName: z.string().nullable(), + glyph: z.any(), + hierarchyVisibilityLevel: z.string(), + homomultimer: z.null(), + hypothetical: z.null(), + id: z.number(), + initialAmount: z.null(), + initialConcentration: z.null(), + linkedSubmodel: z.null(), + modelId: z.number(), + name: z.string(), + notes: z.string(), + other: otherSchema, + references: z.array(z.unknown()), + symbol: z.null(), + synonyms: z.array(z.unknown()), + transparencyLevel: z.string(), + type: z.string(), +}); diff --git a/src/models/fixtures/compartmentPathways.ts b/src/models/fixtures/compartmentPathways.ts new file mode 100644 index 0000000000000000000000000000000000000000..c93b4dc0a92f0efccbffa218eb4de56c00484921 --- /dev/null +++ b/src/models/fixtures/compartmentPathways.ts @@ -0,0 +1,29 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { z } from 'zod'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '../compartmentPathwaySchema'; + +export const compartmentPathwaysFixture = createFixture(z.array(compartmentPathwaySchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); + +export const compartmentPathwaysOverLimitFixture = createFixture( + z.array(compartmentPathwaySchema), + { + seed: ZOD_SEED, + array: { min: 101, max: 101 }, + }, +); + +export const compartmentPathwaysDetailsFixture = createFixture( + z.array(compartmentPathwayDetailsSchema), + { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, + }, +); diff --git a/src/models/mapPoint.ts b/src/models/mapPoint.ts new file mode 100644 index 0000000000000000000000000000000000000000..813926d1fa8cd6b008d0ebe51649938c0f6e533d --- /dev/null +++ b/src/models/mapPoint.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +/* This schema is used only for local Point objects, it's NOT returned from backend */ + +export const mapPointSchema = z.object({ + x: z.number().finite().nonnegative(), + y: z.number().finite().nonnegative(), + z: z.number().finite().nonnegative().optional(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 433b6c499e2a2cd01c02670873976f46dd40d3db..c417dbca7d69a89873f8726558be45836244617b 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -39,4 +39,9 @@ export const apiPath = { createOverlayFile: (): string => `files/`, uploadOverlayFileContent: (fileId: number): string => `files/${fileId}:uploadContent`, getStatisticsById: (projectId: string): string => `projects/${projectId}/statistics/`, + getCompartmentPathwaysIds: (objectId: number): string => + `projects/${PROJECT_ID}/models/${objectId}/bioEntities/elements/?columns=id&type=Compartment,Pathway`, + getCompartmentPathwayDetails: (ids: number[]): string => + `projects/${PROJECT_ID}/models/*/bioEntities/elements/?id=${ids.join(',')}`, + sendCompartmentPathwaysIds: (): string => `projects/${PROJECT_ID}/models/*/bioEntities/elements/`, }; diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index f3b2149d886a31f9605d9cf1b044837447059f40..8b39954fdb451fe218724c0315382a527b4f39b1 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -3,11 +3,13 @@ import { rootSelector } from '@/redux/root/root.selectors'; import { MultiSearchData } from '@/types/fetchDataState'; import { BioEntity, BioEntityContent, MapModel } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; +import { searchedChemicalsBioEntitesOfCurrentMapSelector } from '../chemicals/chemicals.selectors'; +import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector'; import { currentSearchedBioEntityId, currentSelectedSearchElement, } from '../drawer/drawer.selectors'; -import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector'; +import { searchedDrugsBioEntitesOfCurrentMapSelector } from '../drugs/drugs.selectors'; import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors'; export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); @@ -105,3 +107,12 @@ export const bioEntitiesPerModelSelector = createSelector( ); }, ); + +export const allVisibleBioEntitiesSelector = createSelector( + searchedBioEntitesSelectorOfCurrentMap, + searchedChemicalsBioEntitesOfCurrentMapSelector, + searchedDrugsBioEntitesOfCurrentMapSelector, + (content, chemicals, drugs): BioEntity[] => { + return [content, chemicals, drugs].flat(); + }, +); diff --git a/src/redux/compartmentPathways/comparmentPathways.constants.ts b/src/redux/compartmentPathways/comparmentPathways.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bf4d5195d6e6e7fca140310ea21ce69f373da03 --- /dev/null +++ b/src/redux/compartmentPathways/comparmentPathways.constants.ts @@ -0,0 +1 @@ +export const MAX_NUMBER_OF_IDS_IN_GET_QUERY = 100; diff --git a/src/redux/compartmentPathways/compartmentPathways.mock.ts b/src/redux/compartmentPathways/compartmentPathways.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d62817d1c2ee3f81c4fd806f37996c7c2ed99ff --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.mock.ts @@ -0,0 +1,64 @@ +import { MapModel } from '@/types/models'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; + +export const COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK: CompartmentPathwaysState = { + loading: 'idle', + data: [], + error: { name: '', message: '' }, +}; +export const MODELS_MOCK: MapModel[] = [ + { + idObject: 5053, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, + { + idObject: 5054, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, +]; + +export const MODELS_MOCK_SHORT: MapModel[] = [ + { + idObject: 5050, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, +]; diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..037eefa24fe80a7a82141bdedf7ce5c757e0b9b6 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + compartmentPathwaysDetailsFixture, + compartmentPathwaysFixture, + compartmentPathwaysOverLimitFixture, +} from '@/models/fixtures/compartmentPathways'; +import { getModelsIds } from '@/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils'; +import { apiPath } from '../apiPath'; +import compartmentPathwaysReducer from './compartmentPathways.slice'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { MODELS_MOCK } from './compartmentPathways.mock'; + +const mockedAxiosClient = mockNetworkResponse(); +const MODELS_MOCK_IDS = getModelsIds(MODELS_MOCK); +const INITIAL_STATE: CompartmentPathwaysState = { + loading: 'idle', + error: { name: '', message: '' }, + data: [], +}; + +describe('compartmentPathways reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<CompartmentPathwaysState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('compartmentPathways', compartmentPathwaysReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + expect(compartmentPathwaysReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store on loading getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(52)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([1, 2, 3])) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('idle'); + expect(data).toEqual([]); + + store.dispatch(getCompartmentPathways()); + + const { loading: loadingPending, data: dataPending } = store.getState().compartmentPathways; + + expect(loadingPending).toEqual('pending'); + expect(dataPending).toEqual([]); + }); + + it('should update store after succesful getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5054)) + .reply(HttpStatusCode.Ok, compartmentPathwaysOverLimitFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual([ + ...compartmentPathwaysDetailsFixture, + ...compartmentPathwaysDetailsFixture, + ]); + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should update store after failed getCompartmentPathways query', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.NotFound, []); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([])) + .reply(HttpStatusCode.NotFound, []); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.NotFound, []); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + await compartmentPathwaysPromise; + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(promiseFulfilled).toEqual('failed'); + expect(dataFulfilled).toEqual([]); + }); +}); diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..8010fbc062d475e23c04e3f072746b99b7f5e5c7 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.ts @@ -0,0 +1,20 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; + +export const getCompartmentPathwaysReducer = ( + builder: ActionReducerMapBuilder<CompartmentPathwaysState>, +): void => { + builder + .addCase(getCompartmentPathways.pending, state => { + state.loading = 'pending'; + }) + .addCase(getCompartmentPathways.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }) + .addCase(getCompartmentPathways.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; diff --git a/src/redux/compartmentPathways/compartmentPathways.selectors.ts b/src/redux/compartmentPathways/compartmentPathways.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6c8294223b341bae523684e0ad92bd3b79ee9a6 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.selectors.ts @@ -0,0 +1,17 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const compartmentPathwaysSelector = createSelector( + rootSelector, + state => state.compartmentPathways, +); + +export const compartmentPathwaysDataSelector = createSelector( + compartmentPathwaysSelector, + state => state.data, +); + +export const loadingCompartmentPathwaysSelector = createSelector( + compartmentPathwaysSelector, + state => state.loading, +); diff --git a/src/redux/compartmentPathways/compartmentPathways.slice.ts b/src/redux/compartmentPathways/compartmentPathways.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..3cc1b37525aa6b0d4dcfa755d41a381dfc3b9556 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathwaysReducer } from './compartmentPathways.reducers'; + +export const initialState: CompartmentPathwaysState = { + loading: 'idle', + error: { name: '', message: '' }, + data: [], +}; + +export const compartmentPathwaysSlice = createSlice({ + name: 'compartmentPathways', + initialState, + reducers: {}, + extraReducers: builder => { + getCompartmentPathwaysReducer(builder); + }, +}); + +export default compartmentPathwaysSlice.reducer; diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..407eb70d6a20ff4abb36a2408b2a0eb388c084b3 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable no-magic-numbers */ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { + compartmentPathwaysDetailsFixture, + compartmentPathwaysFixture, + compartmentPathwaysOverLimitFixture, +} from '@/models/fixtures/compartmentPathways'; +import { getModelsIds } from '@/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils'; +import { apiPath } from '../apiPath'; +import compartmentPathwaysReducer from './compartmentPathways.slice'; +import { CompartmentPathwaysState } from './compartmentPathways.types'; +import { getCompartmentPathways } from './compartmentPathways.thunks'; +import { MODELS_MOCK, MODELS_MOCK_SHORT } from './compartmentPathways.mock'; + +const mockedAxiosClient = mockNetworkResponse(); +const MODELS_MOCK_IDS = getModelsIds(MODELS_MOCK); + +describe('compartmentPathways thunk', () => { + let store = {} as ToolkitStoreWithSingleSlice<CompartmentPathwaysState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('compartmentPathways', compartmentPathwaysReducer); + }); + + it('should handle query getCompartmentPathways properly when models are undefined', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(52)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails([1, 2, 3])) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('idle'); + expect(data).toEqual([]); + + const comparmentPathwaysPromise = store.dispatch(getCompartmentPathways()); + + const { loading: loadingPending, data: dataPending } = store.getState().compartmentPathways; + + expect(loadingPending).toEqual('pending'); + expect(dataPending).toEqual([]); + + await comparmentPathwaysPromise; + const { loading: loadingFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(loadingFulfilled).toEqual('succeeded'); + expect(dataFulfilled).toEqual([]); + }); + it('should handle sendCompartmentPathwaysIds request properly if it is more than 100 ids', async () => { + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5053)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(5054)) + .reply(HttpStatusCode.Ok, compartmentPathwaysOverLimitFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways(MODELS_MOCK_IDS)); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual([ + ...compartmentPathwaysDetailsFixture, + ...compartmentPathwaysDetailsFixture, + ]); + expect(promiseFulfilled).toEqual('succeeded'); + }); + + it('should not do a network request sendCompartmentPathwaysIds if it is less than 100 ids', async () => { + const ONE_MODEL = MODELS_MOCK_SHORT[0]; + const ID = ONE_MODEL.idObject; + + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwaysIds(ID)) + .reply(HttpStatusCode.Ok, compartmentPathwaysFixture); + + const ids = compartmentPathwaysFixture.map(el => el.id); + mockedAxiosClient + .onGet(apiPath.getCompartmentPathwayDetails(ids)) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + mockedAxiosClient + .onPost(apiPath.sendCompartmentPathwaysIds()) + .reply(HttpStatusCode.Ok, compartmentPathwaysDetailsFixture); + + const compartmentPathwaysPromise = store.dispatch(getCompartmentPathways([ONE_MODEL.idObject])); + + const { loading, data } = store.getState().compartmentPathways; + + expect(loading).toEqual('pending'); + expect(data).toEqual([]); + + const { type } = await compartmentPathwaysPromise; + + expect(type).toBe('compartmentPathways/getCompartmentPathways/fulfilled'); + + const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; + + expect(dataFulfilled).toEqual(compartmentPathwaysDetailsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); +}); diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0d69617a704c2361043c98bf1104fb7ced0e827 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.ts @@ -0,0 +1,120 @@ +/* eslint-disable no-restricted-syntax */ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { CompartmentPathway, CompartmentPathwayDetails } from '@/types/models'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '@/models/compartmentPathwaySchema'; +import { z } from 'zod'; +import { MAX_NUMBER_OF_IDS_IN_GET_QUERY } from './comparmentPathways.constants'; +import { apiPath } from '../apiPath'; + +/** UTILS */ + +const fetchCompartmentPathwaysIds = async ( + modelsIds: number[] | undefined, +): Promise<number[][]> => { + if (!modelsIds) return []; + + const compartmentIds = []; + + for (const modelId of modelsIds) { + /* eslint-disable no-await-in-loop */ + const response = await axiosInstance<CompartmentPathway[]>( + apiPath.getCompartmentPathwaysIds(modelId), + ); + + const isDataValid = validateDataUsingZodSchema( + response.data, + z.array(compartmentPathwaySchema), + ); + + if (isDataValid) { + const result = response.data; + const ids: number[] = []; + + result.forEach(item => { + ids.push(item.id); + }); + + compartmentIds.push(ids); + } + } + + return compartmentIds; +}; + +const fetchCompartmentPathwayDetailsByPost = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + const params = { + id: compartmentPathwayIds.join(','), + }; + const body = new URLSearchParams(params); + + const response = await axiosInstance.post<CompartmentPathwayDetails[]>( + apiPath.sendCompartmentPathwaysIds(), + body, + ); + + return response.data; +}; + +const fetchCompartmentPathwayDetailsByGet = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + const response = await axiosInstance.get<CompartmentPathwayDetails[]>( + apiPath.getCompartmentPathwayDetails(compartmentPathwayIds), + ); + + return response.data; +}; + +const fetchCompartmentPathwayDetails = async ( + compartmentPathwayIds: number[], +): Promise<CompartmentPathwayDetails[]> => { + if (compartmentPathwayIds.length) { + let compartmentPathwayDetails; + if (compartmentPathwayIds.length > MAX_NUMBER_OF_IDS_IN_GET_QUERY) { + compartmentPathwayDetails = await fetchCompartmentPathwayDetailsByPost(compartmentPathwayIds); + } else { + compartmentPathwayDetails = await fetchCompartmentPathwayDetailsByGet(compartmentPathwayIds); + } + + const isDataValid = validateDataUsingZodSchema( + compartmentPathwayDetails, + z.array(compartmentPathwayDetailsSchema), + ); + + if (isDataValid) return compartmentPathwayDetails; + } + return []; +}; + +export const fetchCompartmentPathways = async ( + compartmentPathwaysData: number[][], +): Promise<CompartmentPathwayDetails[]> => { + const compartments = []; + + /* eslint-disable no-await-in-loop */ + for (const compartmentPathwayIds of compartmentPathwaysData) { + const compartmentPathwayDetails = await fetchCompartmentPathwayDetails(compartmentPathwayIds); + + if (compartmentPathwayDetails) compartments.push(...compartmentPathwayDetails); + } + + return compartments; +}; + +/** UTILS */ + +export const getCompartmentPathways = createAsyncThunk( + 'compartmentPathways/getCompartmentPathways', + async (modelsIds: number[] | undefined) => { + const compartmentIds = await fetchCompartmentPathwaysIds(modelsIds); + const comparmentPathways = await fetchCompartmentPathways(compartmentIds); + return comparmentPathways; + }, +); diff --git a/src/redux/compartmentPathways/compartmentPathways.types.ts b/src/redux/compartmentPathways/compartmentPathways.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..76718b5456fa89850ddda36afbec9cd912a7cee1 --- /dev/null +++ b/src/redux/compartmentPathways/compartmentPathways.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { CompartmentPathwayDetails } from '@/types/models'; + +export type CompartmentPathwaysState = FetchDataState<CompartmentPathwayDetails[], []>; diff --git a/src/redux/map/map.constants.ts b/src/redux/map/map.constants.ts index 5a4cd08479371a9042648c4ff07fe5bb9c279e98..27a63ef2a593b6e1fd2f031870ae4b489ba6ba25 100644 --- a/src/redux/map/map.constants.ts +++ b/src/redux/map/map.constants.ts @@ -6,7 +6,7 @@ import { DEFAULT_TILE_SIZE, } from '@/constants/map'; import { Point } from '@/types/map'; -import { MapData, OppenedMap } from './map.types'; +import { MapData, MapState, OppenedMap } from './map.types'; export const MAIN_MAP = 'Main map'; @@ -47,3 +47,10 @@ export const OPENED_MAPS_INITIAL_STATE: OppenedMap[] = [ ]; export const MIDDLEWARE_ALLOWED_ACTIONS: string[] = ['map/setMapData']; + +export const MAP_INITIAL_STATE: MapState = { + data: MAP_DATA_INITIAL_STATE, + loading: 'idle', + error: { name: '', message: '' }, + openedMaps: OPENED_MAPS_INITIAL_STATE, +}; diff --git a/src/redux/map/map.reducers.test.ts b/src/redux/map/map.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d711a41b2833a441d78de8f87362370d1797ef28 --- /dev/null +++ b/src/redux/map/map.reducers.test.ts @@ -0,0 +1,66 @@ +import { DEFAULT_CENTER_POINT, DEFAULT_TILE_SIZE } from '@/constants/map'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { MAP_DATA_INITIAL_STATE, MAP_INITIAL_STATE } from './map.constants'; +import { varyPositionZoom } from './map.slice'; + +describe('map reducers', () => { + describe('varyPositionZoomReducer', () => { + const baseMapSize = { + width: 0, + height: 0, + tileSize: DEFAULT_TILE_SIZE, + }; + + const cases: [ + { + minZoom: number; + maxZoom: number; + currentZ: number; + }, + { delta: number }, + { finalZ: number }, + ][] = [ + [{ minZoom: 1, maxZoom: 1, currentZ: 1 }, { delta: 1 }, { finalZ: 1 }], // exeeds the interval + [{ minZoom: 1, maxZoom: 1, currentZ: 1 }, { delta: -1 }, { finalZ: 1 }], // deceeds the interval + [{ minZoom: 1, maxZoom: 2, currentZ: 1 }, { delta: 1 }, { finalZ: 2 }], // inside the interval (with positive delta) + [{ minZoom: 0, maxZoom: 1, currentZ: 1 }, { delta: -1 }, { finalZ: 0 }], // inside the interval (with negative delta) + ]; + + it.each(cases)( + 'should set valid final z position', + ({ minZoom, maxZoom, currentZ }, { delta }, { finalZ }) => { + const { store } = getReduxWrapperWithStore({ + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + ...baseMapSize, + minZoom, + maxZoom, + }, + position: { + initial: { + ...DEFAULT_CENTER_POINT, + z: currentZ, + }, + last: { + ...DEFAULT_CENTER_POINT, + z: currentZ, + }, + }, + }, + }, + }); + + store.dispatch(varyPositionZoom({ delta })); + const newState = store.getState(); + const newInitialZ = newState.map.data.position.initial.z; + const newLastZ = newState.map.data.position.last.z; + + expect(newInitialZ).toEqual(finalZ); + expect(newLastZ).toEqual(finalZ); + }, + ); + }); +}); diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts index b17f1432b0cad6c95e8877e6970d2c6de5d501fa..eef6f9324c56ef85ab70d4c61ccf5f9320f82fd6 100644 --- a/src/redux/map/map.reducers.ts +++ b/src/redux/map/map.reducers.ts @@ -1,4 +1,5 @@ import { ZERO } from '@/constants/common'; +import { DEFAULT_ZOOM } from '@/constants/map'; import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; import { getPointMerged } from '../../utils/object/getPointMerged'; import { MAIN_MAP } from './map.constants'; @@ -14,6 +15,7 @@ import { OpenMapAndSetActiveAction, SetActiveMapAction, SetBackgroundAction, + SetLastPositionZoomAction, SetMapDataAction, SetMapPositionDataAction, } from './map.types'; @@ -40,6 +42,20 @@ export const setMapPositionReducer = (state: MapState, action: SetMapPositionDat }; }; +export const varyPositionZoomReducer = ( + state: MapState, + action: SetLastPositionZoomAction, +): void => { + const { minZoom, maxZoom } = state.data.size; + const { delta } = action.payload; + const currentZ = state.data.position.last.z || DEFAULT_ZOOM; + const newZ = currentZ + delta; + const newZLimited = Math.min(Math.max(newZ, minZoom), maxZoom); + + state.data.position.last.z = newZLimited; + state.data.position.initial.z = newZLimited; +}; + const updateLastPositionOfCurrentlyActiveMap = (state: MapState): void => { const currentMapId = state.data.modelId; const currentOpenedMap = state.openedMaps.find(openedMap => openedMap.modelId === currentMapId); diff --git a/src/redux/map/map.slice.ts b/src/redux/map/map.slice.ts index 73cbb92f2e7887f7674dd7260e8e895b45de24bb..8fc687b9fb29301fc18cc15579ca40b6caef893f 100644 --- a/src/redux/map/map.slice.ts +++ b/src/redux/map/map.slice.ts @@ -1,30 +1,23 @@ import { createSlice } from '@reduxjs/toolkit'; -import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from './map.constants'; +import { MAP_INITIAL_STATE } from './map.constants'; import { closeMapAndSetMainMapActiveReducer, closeMapReducer, + initMapBackgroundsReducer, + initMapPositionReducers, + initMapSizeAndModelIdReducer, + initOpenedMapsReducer, openMapAndSetActiveReducer, setActiveMapReducer, + setMapBackgroundReducer, setMapDataReducer, - initMapPositionReducers, setMapPositionReducer, - initOpenedMapsReducer, - initMapSizeAndModelIdReducer, - initMapBackgroundsReducer, - setMapBackgroundReducer, + varyPositionZoomReducer, } from './map.reducers'; -import { MapState } from './map.types'; - -const initialState: MapState = { - data: MAP_DATA_INITIAL_STATE, - loading: 'idle', - error: { name: '', message: '' }, - openedMaps: OPENED_MAPS_INITIAL_STATE, -}; const mapSlice = createSlice({ name: 'map', - initialState, + initialState: MAP_INITIAL_STATE, reducers: { setMapData: setMapDataReducer, setActiveMap: setActiveMapReducer, @@ -32,6 +25,7 @@ const mapSlice = createSlice({ closeMap: closeMapReducer, closeMapAndSetMainMapActive: closeMapAndSetMainMapActiveReducer, setMapPosition: setMapPositionReducer, + varyPositionZoom: varyPositionZoomReducer, setMapBackground: setMapBackgroundReducer, }, extraReducers: builder => { @@ -50,6 +44,7 @@ export const { closeMapAndSetMainMapActive, setMapPosition, setMapBackground, + varyPositionZoom, } = mapSlice.actions; export default mapSlice.reducer; diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts index 16f5e54210814eca8c851769a049622b2b4057c1..3d15719aa783b34b5796321c166e30c02a2d9eb6 100644 --- a/src/redux/map/map.types.ts +++ b/src/redux/map/map.types.ts @@ -82,6 +82,12 @@ export type GetUpdatedMapDataResult = Pick< export type SetMapPositionDataAction = PayloadAction<Point>; +export type SetLastPositionZoomActionPayload = { + delta: number; +}; + +export type SetLastPositionZoomAction = PayloadAction<SetLastPositionZoomActionPayload>; + export type InitMapDataActionPayload = { data: GetUpdatedMapDataResult | object; openedMaps: OppenedMap[]; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts index 0e87f44e0487a3a2050a598f1bb30f942e3dd9ef..94f18b3a0b177154aae455a7d905055c72411ec4 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.selector.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.selector.ts @@ -42,6 +42,12 @@ export const isOverlayActiveSelector = createSelector( (overlaysId, overlayId) => overlaysId.includes(overlayId), ); +export const isOverlayLoadingSelector = createSelector( + [overlayBioEntitySelector, (_, overlayId: number): number => overlayId], + ({ overlaysId, data }, overlayId) => + overlaysId.includes(overlayId) && data[overlayId] && !Object.keys(data[overlayId]).length, +); + export const activeOverlaysSelector = createSelector( rootSelector, overlaysDataSelector, diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 2ba83189feb03c61dd36bb018a9519b485a81772..8a09ebb08aca68fb46d2bb1a908fceb45c41bd56 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -1,11 +1,11 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { z } from 'zod'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { OverlayBioEntity } from '@/types/models'; -import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; -import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { OverlayBioEntityRender } from '@/types/OLrendering'; -import { parseOverlayBioEntityToOlRenderingFormat } from './overlayBioEntity.utils'; +import { + getValidOverlayBioEntities, + parseOverlayBioEntityToOlRenderingFormat, +} from './overlayBioEntity.utils'; import { apiPath } from '../apiPath'; import { modelsIdsSelector } from '../models/models.selectors'; import type { RootState } from '../store'; @@ -27,10 +27,10 @@ export const getOverlayBioEntity = createAsyncThunk( apiPath.getOverlayBioEntity({ overlayId, modelId }), ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(overlayBioEntitySchema)); + const validOverlayBioEntities = getValidOverlayBioEntities(response.data); - if (isDataValid) { - return parseOverlayBioEntityToOlRenderingFormat(response.data, overlayId); + if (validOverlayBioEntities) { + return parseOverlayBioEntityToOlRenderingFormat(validOverlayBioEntities, overlayId); } return undefined; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts index 5714a9a799a8b56f2d6ae54ccac8055261fa613c..abb5b8fb8b1a2e841f19a268a3c49a4e2350de02 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.test.ts @@ -1,4 +1,6 @@ -import { calculateOvarlaysOrder } from './overlayBioEntity.utils'; +import { overlayBioEntityFixture } from '@/models/fixtures/overlayBioEntityFixture'; +import { OverlayBioEntity } from '@/types/models'; +import { calculateOvarlaysOrder, getValidOverlayBioEntities } from './overlayBioEntity.utils'; describe('calculateOverlaysOrder', () => { const cases = [ @@ -62,3 +64,37 @@ describe('calculateOverlaysOrder', () => { expect(calculateOvarlaysOrder(data)).toStrictEqual(expected); }); }); + +describe('getValidOverlayBioEntities', () => { + it('should return empty array if overlayBioEntities are empty array', () => { + const result = getValidOverlayBioEntities([]); + + expect(result).toEqual([]); + }); + it('should return the same overlayBioEntities if all overlayBioEntities are valid', () => { + const result = getValidOverlayBioEntities(overlayBioEntityFixture); + expect(result).toEqual(overlayBioEntityFixture); + }); + it('should filter properly and return valid overlayBioEntities if overlayBioEntities are mixed array with valid and invalid entities', () => { + const invalidOverlayBioEntities = overlayBioEntityFixture.map(overlayBioEntity => ({ + ...overlayBioEntity, + left: {}, + })) as OverlayBioEntity[]; + + const result = getValidOverlayBioEntities([ + ...overlayBioEntityFixture, + ...invalidOverlayBioEntities, + ]); + expect(result).toEqual(overlayBioEntityFixture); + }); + it('should return empty array if all overlayBioEntities are invalid', () => { + const invalidOverlayBioEntities = overlayBioEntityFixture.map(overlayBioEntity => ({ + ...overlayBioEntity, + right: {}, + })) as OverlayBioEntity[]; + + const result = getValidOverlayBioEntities(invalidOverlayBioEntities); + + expect(result).toEqual([]); + }); +}); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts index e632f31a4fd5cb75b966d33868cddf9f0acf1777..7c92f7a20080e78660bcf391194990c13940451b 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.utils.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.utils.ts @@ -1,6 +1,8 @@ import { ONE } from '@/constants/common'; +import { overlayBioEntitySchema } from '@/models/overlayBioEntitySchema'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { OverlayBioEntity } from '@/types/models'; +import { z } from 'zod'; export const parseOverlayBioEntityToOlRenderingFormat = ( data: OverlayBioEntity[], @@ -66,3 +68,23 @@ export const calculateOvarlaysOrder = ( return overlaysOrder; }; + +const isValidOverlayBioEntity = (overlayBioEntity: OverlayBioEntity): boolean => { + return overlayBioEntitySchema.safeParse(overlayBioEntity).success; +}; + +type OverlayBioEntities = OverlayBioEntity[]; + +export const getValidOverlayBioEntities = ( + unvalidatedOverlayBioEntities: OverlayBioEntities, +): OverlayBioEntities | undefined => { + const filteredValidOverlayBioEntitiesSchema = z + .array(z.any()) + .transform(overlayBioEntities => overlayBioEntities.filter(isValidOverlayBioEntity)); + + const parsedOverlayBioEntities = filteredValidOverlayBioEntitiesSchema.safeParse( + unvalidatedOverlayBioEntities, + ); + + return parsedOverlayBioEntities.success ? parsedOverlayBioEntities.data : undefined; +}; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index aaca59eb7cb7d8ed9178ba958b07ca9cb8b05f9f..a8494c467cedda636c3db0f9951d99325a9bc048 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -15,9 +15,10 @@ 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'; +import { STATISTICS_STATE_INITIAL_MOCK } from '../statistics/statistics.mock'; +import { COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK } from '../compartmentPathways/compartmentPathways.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, @@ -39,4 +40,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { user: USER_INITIAL_STATE_MOCK, legend: LEGEND_INITIAL_STATE_MOCK, statistics: STATISTICS_STATE_INITIAL_MOCK, + compartmentPathways: COMPARTMENT_PATHWAYS_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index b79cf2b2c1ef600a4697cf2027c8ba2348a00477..944288e8e5a21d046914ed7ad655a40bcda014f3 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -25,6 +25,7 @@ import { import legendReducer from './legend/legend.slice'; import { mapListenerMiddleware } from './map/middleware/map.middleware'; import statisticsReducer from './statistics/statistics.slice'; +import compartmentPathwaysReducer from './compartmentPathways/compartmentPathways.slice'; export const reducers = { search: searchReducer, @@ -46,6 +47,7 @@ export const reducers = { overlayBioEntity: overlayBioEntityReducer, legend: legendReducer, statistics: statisticsReducer, + compartmentPathways: compartmentPathwaysReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index a4b4ee63bf1c35658d6e35493e908c7c8689e0bf..0dae32d43b3d4e3848f0dcbce4bbaebc30e6dab6 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -15,6 +15,9 @@ import { CloseIcon } from '@/shared/Icon/Icons/CloseIcon'; import { Pin } from '@/shared/Icon/Icons/Pin'; import type { IconTypes } from '@/types/iconTypes'; +import { LocationIcon } from './Icons/LocationIcon'; +import { MaginfierZoomInIcon } from './Icons/MagnifierZoomIn'; +import { MaginfierZoomOutIcon } from './Icons/MagnifierZoomOut'; export interface IconProps { className?: string; @@ -37,6 +40,9 @@ const icons = { page: PageIcon, plugin: PluginIcon, close: CloseIcon, + location: LocationIcon, + 'magnifier-zoom-in': MaginfierZoomInIcon, + 'magnifier-zoom-out': MaginfierZoomOutIcon, } as const; export const Icon = ({ name, className = '', ...rest }: IconProps): JSX.Element => { diff --git a/src/shared/Icon/Icons/LocationIcon.tsx b/src/shared/Icon/Icons/LocationIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d931d3310ad3ceb023c6b182a88c85211e7abfe5 --- /dev/null +++ b/src/shared/Icon/Icons/LocationIcon.tsx @@ -0,0 +1,19 @@ +interface LocationIconProps { + className?: string; +} + +export const LocationIcon = ({ className }: LocationIconProps): JSX.Element => ( + <svg + width="28" + height="28" + viewBox="0 0 28 28" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <path + d="M13.9998 9.33073C11.4215 9.33073 9.33317 11.4191 9.33317 13.9974C9.33317 16.5757 11.4215 18.6641 13.9998 18.6641C16.5782 18.6641 18.6665 16.5757 18.6665 13.9974C18.6665 11.4191 16.5782 9.33073 13.9998 9.33073ZM24.4298 12.8307C24.1656 10.4649 23.1047 8.25919 21.4214 6.57587C19.738 4.89255 17.5324 3.83166 15.1665 3.5674V1.16406H12.8332V3.5674C10.4673 3.83166 8.26163 4.89255 6.57831 6.57587C4.895 8.25919 3.8341 10.4649 3.56984 12.8307H1.1665V15.1641H3.56984C3.8341 17.5299 4.895 19.7356 6.57831 21.4189C8.26163 23.1022 10.4673 24.1631 12.8332 24.4274V26.8307H15.1665V24.4274C17.5324 24.1631 19.738 23.1022 21.4214 21.4189C23.1047 19.7356 24.1656 17.5299 24.4298 15.1641H26.8332V12.8307H24.4298V12.8307ZM13.9998 22.1641C9.48484 22.1641 5.83317 18.5124 5.83317 13.9974C5.83317 9.4824 9.48484 5.83073 13.9998 5.83073C18.5148 5.83073 22.1665 9.4824 22.1665 13.9974C22.1665 18.5124 18.5148 22.1641 13.9998 22.1641Z" + fill="#8E92A1" + /> + </svg> +); diff --git a/src/shared/Icon/Icons/MagnifierZoomIn.tsx b/src/shared/Icon/Icons/MagnifierZoomIn.tsx new file mode 100644 index 0000000000000000000000000000000000000000..574eba979012da98ae659df45f9b66594b092449 --- /dev/null +++ b/src/shared/Icon/Icons/MagnifierZoomIn.tsx @@ -0,0 +1,27 @@ +interface MaginfierZoomInProps { + className?: string; +} + +export const MaginfierZoomInIcon = ({ className }: MaginfierZoomInProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <g clipPath="url(#clip0_4_190)"> + <path d="M15 12H12V15H9V12H6V9H9V6H12V9H15V12Z" fill="#8E92A1" /> + <path + d="M23.5605 19.9395L19.5 15.879C20.4798 14.2558 20.9985 12.396 21 10.5C21 4.71 16.2885 0 10.5 0C4.7115 0 0 4.71 0 10.5C0 16.29 4.7115 21 10.5 21C12.3962 20.9994 14.2562 20.4808 15.879 19.5L19.9395 23.5605C20.0785 23.7003 20.2437 23.8112 20.4257 23.8869C20.6077 23.9626 20.8029 24.0016 21 24.0016C21.1971 24.0016 21.3923 23.9626 21.5743 23.8869C21.7563 23.8112 21.9215 23.7003 22.0605 23.5605L23.5605 22.0605C23.6999 21.9213 23.8105 21.756 23.886 21.574C23.9615 21.3921 24.0003 21.197 24.0003 21C24.0003 20.803 23.9615 20.6079 23.886 20.426C23.8105 20.244 23.6999 20.0787 23.5605 19.9395V19.9395ZM10.5 18C8.51068 17.9998 6.60291 17.2094 5.19639 15.8026C3.78987 14.3957 2.9998 12.4878 3 10.4985C3.0002 8.50918 3.79064 6.60141 5.19745 5.19489C6.60425 3.78837 8.51218 2.9983 10.5015 2.9985C12.4908 2.9987 14.3986 3.78915 15.8051 5.19595C17.2116 6.60275 18.0017 8.51068 18.0015 10.5C18.0013 12.4893 17.2109 14.3971 15.8041 15.8036C14.3972 17.2101 12.4893 18.0002 10.5 18V18Z" + fill="#8E92A1" + /> + </g> + <defs> + <clipPath id="clip0_4_190"> + <rect width="24" height="24" fill="white" /> + </clipPath> + </defs> + </svg> +); diff --git a/src/shared/Icon/Icons/MagnifierZoomOut.tsx b/src/shared/Icon/Icons/MagnifierZoomOut.tsx new file mode 100644 index 0000000000000000000000000000000000000000..56978cc55c4b43417e38d5c30833bef91ac6e374 --- /dev/null +++ b/src/shared/Icon/Icons/MagnifierZoomOut.tsx @@ -0,0 +1,27 @@ +interface MaginfierZoomOutProps { + className?: string; +} + +export const MaginfierZoomOutIcon = ({ className }: MaginfierZoomOutProps): JSX.Element => ( + <svg + width="24" + height="24" + viewBox="0 0 24 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <g clipPath="url(#clip0_4_194)"> + <path d="M6 9H15V12H6V9Z" fill="#8E92A1" /> + <path + d="M23.5605 19.9395L19.5 15.879C20.4798 14.2558 20.9985 12.396 21 10.5C21 4.71 16.2885 0 10.5 0C4.7115 0 0 4.71 0 10.5C0 16.29 4.7115 21 10.5 21C12.3962 20.9994 14.2562 20.4808 15.879 19.5L19.9395 23.5605C20.0785 23.7003 20.2437 23.8112 20.4257 23.8869C20.6077 23.9626 20.8029 24.0016 21 24.0016C21.1971 24.0016 21.3923 23.9626 21.5743 23.8869C21.7563 23.8112 21.9215 23.7003 22.0605 23.5605L23.5605 22.0605C23.6999 21.9213 23.8105 21.756 23.886 21.574C23.9615 21.3921 24.0003 21.197 24.0003 21C24.0003 20.803 23.9615 20.6079 23.886 20.426C23.8105 20.244 23.6999 20.0787 23.5605 19.9395V19.9395ZM10.5 18C8.51068 17.9998 6.60291 17.2094 5.19639 15.8026C3.78987 14.3957 2.9998 12.4878 3 10.4985C3.0002 8.50918 3.79064 6.60141 5.19745 5.19489C6.60425 3.78837 8.51218 2.9983 10.5015 2.9985C12.4908 2.9987 14.3986 3.78915 15.8051 5.19595C17.2116 6.60275 18.0017 8.51068 18.0015 10.5C18.0013 12.4893 17.2109 14.3971 15.8041 15.8036C14.3972 17.2101 12.4893 18.0002 10.5 18V18Z" + fill="#8E92A1" + /> + </g> + <defs> + <clipPath id="clip0_4_194"> + <rect width="24" height="24" fill="white" /> + </clipPath> + </defs> + </svg> +); diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index c37714cb64db3ae771784cd0978c5858bfdf5536..c125f09c0ea0f1cb06bb15da4037fd4d1f8e56bf 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -13,4 +13,7 @@ export type IconTypes = | 'page' | 'plugin' | 'close' + | 'location' + | 'magnifier-zoom-in' + | 'magnifier-zoom-out' | 'pin'; diff --git a/src/types/map.ts b/src/types/map.ts index 8dedc23f1526474dcc10d639b9e3e76312a86996..81013f9631bf84d468e0a89539747eae480f34c3 100644 --- a/src/types/map.ts +++ b/src/types/map.ts @@ -1,3 +1,5 @@ +import Map from 'ol/Map'; + export interface Point { x: number; y: number; @@ -5,3 +7,5 @@ export interface Point { } export type LatLng = [number, number]; + +export type MapInstance = Map | undefined; diff --git a/src/types/mapLayers.ts b/src/types/mapLayers.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5b7bb6aaff107991b1aec21a9fd956ec00d3ed1 --- /dev/null +++ b/src/types/mapLayers.ts @@ -0,0 +1,15 @@ +/* excluded from map.ts due to depenceny cycle */ + +import { useOlMapOverlaysLayer } from '@/components/Map/MapViewer/utils/config/overlaysLayer/useOlMapOverlaysLayer'; +import { useOlMapPinsLayer } from '@/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer'; +import { useOlMapReactionsLayer } from '@/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer'; +import { useOlMapTileLayer } from '@/components/Map/MapViewer/utils/config/useOlMapTileLayer'; + +export type MapLayers = + | { + tileLayer: ReturnType<typeof useOlMapTileLayer>; + reactionsLayer: ReturnType<typeof useOlMapReactionsLayer>; + pinsLayer: ReturnType<typeof useOlMapPinsLayer>; + overlaysLayer: ReturnType<typeof useOlMapOverlaysLayer>; + } + | undefined; diff --git a/src/types/models.ts b/src/types/models.ts index 8f4582e557ce84606857b048b60bb9d7518ca18a..23d350f7ec57208a8053061a8e61b966a441893e 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -3,6 +3,10 @@ import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { colorSchema } from '@/models/colorSchema'; +import { + compartmentPathwayDetailsSchema, + compartmentPathwaySchema, +} from '@/models/compartmentPathwaySchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; import { configurationSchema } from '@/models/configurationSchema'; import { disease } from '@/models/disease'; @@ -65,3 +69,5 @@ export type UploadedOverlayFileContent = z.infer<typeof uploadedOverlayFileConte export type CreatedOverlay = z.infer<typeof createdOverlaySchema>; export type Color = z.infer<typeof colorSchema>; export type Statistics = z.infer<typeof statisticsSchema>; +export type CompartmentPathway = z.infer<typeof compartmentPathwaySchema>; +export type CompartmentPathwayDetails = z.infer<typeof compartmentPathwayDetailsSchema>; diff --git a/src/utils/context/mapInstanceContext.tsx b/src/utils/context/mapInstanceContext.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1c0982d8e55a4bcbafdf1416b473b43afcf9f9b9 --- /dev/null +++ b/src/utils/context/mapInstanceContext.tsx @@ -0,0 +1,40 @@ +import { MapInstance } from '@/types/map'; +import { Dispatch, SetStateAction, createContext, useContext, useMemo, useState } from 'react'; + +export interface MapInstanceContext { + mapInstance: MapInstance; + setMapInstance: Dispatch<SetStateAction<MapInstance>>; +} + +export const MapInstanceContext = createContext<MapInstanceContext>({ + mapInstance: undefined, + setMapInstance: () => {}, +}); + +export const useMapInstance = (): MapInstanceContext => useContext(MapInstanceContext); + +export interface MapInstanceProviderProps { + children: React.ReactNode; + initialValue?: MapInstanceContext; +} + +export const MapInstanceProvider = ({ + children, + initialValue, +}: MapInstanceProviderProps): JSX.Element => { + const [mapInstance, setMapInstance] = useState<MapInstance>(initialValue?.mapInstance); + + const mapInstanceContextValue = useMemo( + () => ({ + mapInstance, + setMapInstance, + }), + [mapInstance], + ); + + return ( + <MapInstanceContext.Provider value={mapInstanceContextValue}> + {children} + </MapInstanceContext.Provider> + ); +}; diff --git a/src/utils/map/useSetBounds.test.ts b/src/utils/map/useSetBounds.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e00469d4c7df59c3b33135a5c6da8182b1e0db5 --- /dev/null +++ b/src/utils/map/useSetBounds.test.ts @@ -0,0 +1,109 @@ +/* eslint-disable no-magic-numbers */ +import { ONE } from '@/constants/common'; +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { renderHook } from '@testing-library/react'; +import { Map } from 'ol'; +import { Coordinate } from 'ol/coordinate'; +import { getReduxWrapperWithStore } from '../testing/getReduxWrapperWithStore'; +import { useSetBounds } from './useSetBounds'; + +describe('useSetBounds - hook', () => { + const coordinates: Coordinate[] = [ + [128, 128], + [192, 192], + ]; + + describe('when mapInstance is not set', () => { + it('setBounds should return void', () => { + const { Wrapper } = getReduxWrapperWithStore( + { + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + }, + { + mapInstanceContextValue: { + mapInstance: undefined, + setMapInstance: () => {}, + }, + }, + ); + + const { + result: { current: setBounds }, + } = renderHook(() => useSetBounds(), { wrapper: Wrapper }); + + expect(setBounds(coordinates)).toBe(undefined); + }); + }); + + describe('when mapInstance is set', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const view = mapInstance.getView(); + const getViewSpy = jest.spyOn(mapInstance, 'getView'); + const fitSpy = jest.spyOn(view, 'fit'); + + it('setBounds should set return void', () => { + const { Wrapper } = getReduxWrapperWithStore( + { + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + }, + { + mapInstanceContextValue: { + mapInstance, + setMapInstance: () => {}, + }, + }, + ); + + const { + result: { current: setBounds }, + } = renderHook(() => useSetBounds(), { wrapper: Wrapper }); + + expect(setBounds(coordinates)).toStrictEqual({ + extent: [128, 128, 192, 192], + options: { maxZoom: 1, padding: [128, 128, 128, 128], size: undefined }, + // size is real size on the screen, so it'll be undefined in the jest + }); + expect(getViewSpy).toHaveBeenCalledTimes(ONE); + expect(fitSpy).toHaveBeenCalledWith([128, 128, 192, 192], { + maxZoom: 1, + padding: [128, 128, 128, 128], + size: undefined, + }); + }); + }); +}); diff --git a/src/utils/map/useSetBounds.ts b/src/utils/map/useSetBounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..29ee4727a75618bfd9cff8ba17e0ec5d727ecf1e --- /dev/null +++ b/src/utils/map/useSetBounds.ts @@ -0,0 +1,48 @@ +import { HALF } from '@/constants/dividers'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { MapInstance } from '@/types/map'; +import { FitOptions } from 'ol/View'; +import { Coordinate } from 'ol/coordinate'; +import { Extent, boundingExtent } from 'ol/extent'; +import { useSelector } from 'react-redux'; +import { useMapInstance } from '../context/mapInstanceContext'; + +export interface SetBoundsResult { + extent: Extent; + options: FitOptions; +} + +type SetBounds = (coordinates: Coordinate[]) => SetBoundsResult | undefined; + +const BOUNDS_PADDING = DEFAULT_TILE_SIZE / HALF; +const DEFAULT_PADDING = [BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING]; + +/* prettier-ignore */ +export const handleSetBounds = + (mapInstance: MapInstance, maxZoom: number, coordinates: Coordinate[]): SetBoundsResult | undefined => { + if (!mapInstance) { + return undefined; + } + + const extent = boundingExtent(coordinates); + + const options: FitOptions = { + size: mapInstance.getSize(), + padding: DEFAULT_PADDING, + maxZoom, + }; + + mapInstance.getView().fit(extent, options); + return { extent, options }; + }; + +export const useSetBounds = (): SetBounds => { + const { mapInstance } = useMapInstance(); + const { maxZoom } = useSelector(mapDataSizeSelector); + + const setBounds = (coordinates: Coordinate[]): SetBoundsResult | undefined => + handleSetBounds(mapInstance, maxZoom, coordinates); + + return setBounds; +}; diff --git a/src/utils/point/isPointValid.test.ts b/src/utils/point/isPointValid.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa5a1809cbc1a79bbc26ca9d85d95f155fa1f9d4 --- /dev/null +++ b/src/utils/point/isPointValid.test.ts @@ -0,0 +1,21 @@ +/* eslint-disable no-magic-numbers */ +import { Point } from '@/types/map'; +import { isPointValid } from './isPointValid'; + +describe('isPointValid - util', () => { + const cases = [ + [true, 1, 1, undefined], // x, y valid, z undefined + [true, 1, 1, 1], // x, y, z valid + [false, 1, undefined, 1], // y undefined + [false, undefined, 1, 1], // x undefined + [false, undefined, undefined, 1], // x, y undefined + [false, 1, -1, 1], // y negative + [false, -1, 1, 1], // x negative + [false, -1, -1, 1], // x, y negative + [false, -1, -1, -1], // x, y, z negative + ]; + + it.each(cases)('should return %s for point x=%s, y=%s, z=%s', (result, x, y, z) => { + expect(isPointValid({ x, y, z } as Point)).toBe(result); + }); +}); diff --git a/src/utils/point/isPointValid.ts b/src/utils/point/isPointValid.ts new file mode 100644 index 0000000000000000000000000000000000000000..f3db3d22c569c9f254a039629617b3e8889e837a --- /dev/null +++ b/src/utils/point/isPointValid.ts @@ -0,0 +1,7 @@ +import { mapPointSchema } from '@/models/mapPoint'; +import { Point } from '@/types/map'; + +export const isPointValid = (point: Point): boolean => { + const { success } = mapPointSchema.safeParse(point); + return success; +}; diff --git a/src/utils/testing/getReduxWrapperWithStore.tsx b/src/utils/testing/getReduxWrapperWithStore.tsx index d1f0c3dfe1529bbe9cb64a9d09ec1f16ac219951..18c3beb8cf9e415abdd6e5820e9360a981e1b70e 100644 --- a/src/utils/testing/getReduxWrapperWithStore.tsx +++ b/src/utils/testing/getReduxWrapperWithStore.tsx @@ -1,20 +1,29 @@ import { RootState, StoreType, middlewares, reducers } from '@/redux/store'; import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; +import { MapInstanceContext, MapInstanceProvider } from '../context/mapInstanceContext'; interface WrapperProps { children: React.ReactNode; } export type InitialStoreState = Partial<RootState>; +export type ReduxComponentWrapper = ({ children }: WrapperProps) => JSX.Element; +export interface Options { + mapInstanceContextValue?: MapInstanceContext; +} -export type GetReduxWrapperUsingSliceReducer = (initialState?: InitialStoreState) => { - Wrapper: ({ children }: WrapperProps) => JSX.Element; +export type GetReduxWrapperUsingSliceReducer = ( + initialState?: InitialStoreState, + options?: Options, +) => { + Wrapper: ReduxComponentWrapper; store: StoreType; }; export const getReduxWrapperWithStore: GetReduxWrapperUsingSliceReducer = ( preloadedState: InitialStoreState = {}, + options: Options = {}, ) => { const testStore = configureStore({ reducer: reducers, @@ -23,7 +32,9 @@ export const getReduxWrapperWithStore: GetReduxWrapperUsingSliceReducer = ( }); const Wrapper = ({ children }: WrapperProps): JSX.Element => ( - <Provider store={testStore}>{children}</Provider> + <MapInstanceProvider initialValue={options.mapInstanceContextValue}> + <Provider store={testStore}>{children}</Provider> + </MapInstanceProvider> ); return { Wrapper, store: testStore }; diff --git a/tailwind.config.ts b/tailwind.config.ts index 75ce9dc54480bc8bb8e9b0da255d2e772bec2ac2..80376a72e62b6e23b6a7a7d560dee0650e4d5d92 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -36,6 +36,9 @@ const config: Config = { boxShadow: { primary: '4px 8px 32px 0px rgba(0, 0, 0, 0.12)', }, + dropShadow: { + primary: '0px 4px 24px rgba(0, 0, 0, 0.08)', + }, }, fontFamily: { manrope: ['var(--font-manrope)'],