diff --git a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..051bc4d73ffb780eceff010f71b235f579a9060c --- /dev/null +++ b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx @@ -0,0 +1,135 @@ +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { initialMapDataFixture, openedMapsThreeSubmapsFixture } from '@/redux/map/map.fixtures'; +import { act, render, screen, within } from '@testing-library/react'; +import { MapNavigation } from './MapNavigation.component'; + +const MAIN_MAP_ID = 5053; +const HISTAMINE_MAP_ID = 5052; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <MapNavigation /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('MapNavigation - component', () => { + it('should render list of currently opened maps, main map should not have close button', async () => { + renderComponent({ + map: { + data: { ...initialMapDataFixture }, + loading: 'succeeded', + error: { message: '', name: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + }); + + const mainMapButton = screen.getByRole('button', { name: 'Main map' }); + expect(mainMapButton).toBeInTheDocument(); + + const histamineMapButton = screen.getByRole('button', { name: 'Histamine signaling' }); + expect(histamineMapButton).toBeInTheDocument(); + + const prknMapButton = screen.getByRole('button', { name: 'PRKN substrates' }); + expect(prknMapButton).toBeInTheDocument(); + }); + + it('all maps should have close button expect main map', async () => { + renderComponent({ + map: { + data: { ...initialMapDataFixture }, + loading: 'succeeded', + error: { message: '', name: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + }); + + const mainMapButton = screen.getByRole('button', { name: 'Main map' }); + const mainMapCloseButton = await within(mainMapButton).queryByTestId('close-icon'); + expect(mainMapCloseButton).not.toBeInTheDocument(); + + const histamineMapButton = screen.getByRole('button', { name: 'Histamine signaling' }); + const histamineMapCloseButton = await within(histamineMapButton).getByTestId('close-icon'); + expect(histamineMapCloseButton).toBeInTheDocument(); + + const prknMapButton = screen.getByRole('button', { name: 'PRKN substrates' }); + const prknMapCloseButton = await within(prknMapButton).getByTestId('close-icon'); + expect(prknMapCloseButton).toBeInTheDocument(); + }); + + it('should close map tab when clicking on close button while', async () => { + const { store } = renderComponent({ + map: { + data: { + ...initialMapDataFixture, + modelId: MAIN_MAP_ID, + }, + loading: 'succeeded', + error: { message: '', name: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + }); + + const histamineMapButton = screen.getByRole('button', { name: 'Histamine signaling' }); + const histamineMapCloseButton = await within(histamineMapButton).getByTestId('close-icon'); + await act(() => { + histamineMapCloseButton.click(); + }); + + const { + map: { + data: { modelId }, + openedMaps, + }, + } = store.getState(); + + const isHistamineMapOpened = openedMaps.some(map => map.modelName === 'Histamine signaling'); + + expect(isHistamineMapOpened).toBe(false); + expect(modelId).toBe(MAIN_MAP_ID); + }); + + it('should close map and open main map if closed currently selected map', async () => { + const { store } = renderComponent({ + map: { + data: { + ...initialMapDataFixture, + modelId: HISTAMINE_MAP_ID, + }, + openedMaps: openedMapsThreeSubmapsFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + }); + + const histamineMapButton = screen.getByRole('button', { name: 'Histamine signaling' }); + const histamineMapCloseButton = await within(histamineMapButton).getByTestId('close-icon'); + await act(() => { + histamineMapCloseButton.click(); + }); + + const { + map: { + data: { modelId }, + openedMaps, + }, + } = store.getState(); + + const isHistamineMapOpened = openedMaps.some(map => map.modelName === 'Histamine signaling'); + + expect(isHistamineMapOpened).toBe(false); + expect(modelId).toBe(MAIN_MAP_ID); + }); +}); diff --git a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx index 9a2e9c9b58ba1d0ee3fba92c5dae6cf880027025..0bc4e1d80759f06d92e40b57f2cd8bded63ac632 100644 --- a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx +++ b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx @@ -1,10 +1,56 @@ +import { MouseEvent } from 'react'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapModelIdSelector, mapOpenedMapsSelector } from '@/redux/map/map.selectors'; +import { closeMap, closeMapAndSetMainMapActive, setActiveMap } from '@/redux/map/map.slice'; +import { OppenedMap } from '@/redux/map/map.types'; import { Button } from '@/shared/Button'; +import { Icon } from '@/shared/Icon'; +import { MAIN_MAP } from '@/redux/map/map.constants'; +import { twMerge } from 'tailwind-merge'; -export const MapNavigation = (): JSX.Element => ( - <div className="h-10 w-full bg-white-pearl shadow-map-navigation-bar"> - {/* TODO: Button is temporary until we add tabs */} - <Button className="h-10 bg-[#EBF4FF]" variantStyles="secondary"> - Main map - </Button> - </div> -); +export const MapNavigation = (): JSX.Element => { + const dispatch = useAppDispatch(); + const openedMaps = useAppSelector(mapOpenedMapsSelector); + const currentModelId = useAppSelector(mapModelIdSelector); + + const isActive = (modelId: number): boolean => currentModelId === modelId; + const isNotMainMap = (modelName: string): boolean => modelName !== MAIN_MAP; + + const onCloseSubmap = (event: MouseEvent<HTMLDivElement>, map: OppenedMap): void => { + event.stopPropagation(); + if (isActive(map.modelId)) { + dispatch(closeMapAndSetMainMapActive({ modelId: map.modelId })); + } else { + dispatch(closeMap({ modelId: map.modelId })); + } + }; + + const onSubmapTabClick = (map: OppenedMap): void => { + dispatch(setActiveMap(map)); + }; + + return ( + <div className="flex h-10 w-full flex-row flex-nowrap justify-start overflow-y-auto bg-white-pearl shadow-map-navigation-bar"> + {openedMaps.map(map => ( + <Button + key={map.modelId} + className={twMerge( + 'h-10 whitespace-nowrap ', + isActive(map.modelId) ? 'bg-[#EBF4FF]' : 'font-normal', + )} + variantStyles={isActive(map.modelId) ? 'secondary' : 'ghost'} + onClick={(): void => onSubmapTabClick(map)} + > + {map.modelName} + {isNotMainMap(map.modelName) && ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + <div onClick={(event): void => onCloseSubmap(event, map)} data-testid="close-icon"> + <Icon name="close" className="ml-3 h-5 w-5 fill-font-400" /> + </div> + )} + </Button> + ))} + </div> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx index ff2cc2dbf7fc8816c358cf5845a45fc269fb0840..ddba126b8e91e1790232b8a8d88619d5b5ca817d 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx @@ -33,7 +33,7 @@ describe('DrugsAccordion - component', () => { renderComponent({ drugs: { data: drugsFixture, loading: 'succeeded', error: { name: '', message: '' } }, }); - expect(screen.getByText('Drugs (2)')).toBeInTheDocument(); + expect(screen.getByText('Drugs (4)')).toBeInTheDocument(); }); it('should display loading indicator while waiting for drug search response', () => { renderComponent({ diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2f3c00b7a7e10e59098b8c5ea0d950de93fca5d2 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.test.tsx @@ -0,0 +1,63 @@ +/* eslint-disable no-magic-numbers */ +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { AccordionsDetails } from './AccordionsDetails.component'; + +const PINS_LIST = drugsFixture.map(drug => ({ + id: drug.id, + name: drug.name, + data: drug, +})); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <AccordionsDetails pinsList={PINS_LIST} /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('AccordionsDetails - component', () => { + it('should display name of drug', () => { + renderComponent(); + + const drugName = drugsFixture[0].name; + + expect(screen.getByText(drugName, { exact: false })).toBeInTheDocument(); + }); + it('should display description of drug', () => { + renderComponent(); + + const drugDescription = drugsFixture[0].description; + + expect(screen.getByText(drugDescription, { exact: false })).toBeInTheDocument(); + }); + it('should display synonyms of drug', () => { + renderComponent(); + + const firstDrugSynonym = drugsFixture[0].synonyms[0]; + const secondDrugSynonym = drugsFixture[0].synonyms[1]; + + expect(screen.getByText(firstDrugSynonym, { exact: false })).toBeInTheDocument(); + expect(screen.getByText(secondDrugSynonym, { exact: false })).toBeInTheDocument(); + }); + it('should display additional info about drug', () => { + renderComponent(); + + const drugAdditionalInfo = drugsFixture[0].bloodBrainBarrier; + + expect(screen.getByText(drugAdditionalInfo, { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9b4bad368a4a0efe4601c55db57a717a1b2490c9 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.component.tsx @@ -0,0 +1,49 @@ +import { + Accordion, + AccordionItem, + AccordionItemButton, + AccordionItemHeading, + AccordionItemPanel, +} from '@/shared/Accordion'; +import { PinItem } from '../PinsList/PinsList.types'; +import { + getAdditionalInfo, + getEntityDescriptions, + getEntityNames, + getEntitySynonyms, +} from './AccordionsDetails.utils'; + +interface AccordionsDetailsProps { + pinsList: PinItem[]; +} + +export const AccordionsDetails = ({ pinsList }: AccordionsDetailsProps): JSX.Element => { + return ( + <> + <Accordion allowZeroExpanded className="px-6"> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>Drug</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel>{getEntityNames(pinsList)}</AccordionItemPanel> + </AccordionItem> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>Description</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel>{getEntityDescriptions(pinsList)}</AccordionItemPanel> + </AccordionItem> + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton>Synonyms</AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel>{getEntitySynonyms(pinsList)}</AccordionItemPanel> + </AccordionItem> + </Accordion> + <div className="flex justify-between px-6 py-4 text-sm font-bold"> + <div>Blood brain barrier</div> + <div>{getAdditionalInfo(pinsList)}</div> + </div> + </> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.utils.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c59527084224d4b941aeb94a094b0739179be32d --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/AccordionsDetails/AccordionsDetails.utils.tsx @@ -0,0 +1,47 @@ +import { PinItem } from '../PinsList/PinsList.types'; + +export const getEntityNames = (pinsList: PinItem[]): string => { + let name = ''; + + pinsList.forEach(element => { + name += element.data.name; + }); + + return name; +}; + +export const getEntityDescriptions = (pinsList: PinItem[]): string => { + let description = ''; + + pinsList.forEach(element => { + if ('description' in element.data) { + description += element.data.description; + } + }); + + return description; +}; + +export const getEntitySynonyms = (pinsList: PinItem[]): string => { + let synonyms = ''; + + pinsList.forEach(element => { + if ('synonyms' in element.data) { + synonyms += element.data.synonyms.join(', '); + } + }); + + return synonyms; +}; + +export const getAdditionalInfo = (pinsList: PinItem[]): string => { + let additionalDetails = ''; + + pinsList.forEach(element => { + if ('bloodBrainBarrier' in element.data) { + additionalDetails += element.data.bloodBrainBarrier; + } + }); + + return additionalDetails; +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx index 44bb49c85cfbf0e96d4099d9644986d6fb316943..6aeb854e74c1c8af9a129066d698ddc68e669a04 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx @@ -1,4 +1,5 @@ import { assertNever } from '@/utils/assertNever'; +import { AccordionsDetails } from '../AccordionsDetails/AccordionsDetails.component'; import { PinItem, PinType } from './PinsList.types'; import { MirnaPinsListItem } from './PinsListItem'; @@ -9,18 +10,29 @@ interface PinsListProps { export const PinsList = ({ pinsList, type }: PinsListProps): JSX.Element => { switch (type) { + case 'drugs': + return ( + <div className="h-[calc(100vh-198px)] overflow-auto"> + <AccordionsDetails pinsList={pinsList} /> + <ul className="px-6 py-2"> + {pinsList.map(result => { + return result.data.targets.map(pin => ( + <MirnaPinsListItem key={pin.name} name={pin.name} type={type} pin={pin} /> + )); + })} + </ul> + </div> + ); case 'bioEntity': return <div />; case 'chemicals': return <div />; - case 'drugs': - return <div />; case 'mirna': return ( <ul className="h-[calc(100vh-198px)] overflow-auto px-6 py-2"> {pinsList.map(result => { return result.data.targets.map(pin => ( - <MirnaPinsListItem key={pin.name} name={pin.name} pin={pin} /> + <MirnaPinsListItem key={pin.name} name={pin.name} type={type} pin={pin} /> )); })} </ul> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/MirnaPinsListItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/MirnaPinsListItem.component.tsx index 2bb701d9a8b02ee9c365b18c69df00e6706afbc3..ebd97b20c8f00a7d3b7f28de1cff7af0c6cf53c6 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/MirnaPinsListItem.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/MirnaPinsListItem.component.tsx @@ -2,19 +2,22 @@ import { twMerge } from 'tailwind-merge'; import { Icon } from '@/shared/Icon'; import { MirnaItems } from '@/types/models'; import { getPinColor } from './PinsListItem.component.utils'; +import { PinType } from '../PinsList.types'; interface MirnaPinsListItemProps { name: string; + type: PinType; pin: MirnaItems; } -export const MirnaPinsListItem = ({ name, pin }: MirnaPinsListItemProps): JSX.Element => { +export const MirnaPinsListItem = ({ name, type, pin }: MirnaPinsListItemProps): JSX.Element => { return ( - <div className="mb-4 flex w-full flex-col gap-2 rounded-lg border-[1px] border-solid border-greyscale-500 p-4"> - <div className="flex w-full flex-row gap-2"> - <Icon name="pin" className={twMerge('mr-2 shrink-0', getPinColor('mirna'))} /> - <p className="min-w-fit">Full name: </p> - <p className="w-full font-bold">{name}</p> + <div className="mb-4 flex w-full flex-col gap-3 rounded-lg border-[1px] border-solid border-greyscale-500 p-4"> + <div className="flex w-full flex-row items-center gap-2"> + <Icon name="pin" className={twMerge('mr-2 shrink-0', getPinColor(type))} /> + <p> + Full name: <span className="w-full font-bold">{name}</span> + </p> </div> <ul className="leading-6"> <div className="font-bold">Elements:</div> diff --git a/src/components/Map/Drawer/SubmapsDrawer/SubmapItem/SubmapItem.component.tsx b/src/components/Map/Drawer/SubmapsDrawer/SubmapItem/SubmapItem.component.tsx index a143386895dc17b677d768ac5ee16ef8ac51e6f7..e056671a444f93c2d528489ab00c9b5074e8a214 100644 --- a/src/components/Map/Drawer/SubmapsDrawer/SubmapItem/SubmapItem.component.tsx +++ b/src/components/Map/Drawer/SubmapsDrawer/SubmapItem/SubmapItem.component.tsx @@ -3,9 +3,10 @@ import { IconButton } from '@/shared/IconButton'; interface SubmapItemProps { modelName: string; + onOpenClick: () => void; } -export const SubmpamItem = ({ modelName }: SubmapItemProps): JSX.Element => ( +export const SubmpamItem = ({ modelName, onOpenClick }: SubmapItemProps): JSX.Element => ( <div className="flex flex-row flex-nowrap items-center justify-between border-b py-6"> {modelName} <div className="flex flex-row flex-nowrap items-center"> @@ -16,6 +17,8 @@ export const SubmpamItem = ({ modelName }: SubmapItemProps): JSX.Element => ( icon="chevron-right" className="h-6 w-6 bg-white-pearl" classNameIcon="fill-font-500 h-6 w-6" + data-testid={`${modelName}-open`} + onClick={onOpenClick} /> </div> </div> diff --git a/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.test.tsx b/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.test.tsx index f37efc2cedc5f130a0c3647433411cb167dcc778..42754f4eb9456f4a661c9879116dacbe75b1e705 100644 --- a/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.test.tsx +++ b/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.test.tsx @@ -2,12 +2,20 @@ import { InitialStoreState, getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import { StoreType } from '@/redux/store'; import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; import { openedDrawerSubmapsFixture } from '@/redux/drawer/drawerFixture'; +import { + initialMapDataFixture, + openedMapsInitialValueFixture, + openedMapsThreeSubmapsFixture, +} from '@/redux/map/map.fixtures'; import { SubmapsDrawer } from './SubmapsDrawer'; +const MAIN_MAP_ID = 5053; +const HISTAMINE_MAP_ID = 5052; + const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); @@ -48,4 +56,78 @@ describe('SubmapsDrawer - component', () => { expect(isOpen).toBe(false); }); + it("should open submap and set it to active if it's not already opened", async () => { + const { store } = renderComponent({ + models: { data: MODELS_MOCK_SHORT, loading: 'succeeded', error: { name: '', message: '' } }, + map: { + data: initialMapDataFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsInitialValueFixture, + }, + }); + + const { + data: { modelId }, + openedMaps, + } = store.getState().map; + + // eslint-disable-next-line no-magic-numbers + expect(modelId).toBe(0); + expect(openedMaps).not.toContainEqual({ + modelId: HISTAMINE_MAP_ID, + modelName: 'Histamine signaling', + lastPosition: { x: 0, y: 0, z: 0 }, + }); + + const openHistamineMapButton = screen.getByTestId('Histamine signaling-open'); + await act(() => { + openHistamineMapButton.click(); + }); + + const { + data: { modelId: newModelId }, + openedMaps: newOpenedMaps, + } = store.getState().map; + + expect(newOpenedMaps).toContainEqual({ + modelId: HISTAMINE_MAP_ID, + modelName: 'Histamine signaling', + lastPosition: { x: 0, y: 0, z: 0 }, + }); + + expect(newModelId).toBe(HISTAMINE_MAP_ID); + }); + it("should set map active if it's already opened", async () => { + const { store } = renderComponent({ + models: { data: MODELS_MOCK_SHORT, loading: 'succeeded', error: { name: '', message: '' } }, + map: { + data: { + ...initialMapDataFixture, + modelId: MAIN_MAP_ID, + }, + openedMaps: openedMapsThreeSubmapsFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }); + + const openHistamineMapButton = screen.getByTestId('Histamine signaling-open'); + await act(() => { + openHistamineMapButton.click(); + }); + + const { + map: { + data: { modelId }, + openedMaps, + }, + } = store.getState(); + + const histamineMap = openedMaps.filter(map => map.modelName === 'Histamine signaling'); + + // eslint-disable-next-line no-magic-numbers + expect(histamineMap.length).toBe(1); + expect(modelId).toBe(HISTAMINE_MAP_ID); + }); }); diff --git a/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.tsx b/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.tsx index 35af92d1994ef36b6de85fe734667d7344f88b75..a3b4bc1bc9e63c724abbe954915846275793bbc0 100644 --- a/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.tsx +++ b/src/components/Map/Drawer/SubmapsDrawer/SubmapsDrawer.tsx @@ -1,17 +1,38 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { modelsDataSelector } from '@/redux/models/models.selectors'; import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openMapAndSetActive, setActiveMap } from '@/redux/map/map.slice'; +import { MapModel } from '@/types/models'; +import { mapOpenedMapsSelector } from '@/redux/map/map.selectors'; import { SubmpamItem } from './SubmapItem/SubmapItem.component'; export const SubmapsDrawer = (): JSX.Element => { const models = useAppSelector(modelsDataSelector); + const openedMaps = useAppSelector(mapOpenedMapsSelector); + const dispatch = useAppDispatch(); + + const isMapAlreadyOpened = (modelId: number): boolean => + openedMaps.some(map => map.modelId === modelId); + + const onSubmapOpenClick = (model: MapModel): void => { + if (isMapAlreadyOpened(model.idObject)) { + dispatch(setActiveMap({ modelId: model.idObject })); + } else { + dispatch(openMapAndSetActive({ modelId: model.idObject, modelName: model.name })); + } + }; return ( <div data-testid="submap-drawer" className="h-full max-h-full"> <DrawerHeading title="Submaps" /> <ul className="h-[calc(100%-93px)] max-h-[calc(100%-93px)] overflow-y-auto px-6"> {models.map(model => ( - <SubmpamItem key={model.idObject} modelName={model.name} /> + <SubmpamItem + key={model.idObject} + modelName={model.name} + onOpenClick={(): void => onSubmapOpenClick(model)} + /> ))} </ul> </div> diff --git a/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts b/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts new file mode 100644 index 0000000000000000000000000000000000000000..163511b5a1343a680bf85dd7824d0a5cfe415a83 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/getCanvasIcon.ts @@ -0,0 +1,64 @@ +import { PIN_PATH2D, PIN_SIZE } from '@/constants/canvas'; +import { HALF, QUARTER, THIRD } from '@/constants/dividers'; +import { DEFAULT_FONT_FAMILY } from '@/constants/font'; +import { Point } from '@/types/map'; +import { getCanvas } from '@/utils/canvas/getCanvas'; +import { getFontSizeToFit } from '@/utils/canvas/getFontSizeToFit'; + +const SMALL_TEXT_VALUE = 1; +const MEDIUM_TEXT_VALUE = 10; + +interface Args { + color: string; + value: number; +} + +const drawPinOnCanvas = ({ color }: Args, ctx: CanvasRenderingContext2D): void => { + const path = new Path2D(PIN_PATH2D); + ctx.fillStyle = color; + ctx.fill(path); +}; + +const getTextWidth = (value: number): number => { + switch (true) { + case value === SMALL_TEXT_VALUE: + return PIN_SIZE.width / QUARTER; + case value < MEDIUM_TEXT_VALUE: + return PIN_SIZE.width / THIRD; + default: + return PIN_SIZE.width / HALF; + } +}; + +const getTextPosition = (textWidth: number, textHeight: number): Point => ({ + x: (PIN_SIZE.width - textWidth) / HALF, + y: (PIN_SIZE.height - textHeight) / QUARTER, +}); + +const drawNumberOnCanvas = ({ value }: Args, ctx: CanvasRenderingContext2D): void => { + const text = `${value}`; + const textMetrics = ctx.measureText(text); + + const textWidth = getTextWidth(value); + const fontSize = getFontSizeToFit(ctx, text, DEFAULT_FONT_FAMILY, textWidth); + const textHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent; + const { x, y } = getTextPosition(textWidth, textHeight); + + ctx.fillStyle = 'white'; + ctx.textBaseline = 'top'; + ctx.font = `${fontSize}px ${DEFAULT_FONT_FAMILY}`; + ctx.fillText(text, x, y); +}; + +export const getCanvasIcon = (args: Args): HTMLCanvasElement => { + const canvas = getCanvas(PIN_SIZE); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return canvas; + } + + drawPinOnCanvas(args, ctx); + drawNumberOnCanvas(args, ctx); + + return canvas; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts index f2c512a5cd10a2b0bf78f73bd6b9e62c44906f6b..1ed0fcc6485371ebd962bc179af3a3b8e80e90a2 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-magic-numbers */ -import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; import mapSlice from '@/redux/map/map.slice'; import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; @@ -74,6 +74,7 @@ describe('useOlMapLayers - util', () => { name: '', message: '', }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, }); const dummyElement = document.createElement('div'); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index 67d71d50060f40018ec5d515b0608a8b98bb4f92..350a1aec442b4dae172af9e58c930325d4e5b207 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -1,57 +1,24 @@ /* eslint-disable no-magic-numbers */ -import { OPTIONS } from '@/constants/map'; -import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors'; -import { mapDataSizeSelector } from '@/redux/map/map.selectors'; -import { projectDataSelector } from '@/redux/project/project.selectors'; -import TileLayer from 'ol/layer/Tile'; -import { XYZ } from 'ol/source'; -import { useEffect, useMemo } from 'react'; -import { useSelector } from 'react-redux'; +import { useEffect } from 'react'; import { MapConfig, MapInstance } from '../../MapViewer.types'; -import { getMapTileUrl } from './getMapTileUrl'; +import { useOlMapPinsLayer } from './useOlMapPinsLayer'; +import { useOlMapTileLayer } from './useOlMapTileLayer'; interface UseOlMapLayersInput { mapInstance: MapInstance; } export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { - const mapSize = useSelector(mapDataSizeSelector); - const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector); - const project = useSelector(projectDataSelector); - - const sourceUrl = useMemo( - () => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }), - [project?.directory, currentBackgroundImagePath], - ); - - const source = useMemo( - () => - new XYZ({ - url: sourceUrl, - maxZoom: mapSize.maxZoom, - minZoom: mapSize.minZoom, - tileSize: mapSize.tileSize, - wrapX: OPTIONS.wrapXInTileLayer, - }), - [sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize], - ); - - const tileLayer = useMemo( - (): TileLayer<XYZ> => - new TileLayer({ - visible: true, - source, - }), - [source], - ); + const tileLayer = useOlMapTileLayer(); + const pinsLayer = useOlMapPinsLayer(); useEffect(() => { if (!mapInstance) { return; } - mapInstance.setLayers([tileLayer]); - }, [tileLayer, mapInstance]); + mapInstance.setLayers([tileLayer, pinsLayer]); + }, [tileLayer, pinsLayer, mapInstance]); return [tileLayer]; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..701a25c1d4db78655b67ee74a7ec08bd45b1bbda --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +import { allBioEntitesSelectorOfCurrentMap } from '@/redux/bioEntity/bioEntity.selectors'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { Point } from 'ol/geom'; +import BaseLayer from 'ol/layer/Base'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Icon from 'ol/style/Icon'; +import Style from 'ol/style/Style'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getCanvasIcon } from './getCanvasIcon'; + +export const useOlMapPinsLayer = (): BaseLayer => { + const pointToProjection = usePointToProjection(); + const bioEntites = useSelector(allBioEntitesSelectorOfCurrentMap); + + const bioEntityFeatures = useMemo( + () => + bioEntites.map(({ bioEntity: { x, y, name, width, height } }, index) => { + const point = { + x: x + width / 2, + y: y + height / 2, + }; + + const feature = new Feature({ + geometry: new Point(pointToProjection(point)), + name, + }); + + const style = new Style({ + image: new Icon({ + displacement: [0, 32], + anchorXUnits: 'fraction', + anchorYUnits: 'pixels', + img: getCanvasIcon({ + color: '#106AD7', + value: index + 1, + }), + }), + }); + + feature.setStyle(style); + + return feature; + }), + [bioEntites, pointToProjection], + ); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features: [...bioEntityFeatures], + }); + }, [bioEntityFeatures]); + + const pinsLayer = useMemo( + () => + new VectorLayer({ + source: vectorSource, + }), + [vectorSource], + ); + + return pinsLayer; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..83599a293933735e1319d4692bb23b03e22d6da4 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts @@ -0,0 +1,45 @@ +/* eslint-disable no-magic-numbers */ +import { OPTIONS } from '@/constants/map'; +import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { projectDataSelector } from '@/redux/project/project.selectors'; +import BaseLayer from 'ol/layer/Base'; +import TileLayer from 'ol/layer/Tile'; +import { XYZ } from 'ol/source'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getMapTileUrl } from './getMapTileUrl'; + +export const useOlMapTileLayer = (): BaseLayer => { + const mapSize = useSelector(mapDataSizeSelector); + const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector); + const project = useSelector(projectDataSelector); + + const sourceUrl = useMemo( + () => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }), + [project?.directory, currentBackgroundImagePath], + ); + + const source = useMemo( + () => + new XYZ({ + url: sourceUrl, + maxZoom: mapSize.maxZoom, + minZoom: mapSize.minZoom, + tileSize: mapSize.tileSize, + wrapX: OPTIONS.wrapXInTileLayer, + }), + [sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize], + ); + + const tileLayer = useMemo( + (): TileLayer<XYZ> => + new TileLayer({ + visible: true, + source, + }), + [source], + ); + + return tileLayer; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 3f87400ff1f13608194de724b0693ed3f0d30cc3..aa6137cbcc0be43f521ac40e5f3cf16c62f1dfff 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts @@ -3,10 +3,10 @@ import mapSlice, { setMapPosition } from '@/redux/map/map.slice'; import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { renderHook, waitFor } from '@testing-library/react'; +import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; import { View } from 'ol'; import Map from 'ol/Map'; import React from 'react'; -import { MAP_DATA_INITIAL_STATE } from '../../../../../redux/map/map.constants'; import { useOlMap } from '../useOlMap'; import { useOlMapView } from './useOlMapView'; @@ -87,6 +87,7 @@ describe('useOlMapView - util', () => { name: '', message: '', }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, }); const dummyElement = document.createElement('div'); diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts index acf6e51664ffb871b4aab35e36ba3199bbfe9a29..4b39092bc28a0846ae4e4e2c7ca7eee3bc50233b 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts @@ -37,10 +37,11 @@ export const handleReactionResults = return; } - const { products, reactants } = payload[FIRST]; + const { products, reactants, modifiers } = payload[FIRST]; const productsIds = products.map(p => p.aliasId); const reactantsIds = reactants.map(r => r.aliasId); - const bioEntitiesIds = [...productsIds, ...reactantsIds].map(identifier => String(identifier)); + const modifiersIds = modifiers.map(m => m.aliasId); + const bioEntitiesIds = [...productsIds, ...reactantsIds, ...modifiersIds].map(identifier => String(identifier)); dispatch(setBioEntityContent([])); await dispatch( diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 4de3ca1fb95c0e3bbf4b82dbc85e7afffb32d033..3376b1ca88b4dbbd938d828d5e69b95675af1aad 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -1,17 +1,10 @@ import { FunctionalArea } from '@/components/FunctionalArea'; import { Map } from '@/components/Map'; +import { manrope } from '@/constants/font'; import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager'; -import { Manrope } from '@next/font/google'; import { twMerge } from 'tailwind-merge'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; -const manrope = Manrope({ - variable: '--font-manrope', - display: 'swap', - weight: ['400', '700'], - subsets: ['latin'], -}); - export const MinervaSPA = (): JSX.Element => { useInitializeStore(); useReduxBusQueryManager(); diff --git a/src/constants/canvas.ts b/src/constants/canvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9741f3001cb3defe3931b799a208860b4110879 --- /dev/null +++ b/src/constants/canvas.ts @@ -0,0 +1,7 @@ +export const PIN_PATH2D = + 'M12.3077 0C6.25641 0 0 4.61538 0 12.3077C0 19.5897 11.0769 30.9744 11.5897 31.4872C11.7949 31.6923 12 31.7949 12.3077 31.7949C12.6154 31.7949 12.8205 31.6923 13.0256 31.4872C13.5385 30.9744 24.6154 19.6923 24.6154 12.3077C24.6154 4.61538 18.359 0 12.3077 0Z'; + +export const PIN_SIZE = { + width: 25, + height: 32, +}; diff --git a/src/constants/common.ts b/src/constants/common.ts index ee434dc7c1ebfa6b43b4f2b6004ef5d042a41861..2651c7063a81f9981f27c21bce15a092c7eb5fd1 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -1 +1,2 @@ export const SIZE_OF_EMPTY_ARRAY = 0; +export const ZERO = 0; diff --git a/src/constants/dividers.ts b/src/constants/dividers.ts new file mode 100644 index 0000000000000000000000000000000000000000..c73dd7aa1975e776c72961afc6d4f71f8d594cb9 --- /dev/null +++ b/src/constants/dividers.ts @@ -0,0 +1,3 @@ +export const HALF = 2; +export const THIRD = 3; +export const QUARTER = 4; diff --git a/src/constants/errors.ts b/src/constants/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..887f64f35d00d60ce428da9f5b4239c0d8c6cbaa --- /dev/null +++ b/src/constants/errors.ts @@ -0,0 +1 @@ +export const DEFAULT_ERROR: Error = { message: '', name: '' }; diff --git a/src/constants/font.ts b/src/constants/font.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8ea3d4d6cb31c59c237ee51062294131e3ae497 --- /dev/null +++ b/src/constants/font.ts @@ -0,0 +1,10 @@ +import { Manrope } from '@next/font/google'; + +export const manrope = Manrope({ + variable: '--font-manrope', + display: 'swap', + weight: ['400', '700'], + subsets: ['latin'], +}); + +export const DEFAULT_FONT_FAMILY = manrope.style.fontFamily; diff --git a/src/models/mapOverlay.ts b/src/models/mapOverlay.ts index b76cd45abde2b9bf1c4c3314040ab37e4e48a610..a22b65aa5eed7751bc3033c30998972dadadd9c5 100644 --- a/src/models/mapOverlay.ts +++ b/src/models/mapOverlay.ts @@ -5,8 +5,8 @@ export const mapOverlay = z.object({ googleLicenseConsent: z.boolean(), creator: z.string(), description: z.string(), - genomeType: z.null(), - genomeVersion: z.null(), + genomeType: z.string().nullable(), + genomeVersion: z.string().nullable(), idObject: z.number(), publicOverlay: z.boolean(), type: z.string(), diff --git a/src/models/reaction.ts b/src/models/reaction.ts index 7b2dd7b1454b314195af963dc315e1cf13de6ba6..b36154d9b45d07d7a25ea14b04b1b87b50cdbf9f 100644 --- a/src/models/reaction.ts +++ b/src/models/reaction.ts @@ -16,7 +16,7 @@ export const reactionSchema = z.object({ }), ), modelId: z.number(), - modifiers: z.array(z.unknown()), + modifiers: z.array(productsSchema), name: z.string(), notes: z.string(), products: z.array(productsSchema), diff --git a/src/redux/backgrounds/background.mock.ts b/src/redux/backgrounds/background.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..515bb7249c74207a0435d8a220ac24da9ad55b6e --- /dev/null +++ b/src/redux/backgrounds/background.mock.ts @@ -0,0 +1,60 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { MapBackground } from '@/types/models'; +import { BackgroundsState } from './backgrounds.types'; + +export const BACKGROUND_INITIAL_STATE_MOCK: BackgroundsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; + +export const BACKGROUNDS_MOCK: MapBackground[] = [ + { + id: 13, + name: 'Pathways and compartments', + defaultOverlay: false, + project: { + projectId: 'pdmap_appu_test', + }, + creator: { + login: 'admin', + }, + status: 'NA', + progress: 0, + description: null, + order: 0, + images: [], + }, + { + id: 14, + name: 'Network', + defaultOverlay: false, + project: { + projectId: 'pdmap_appu_test', + }, + creator: { + login: 'admin', + }, + status: 'NA', + progress: 0, + description: null, + order: 1, + images: [], + }, + { + id: 15, + name: 'Empty', + defaultOverlay: false, + project: { + projectId: 'pdmap_appu_test', + }, + creator: { + login: 'admin', + }, + status: 'NA', + progress: 0, + description: null, + order: 2, + images: [], + }, +]; diff --git a/src/redux/backgrounds/background.selectors.ts b/src/redux/backgrounds/background.selectors.ts index 16b233972a19c5eb7edc916f74c716c41550e48b..319bfc27376c4a099fe51ee0fee14b4a2e73348d 100644 --- a/src/redux/backgrounds/background.selectors.ts +++ b/src/redux/backgrounds/background.selectors.ts @@ -9,6 +9,12 @@ export const backgroundsDataSelector = createSelector( backgrounds => backgrounds?.data || [], ); +const MAIN_BACKGROUND = 0; +export const mainBackgroundsDataSelector = createSelector( + backgroundsDataSelector, + backgrounds => backgrounds[MAIN_BACKGROUND], +); + export const currentBackgroundSelector = createSelector( backgroundsDataSelector, mapDataSelector, diff --git a/src/redux/bioEntity/bioEntity.mock.ts b/src/redux/bioEntity/bioEntity.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..3706162e8dd2ec52452f51cdbaef0c4dd50e027a --- /dev/null +++ b/src/redux/bioEntity/bioEntity.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { BioEntityContentsState } from './bioEntity.types'; + +export const BIOENTITY_INITIAL_STATE_MOCK: BioEntityContentsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index 43c3002e30980c9f6c09076cb632b6d54fa74bb3..dac3b8c71cb696d8c9a2bd6289f35aa7df5dfccd 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -1,6 +1,8 @@ import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { rootSelector } from '@/redux/root/root.selectors'; +import { BioEntityContent } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; +import { currentModelIdSelector } from '../models/models.selectors'; export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); @@ -9,6 +11,13 @@ export const loadingBioEntityStatusSelector = createSelector( state => state.loading, ); +export const allBioEntitesSelectorOfCurrentMap = createSelector( + bioEntitySelector, + currentModelIdSelector, + (state, currentModelId): BioEntityContent[] => + (state?.data || []).filter(({ bioEntity }) => bioEntity.model === currentModelId), +); + export const numberOfBioEntitiesSelector = createSelector(bioEntitySelector, state => state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, ); diff --git a/src/redux/chemicals/chemicals.mock.ts b/src/redux/chemicals/chemicals.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b492a2e4656daa87eed1047a89ceed0049500e9 --- /dev/null +++ b/src/redux/chemicals/chemicals.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ChemicalsState } from './chemicals.types'; + +export const CHEMICALS_INITIAL_STATE_MOCK: ChemicalsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/drugs/drugs.mock.ts b/src/redux/drugs/drugs.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..884c8de9aa8d89a6c2b830ed85427b0a77e06f37 --- /dev/null +++ b/src/redux/drugs/drugs.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { DrugsState } from './drugs.types'; + +export const DRUGS_INITIAL_STATE_MOCK: DrugsState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts index 7a1d3eacacbf0fee4cd7093f4c0255f4c910af51..af9a8d7fa58659446deb5e8685b489cc67cfdcbf 100644 --- a/src/redux/drugs/drugs.selectors.ts +++ b/src/redux/drugs/drugs.selectors.ts @@ -5,6 +5,16 @@ import { createSelector } from '@reduxjs/toolkit'; export const drugsSelector = createSelector(rootSelector, state => state.drugs); export const loadingDrugsStatusSelector = createSelector(drugsSelector, state => state.loading); -export const numberOfDrugsSelector = createSelector(drugsSelector, state => - state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, -); +export const numberOfDrugsSelector = createSelector(drugsSelector, state => { + if (!state.data) { + return SIZE_OF_EMPTY_ARRAY; + } + + let numberOfDrugs = 0; + + state.data.forEach(element => { + numberOfDrugs += element.targets.length; + }); + + return numberOfDrugs; +}); diff --git a/src/redux/map/map.constants.ts b/src/redux/map/map.constants.ts index de82f296b19012d682782e59e10363210eeb285e..333a20627dc73fa4844b919b580d2231b1539c13 100644 --- a/src/redux/map/map.constants.ts +++ b/src/redux/map/map.constants.ts @@ -5,13 +5,20 @@ import { DEFAULT_MIN_ZOOM, DEFAULT_TILE_SIZE, } from '@/constants/map'; -import { MapData } from './map.types'; +import { Point } from '@/types/map'; +import { MapData, OppenedMap } from './map.types'; + +export const MAIN_MAP = 'Main map'; + +export const MODEL_ID_DEFAULT: number = 0; + +export const BACKGROUND_ID_DEFAULT: number = 0; export const MAP_DATA_INITIAL_STATE: MapData = { projectId: PROJECT_ID, meshId: '', - modelId: 0, - backgroundId: 0, + modelId: MODEL_ID_DEFAULT, + backgroundId: BACKGROUND_ID_DEFAULT, overlaysIds: [], position: { last: DEFAULT_CENTER_POINT, @@ -29,5 +36,10 @@ export const MAP_DATA_INITIAL_STATE: MapData = { maxZoom: DEFAULT_MAX_ZOOM, }, }; +export const DEFAULT_POSITION: Point = { x: 0, y: 0, z: 0 }; + +export const OPENED_MAPS_INITIAL_STATE: OppenedMap[] = [ + { modelId: MODEL_ID_DEFAULT, modelName: MAIN_MAP, lastPosition: DEFAULT_POSITION }, +]; export const MIDDLEWARE_ALLOWED_ACTIONS: string[] = ['map/setMapData']; diff --git a/src/redux/map/map.fixtures.ts b/src/redux/map/map.fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cb2f16fe2683d2988f2a7bd16aff6e95a558eaf --- /dev/null +++ b/src/redux/map/map.fixtures.ts @@ -0,0 +1,42 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { MapData, MapState, OppenedMap } from './map.types'; + +export const openedMapsInitialValueFixture: OppenedMap[] = [ + { modelId: 0, modelName: 'Main map', lastPosition: { x: 0, y: 0, z: 0 } }, +]; + +export const openedMapsThreeSubmapsFixture: OppenedMap[] = [ + { modelId: 5053, modelName: 'Main map', lastPosition: { x: 0, y: 0, z: 0 } }, + { modelId: 5052, modelName: 'Histamine signaling', lastPosition: { x: 0, y: 0, z: 0 } }, + { modelId: 5054, modelName: 'PRKN substrates', lastPosition: { x: 0, y: 0, z: 0 } }, +]; + +export const initialMapDataFixture: MapData = { + projectId: 'pdmap', + meshId: '', + modelId: 0, + backgroundId: 0, + overlaysIds: [], + position: { + initial: { x: 0, y: 0, z: 5 }, + last: { x: 0, y: 0, z: 5 }, + }, + show: { + legend: false, + comments: false, + }, + size: { + width: 0, + height: 0, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }, +}; + +export const initialMapStateFixture: MapState = { + data: initialMapDataFixture, + loading: 'idle', + error: DEFAULT_ERROR, + openedMaps: openedMapsInitialValueFixture, +}; diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts index 7dc80074bba59f069adb94676a3634860a7d8ee1..b0ad81bf535fa81d7f6dc1678a8b2b6d7eaeee06 100644 --- a/src/redux/map/map.reducers.ts +++ b/src/redux/map/map.reducers.ts @@ -1,7 +1,21 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { ZERO } from '@/constants/common'; +import { + CloseMapAction, + MapState, + OpenMapAndSetActiveAction, + SetActiveMapAction, + SetMapDataAction, + SetMapPositionDataAction, +} from './map.types'; +import { MAIN_MAP } from './map.constants'; import { getPointMerged } from '../../utils/object/getPointMerged'; -import { initMapData, initMapPosition } from './map.thunks'; -import { MapState, SetMapDataAction, SetMapPositionDataAction } from './map.types'; +import { + initMapBackground, + initMapPosition, + initMapSizeAndModelId, + initOpenedMaps, +} from './map.thunks'; export const setMapDataReducer = (state: MapState, action: SetMapDataAction): void => { const payload = action.payload || {}; @@ -26,32 +40,72 @@ export const setMapPositionReducer = (state: MapState, action: SetMapPositionDat }; }; -export const getMapReducers = (builder: ActionReducerMapBuilder<MapState>): void => { - builder.addCase(initMapData.pending, state => { - state.loading = 'pending'; - }); - builder.addCase(initMapData.fulfilled, (state, action) => { - const payload = action.payload || {}; - state.data = { ...state.data, ...payload }; - state.loading = 'succeeded'; - }); - builder.addCase(initMapData.rejected, state => { - state.loading = 'failed'; - // TODO to discuss manage state of failure +const updateLastPositionOfCurrentlyActiveMap = (state: MapState): void => { + const currentMapId = state.data.modelId; + const currentOpenedMap = state.openedMaps.find(openedMap => openedMap.modelId === currentMapId); + if (currentOpenedMap) { + currentOpenedMap.lastPosition = state.data.position.last; + } +}; + +export const setActiveMapReducer = (state: MapState, action: SetActiveMapAction): void => { + updateLastPositionOfCurrentlyActiveMap(state); + state.data.modelId = action.payload.modelId; +}; + +export const openMapAndSetActiveReducer = ( + state: MapState, + action: OpenMapAndSetActiveAction, +): void => { + updateLastPositionOfCurrentlyActiveMap(state); + + state.openedMaps.push({ + modelId: action.payload.modelId, + modelName: action.payload.modelName, + lastPosition: { x: 0, y: 0, z: 0 }, }); + state.data.modelId = action.payload.modelId; +}; + +export const closeMapReducer = (state: MapState, action: CloseMapAction): void => { + state.openedMaps = state.openedMaps.filter( + openedMap => openedMap.modelId !== action.payload.modelId, + ); }; -export const getMapPositionReducers = (builder: ActionReducerMapBuilder<MapState>): void => { - builder.addCase(initMapPosition.pending, state => { - state.loading = 'pending'; +export const closeMapAndSetMainMapActiveReducer = ( + state: MapState, + action: CloseMapAction, +): void => { + state.openedMaps = state.openedMaps.filter( + openedMap => openedMap.modelId !== action.payload.modelId, + ); + state.data.modelId = + state.openedMaps.find(openedMap => openedMap.modelName === MAIN_MAP)?.modelId || ZERO; +}; + +export const initMapSizeAndModelIdReducer = (builder: ActionReducerMapBuilder<MapState>): void => { + builder.addCase(initMapSizeAndModelId.fulfilled, (state, action) => { + state.data.modelId = action.payload.modelId; + state.data.size = action.payload.size; }); +}; + +export const initMapPositionReducers = (builder: ActionReducerMapBuilder<MapState>): void => { builder.addCase(initMapPosition.fulfilled, (state, action) => { - const payload = action.payload || {}; - state.data = { ...state.data, ...payload }; + state.data.position = action.payload; + }); +}; + +export const initMapBackgroundsReducer = (builder: ActionReducerMapBuilder<MapState>): void => { + builder.addCase(initMapBackground.fulfilled, (state, action) => { + state.data.backgroundId = action.payload; state.loading = 'succeeded'; }); - builder.addCase(initMapPosition.rejected, state => { - state.loading = 'failed'; - // TODO to discuss manage state of failure +}; + +export const initOpenedMapsReducer = (builder: ActionReducerMapBuilder<MapState>): void => { + builder.addCase(initOpenedMaps.fulfilled, (state, action) => { + state.openedMaps = action.payload; }); }; diff --git a/src/redux/map/map.selectors.ts b/src/redux/map/map.selectors.ts index bc71ec9840e6bd2c6700461b528082e707bfd217..3f9d9d869324b43b48093632cfd02a7e5e493ff0 100644 --- a/src/redux/map/map.selectors.ts +++ b/src/redux/map/map.selectors.ts @@ -9,11 +9,20 @@ export const mapDataSizeSelector = createSelector(mapDataSelector, map => map.si export const mapDataPositionSelector = createSelector(mapDataSelector, map => map.position); +export const mapOpenedMapsSelector = createSelector(mapSelector, state => state.openedMaps); + +export const mapModelIdSelector = createSelector(mapDataSelector, map => map.modelId); export const mapDataInitialPositionSelector = createSelector( mapDataPositionSelector, position => position.initial, ); +export const mapOpenedMapPositionByIdSelector = createSelector( + [mapOpenedMapsSelector, (_state, modelId: number): number => modelId], + (openedMaps, modelId) => + openedMaps.find(openedMap => openedMap.modelId === modelId)?.lastPosition, +); + export const mapDataLastPositionSelector = createSelector( mapDataPositionSelector, position => position.last, diff --git a/src/redux/map/map.slice.ts b/src/redux/map/map.slice.ts index 49a21589200ba0f0c3c10f2931310eac663cca8f..ca51213ca667fc5087cec6f45d988c61f508d8dc 100644 --- a/src/redux/map/map.slice.ts +++ b/src/redux/map/map.slice.ts @@ -1,10 +1,16 @@ import { createSlice } from '@reduxjs/toolkit'; -import { MAP_DATA_INITIAL_STATE } from './map.constants'; +import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from './map.constants'; import { - getMapPositionReducers, - getMapReducers, + closeMapAndSetMainMapActiveReducer, + closeMapReducer, + openMapAndSetActiveReducer, + setActiveMapReducer, setMapDataReducer, + initMapPositionReducers, setMapPositionReducer, + initOpenedMapsReducer, + initMapSizeAndModelIdReducer, + initMapBackgroundsReducer, } from './map.reducers'; import { MapState } from './map.types'; @@ -12,6 +18,7 @@ const initialState: MapState = { data: MAP_DATA_INITIAL_STATE, loading: 'idle', error: { name: '', message: '' }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }; const mapSlice = createSlice({ @@ -19,14 +26,27 @@ const mapSlice = createSlice({ initialState, reducers: { setMapData: setMapDataReducer, + setActiveMap: setActiveMapReducer, + openMapAndSetActive: openMapAndSetActiveReducer, + closeMap: closeMapReducer, + closeMapAndSetMainMapActive: closeMapAndSetMainMapActiveReducer, setMapPosition: setMapPositionReducer, }, extraReducers: builder => { - getMapReducers(builder); - getMapPositionReducers(builder); + initMapPositionReducers(builder); + initMapSizeAndModelIdReducer(builder); + initMapBackgroundsReducer(builder); + initOpenedMapsReducer(builder); }, }); -export const { setMapData, setMapPosition } = mapSlice.actions; +export const { + setMapData, + setActiveMap, + openMapAndSetActive, + closeMap, + closeMapAndSetMainMapActive, + setMapPosition, +} = mapSlice.actions; export default mapSlice.reducer; diff --git a/src/redux/map/map.thunks.test.ts b/src/redux/map/map.thunks.test.ts index d717ad71b5c557ab908eed821bdd5f1616325c01..69a2e9e110194858eb8e5a986dc2ce0201afefdb 100644 --- a/src/redux/map/map.thunks.test.ts +++ b/src/redux/map/map.thunks.test.ts @@ -1,20 +1,11 @@ -import { PROJECT_ID } from '@/constants'; -import { backgroundsFixture } from '@/models/fixtures/backgroundsFixture'; -import { modelsFixture } from '@/models/fixtures/modelsFixture'; -import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { MODELS_MOCK } from '@/models/mocks/modelsMock'; +/* eslint-disable no-magic-numbers */ import { QueryData } from '@/types/query'; -import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { HttpStatusCode } from 'axios'; -import { apiPath } from '../apiPath'; -import { backgroundsDataSelector } from '../backgrounds/background.selectors'; -import { modelsDataSelector } from '../models/models.selectors'; -import { overlaysDataSelector } from '../overlays/overlays.selectors'; -import { AppDispatch, StoreType } from '../store'; -import { initMapData } from './map.thunks'; -import { InitMapDataActionPayload } from './map.types'; - -const mockedAxiosClient = mockNetworkResponse(); +import { BACKGROUNDS_MOCK, BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; +import { RootState } from '../store'; +import { INITIAL_STORE_STATE_MOCK } from '../root/root.fixtures'; +import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; +import { getBackgroundId, getInitMapPosition, getInitMapSizeAndModelId } from './map.thunks'; const EMPTY_QUERY_DATA: QueryData = { modelId: undefined, @@ -22,74 +13,98 @@ const EMPTY_QUERY_DATA: QueryData = { initialPosition: undefined, }; -describe('map thunks', () => { - describe('initMapData - thunk', () => { - describe('when API is returning valid data', () => { - let store = {} as StoreType; - let payload = {} as InitMapDataActionPayload; +const QUERY_DATA_WITH_BG: QueryData = { + modelId: undefined, + backgroundId: 21, + initialPosition: undefined, +}; - beforeAll(async () => { - mockedAxiosClient.resetHandlers(); - mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); - mockedAxiosClient - .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) - .reply(HttpStatusCode.Ok, overlaysFixture); - mockedAxiosClient - .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) - .reply(HttpStatusCode.Ok, backgroundsFixture); +const QUERY_DATA_WITH_MODELID: QueryData = { + modelId: 5054, + backgroundId: undefined, + initialPosition: undefined, +}; - store = getReduxWrapperWithStore().store; - const dispatch = store.dispatch as AppDispatch; - payload = (await dispatch(initMapData({ queryData: EMPTY_QUERY_DATA }))) - .payload as InitMapDataActionPayload; - }); +const QUERY_DATA_WITH_POSITION: QueryData = { + modelId: undefined, + backgroundId: undefined, + initialPosition: { + x: 21, + y: 3, + z: 7, + }, +}; - it('should fetch backgrounds data in store', async () => { - const data = backgroundsDataSelector(store.getState()); - expect(data).toEqual(backgroundsFixture); - }); +const STATE_WITH_MODELS: RootState = { + ...INITIAL_STORE_STATE_MOCK, + models: { ...MODELS_INITIAL_STATE_MOCK, data: MODELS_MOCK }, +}; - it('should fetch overlays data in store', async () => { - const data = overlaysDataSelector(store.getState()); - expect(data).toEqual(overlaysFixture); - }); +describe('map thunks - utils', () => { + describe('getBackgroundId', () => { + it('should return backgroundId value from queryData', () => { + const backgroundId = getBackgroundId(INITIAL_STORE_STATE_MOCK, QUERY_DATA_WITH_BG); + expect(backgroundId).toBe(21); + }); + it('should return main map background id if query param does not include background id', () => { + const store: RootState = { + ...INITIAL_STORE_STATE_MOCK, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + }; + const backgroundId = getBackgroundId(store, EMPTY_QUERY_DATA); - it('should fetch models data in store', async () => { - const data = modelsDataSelector(store.getState()); - expect(data).toEqual(modelsFixture); - }); + expect(backgroundId).toBe(13); + }); + it('should return default value (0) if query data does not include backgroundId and could not find main background in the store', () => { + const backgroundId = getBackgroundId(INITIAL_STORE_STATE_MOCK, EMPTY_QUERY_DATA); - it('should return valid payload', () => { - const FIRST = 0; + expect(backgroundId).toBe(0); + }); + }); - expect(payload).toMatchObject({ - modelId: modelsFixture[FIRST].idObject, - backgroundId: backgroundsFixture[FIRST].id, - }); + describe('getInitMapPosition', () => { + it('should return valid map position from query params ', () => { + const position = getInitMapPosition(STATE_WITH_MODELS, QUERY_DATA_WITH_POSITION); + expect(position).toEqual({ + initial: { x: 21, y: 3, z: 7 }, + last: { x: 21, y: 3, z: 7 }, }); }); - describe('when API is returning empty array', () => { - let store = {} as StoreType; - let payload = {} as InitMapDataActionPayload; + it('should return valid map position if query params do not include position', () => { + const position = getInitMapPosition(STATE_WITH_MODELS, EMPTY_QUERY_DATA); + expect(position).toEqual({ + initial: { x: 13389.625, y: 6751.5, z: 5 }, + last: { x: 13389.625, y: 6751.5, z: 5 }, + }); + }); + it('should return default map position', () => { + const position = getInitMapPosition(INITIAL_STORE_STATE_MOCK, EMPTY_QUERY_DATA); + + expect(position).toEqual({ initial: { x: 0, y: 0, z: 0 }, last: { x: 0, y: 0, z: 0 } }); + }); + }); - beforeEach(async () => { - mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, []); - mockedAxiosClient - .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) - .reply(HttpStatusCode.Ok, []); - mockedAxiosClient - .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) - .reply(HttpStatusCode.Ok, []); + describe('getInitMapSizeAndModelId', () => { + it('should return correct mapsize and modelid when modelId is provided in queryData', () => { + const payload = getInitMapSizeAndModelId(STATE_WITH_MODELS, QUERY_DATA_WITH_MODELID); - store = getReduxWrapperWithStore().store; - const dispatch = store.dispatch as AppDispatch; - payload = (await dispatch(initMapData({ queryData: EMPTY_QUERY_DATA }))) - .payload as InitMapDataActionPayload; + expect(payload).toEqual({ + modelId: 5054, + size: { height: 1171.9429798877356, maxZoom: 5, minZoom: 2, tileSize: 256, width: 1652.75 }, }); - - it('should return empty payload', () => { - expect(payload).toStrictEqual({}); + }); + it('should return correct mapsize and modelId if query params do not include modelId', () => { + const payload = getInitMapSizeAndModelId(STATE_WITH_MODELS, EMPTY_QUERY_DATA); + expect(payload).toEqual({ + modelId: 5053, + size: { + height: 13503, + maxZoom: 9, + minZoom: 2, + tileSize: 256, + width: 26779.25, + }, }); }); }); diff --git a/src/redux/map/map.thunks.ts b/src/redux/map/map.thunks.ts index 0ebf33265655e61b9e1ad0e382a479b9c8076a31..f2ea946bf66c0f1086d3e73696fca480d42d1ede 100644 --- a/src/redux/map/map.thunks.ts +++ b/src/redux/map/map.thunks.ts @@ -1,72 +1,155 @@ -import { PROJECT_ID } from '@/constants'; -import { QueryData } from '@/types/query'; -import { GetUpdatedMapDataResult, getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; +/* eslint-disable no-magic-numbers */ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { backgroundsDataSelector } from '../backgrounds/background.selectors'; -import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; -import { modelsDataSelector } from '../models/models.selectors'; -import { getModels } from '../models/models.thunks'; -import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; +import { ZERO } from '@/constants/common'; +import { QueryData } from '@/types/query'; +import { DEFAULT_ZOOM } from '@/constants/map'; +import { getPointMerged } from '@/utils/object/getPointMerged'; import type { AppDispatch, RootState } from '../store'; import { - InitMapDataActionParams, - InitMapDataActionPayload, - SetMapPositionDataActionPayload, + InitMapBackgroundActionPayload, + InitMapBackgroundParams, + InitMapPositionActionPayload, + InitMapPositionParams, + InitMapSizeAndModelIdActionPayload, + InitMapSizeAndModelIdParams, + InitOpenedMapsActionPayload, + InitOpenedMapsProps, + MapSizeAndModelId, + OppenedMap, + Position, } from './map.types'; +import { mainBackgroundsDataSelector } from '../backgrounds/background.selectors'; +import { + currentModelSelector, + mainMapModelSelector, + modelByIdSelector, + modelsDataSelector, +} from '../models/models.selectors'; +import { DEFAULT_POSITION, MAIN_MAP } from './map.constants'; + +/** UTILS - in the same file because of dependancy cycle */ + +export const getBackgroundId = (state: RootState, queryData: QueryData): number => { + const mainMapBackground = mainBackgroundsDataSelector(state); + const backgroundId = queryData?.backgroundId || mainMapBackground?.id || ZERO; + + return backgroundId; +}; + +export const getInitMapPosition = (state: RootState, queryData: QueryData): Position => { + const mainMapModel = mainMapModelSelector(state); + const modelId = queryData?.modelId || mainMapModel?.idObject || ZERO; + const currentModel = modelByIdSelector(state, modelId); + const position = queryData?.initialPosition; + const HALF = 2; -const getInitMapDataPayload = ( + if (!currentModel) { + return { + last: DEFAULT_POSITION, + initial: DEFAULT_POSITION, + }; + } + + const defaultPosition = { + x: currentModel.defaultCenterX ?? currentModel.width / HALF, + y: currentModel.defaultCenterY ?? currentModel.height / HALF, + z: currentModel.defaultZoomLevel ?? DEFAULT_ZOOM, + }; + + const mergedPosition = getPointMerged(position || {}, defaultPosition); + + return { + last: mergedPosition, + initial: mergedPosition, + }; +}; + +export const getInitMapSizeAndModelId = ( state: RootState, queryData: QueryData, -): GetUpdatedMapDataResult | object => { +): MapSizeAndModelId => { + const mainMapModel = mainMapModelSelector(state); + const modelId = queryData?.modelId || mainMapModel?.idObject || ZERO; + const currentModel = modelByIdSelector(state, modelId); + + return { + modelId: currentModel?.idObject || ZERO, + size: { + width: currentModel?.width || ZERO, + height: currentModel?.height || ZERO, + tileSize: currentModel?.tileSize || ZERO, + minZoom: currentModel?.minZoom || ZERO, + maxZoom: currentModel?.maxZoom || ZERO, + }, + }; +}; + +export const getOpenedMaps = (state: RootState, queryData: QueryData): OppenedMap[] => { const FIRST = 0; const models = modelsDataSelector(state); - const backgrounds = backgroundsDataSelector(state); - const modelId = queryData?.modelId || models?.[FIRST]?.idObject; - const backgroundId = queryData?.backgroundId || backgrounds?.[FIRST]?.id; - const model = models.find(({ idObject }) => idObject === modelId); - const background = backgrounds.find(({ id }) => id === backgroundId); - const position = queryData?.initialPosition; + const currentModel = currentModelSelector(state); + const mainMapId = models?.[FIRST]?.idObject || ZERO; - if (!model || !background) { - return {}; - } + const openedMaps: OppenedMap[] = [ + { modelId: mainMapId, modelName: MAIN_MAP, lastPosition: DEFAULT_POSITION }, + ]; - return getUpdatedMapData({ - model, - background, - position: { - last: position, - initial: position, - }, - }); + if (queryData.modelId !== mainMapId) { + openedMaps.push({ + modelId: currentModel?.idObject || ZERO, + modelName: currentModel?.name || '', + lastPosition: { ...DEFAULT_POSITION, ...queryData.initialPosition }, + }); + } + return openedMaps; }; -export const initMapData = createAsyncThunk< - InitMapDataActionPayload, - InitMapDataActionParams, +/** THUNKS */ + +export const initMapSizeAndModelId = createAsyncThunk< + InitMapSizeAndModelIdActionPayload, + InitMapSizeAndModelIdParams, { dispatch: AppDispatch; state: RootState } >( - 'map/initMapData', - async ({ queryData }, { dispatch, getState }): Promise<InitMapDataActionPayload> => { - await Promise.all([ - dispatch(getAllBackgroundsByProjectId(PROJECT_ID)), - dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), - dispatch(getModels()), - ]); - + 'map/initMapSizeAndModelId', + async ({ queryData }, { getState }): Promise<InitMapSizeAndModelIdActionPayload> => { const state = getState(); - return getInitMapDataPayload(state, queryData); + + return getInitMapSizeAndModelId(state, queryData); }, ); export const initMapPosition = createAsyncThunk< - InitMapDataActionPayload, - InitMapDataActionParams, + InitMapPositionActionPayload, + InitMapPositionParams, { dispatch: AppDispatch; state: RootState } >( 'map/initMapPosition', - async ({ queryData }, { getState }): Promise<SetMapPositionDataActionPayload> => { + async ({ queryData }, { getState }): Promise<InitMapPositionActionPayload> => { + const state = getState(); + + return getInitMapPosition(state, queryData); + }, +); + +export const initMapBackground = createAsyncThunk< + InitMapBackgroundActionPayload, + InitMapBackgroundParams, + { dispatch: AppDispatch; state: RootState } +>( + 'map/initMapBackground', + async ({ queryData }, { getState }): Promise<InitMapBackgroundActionPayload> => { const state = getState(); - return getInitMapDataPayload(state, queryData); + return getBackgroundId(state, queryData); }, ); + +export const initOpenedMaps = createAsyncThunk< + InitOpenedMapsActionPayload, + InitOpenedMapsProps, + { dispatch: AppDispatch; state: RootState } +>('appInit/initOpenedMaps', async ({ queryData }, { getState }): Promise<OppenedMap[]> => { + const state = getState(); + + return getOpenedMaps(state, queryData); +}); diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts index db099d1113ae97fda4e7186da1db597566aee802..bd641cd7708a0116fe2a8ba12741733b4a9ac1f8 100644 --- a/src/redux/map/map.types.ts +++ b/src/redux/map/map.types.ts @@ -11,6 +11,17 @@ export interface MapSize { maxZoom: number; } +export type OppenedMap = { + modelId: number; + modelName: string; + lastPosition: Point; +}; + +export type Position = { + initial: Point; + last: Point; +}; + export type MapData = { projectId: string; meshId: string; @@ -18,17 +29,14 @@ export type MapData = { backgroundId: number; overlaysIds: number[]; size: MapSize; - position: { - initial: Point; - last: Point; - }; + position: Position; show: { legend: boolean; comments: boolean; }; }; -export type MapState = FetchDataState<MapData, MapData>; +export type MapState = FetchDataState<MapData, MapData> & { openedMaps: OppenedMap[] }; export type SetMapDataActionPayload = | (Omit<Partial<MapData>, 'position' | 'projectId'> & { @@ -36,17 +44,29 @@ export type SetMapDataActionPayload = }) | undefined; +export type UpdateOpenedMainMapActionPayload = Pick<OppenedMap, 'modelId' | 'lastPosition'>; + +export type UpdateOpenedMainMapAction = PayloadAction<UpdateOpenedMainMapActionPayload>; + export type SetMapDataAction = PayloadAction<SetMapDataActionPayload>; -export type InitMapDataActionParams = { queryData: QueryData }; +export type SetActiveMapActionPayload = Pick<OppenedMap, 'modelId'>; -export type InitMapDataActionPayload = SetMapDataActionPayload | object; +export type SetActiveMapAction = PayloadAction<SetActiveMapActionPayload>; -export type InitMapDataAction = PayloadAction<SetMapDataAction>; +export type SetMainMapModelIdAction = PayloadAction<SetActiveMapActionPayload>; -export type MiddlewareAllowedAction = PayloadAction< - SetMapDataActionPayload | InitMapDataActionPayload ->; +export type OpenMapAndSetActivePayload = Pick<OppenedMap, 'modelId' | 'modelName'>; + +export type OpenMapAndSetActiveAction = PayloadAction<OpenMapAndSetActivePayload>; + +export type CloseMapActionPayload = Pick<OppenedMap, 'modelId'>; + +export type CloseMapAction = PayloadAction<CloseMapActionPayload>; + +export type InitMapDataActionParams = { queryData: QueryData }; + +export type InitMapDataAction = PayloadAction<SetMapDataAction>; export type SetMapDataByQueryDataActionParams = { queryData: QueryData }; @@ -55,6 +75,41 @@ export type SetMapDataByQueryDataActionPayload = Pick< 'modelId' | 'backgroundId' | 'position' >; -export type SetMapPositionDataActionPayload = Pick<MapData, 'position'> | object; +export type GetUpdatedMapDataResult = Pick< + MapData, + 'modelId' | 'backgroundId' | 'size' | 'position' +>; + +export type SetMapPositionDataActionPayload = GetUpdatedMapDataResult | object; export type SetMapPositionDataAction = PayloadAction<SetMapPositionDataActionPayload>; + +export type InitMapDataActionPayload = { + data: GetUpdatedMapDataResult | object; + openedMaps: OppenedMap[]; +}; +export type MiddlewareAllowedAction = PayloadAction< + SetMapDataActionPayload | InitMapDataActionPayload +>; + +export type InitOpenedMapsActionPayload = OppenedMap[]; + +export type InitOpenedMapsProps = { + queryData: QueryData; +}; + +export type MapSizeAndModelId = Pick<MapData, 'modelId' | 'size'>; +export type InitMapSizeAndModelIdActionPayload = MapSizeAndModelId; +export type InitMapSizeAndModelIdParams = { + queryData: QueryData; +}; + +export type InitMapPositionActionPayload = Position; +export type InitMapPositionParams = { + queryData: QueryData; +}; + +export type InitMapBackgroundActionPayload = number; +export type InitMapBackgroundParams = { + queryData: QueryData; +}; diff --git a/src/redux/map/middleware/checkIfIsMapUpdateActionValid.test.ts b/src/redux/map/middleware/checkIfIsMapUpdateActionValid.test.ts index aa889249020931981df2631afd7550950ced7dbf..5580c0242b1e669dddc051a2f0f181a0e0ebe8c4 100644 --- a/src/redux/map/middleware/checkIfIsMapUpdateActionValid.test.ts +++ b/src/redux/map/middleware/checkIfIsMapUpdateActionValid.test.ts @@ -1,6 +1,10 @@ import { RootState } from '@/redux/store'; import { Loading } from '@/types/loadingState'; -import { MAP_DATA_INITIAL_STATE, MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants'; +import { + MAP_DATA_INITIAL_STATE, + MIDDLEWARE_ALLOWED_ACTIONS, + OPENED_MAPS_INITIAL_STATE, +} from '../map.constants'; import { SetMapDataAction } from '../map.types'; import { checkIfIsMapUpdateActionValid } from './checkIfIsMapUpdateActionValid'; @@ -12,6 +16,7 @@ const state: Pick<RootState, 'map'> = { }, loading: 'idle' as Loading, error: { name: '', message: '' }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, }; diff --git a/src/redux/map/middleware/map.middleware.test.ts b/src/redux/map/middleware/map.middleware.test.ts index 3359b92a548c0bee5ec6e40c7a651f163b12d3dd..11015648425d5752f83f22dd282a2e089a5e7871 100644 --- a/src/redux/map/middleware/map.middleware.test.ts +++ b/src/redux/map/middleware/map.middleware.test.ts @@ -3,7 +3,11 @@ import { modelsFixture } from '@/models/fixtures/modelsFixture'; import { Loading } from '@/types/loadingState'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { Action } from '@reduxjs/toolkit'; -import { MAP_DATA_INITIAL_STATE, MIDDLEWARE_ALLOWED_ACTIONS } from '../map.constants'; +import { + MAP_DATA_INITIAL_STATE, + MIDDLEWARE_ALLOWED_ACTIONS, + OPENED_MAPS_INITIAL_STATE, +} from '../map.constants'; import * as mapSlice from '../map.slice'; import * as checkIfIsMapUpdateActionValid from './checkIfIsMapUpdateActionValid'; import * as getUpdatedModel from './getUpdatedModel'; @@ -58,6 +62,7 @@ const { store } = getReduxWrapperWithStore({ ...MAP_DATA_INITIAL_STATE, modelId: modelsFixture[0].idObject, }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, models: { ...defaultSliceState, diff --git a/src/redux/map/middleware/map.middleware.ts b/src/redux/map/middleware/map.middleware.ts index f3e12654c64b70720198f86e086789f29136353e..5658c14519d6d650b3c70b52abf90f2252fd1219 100644 --- a/src/redux/map/middleware/map.middleware.ts +++ b/src/redux/map/middleware/map.middleware.ts @@ -1,23 +1,22 @@ import { currentBackgroundSelector } from '@/redux/backgrounds/background.selectors'; -import type { AppDispatch, AppListenerEffectAPI, AppStartListening } from '@/redux/store'; -import { GetUpdatedMapDataResult, getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; -import { Action, createListenerMiddleware } from '@reduxjs/toolkit'; -import { setMapData, setMapPosition } from '../map.slice'; +import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store'; +import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; +import { Action, createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; +import { + openMapAndSetActive, + setActiveMap, + setMapData, + setMapPosition, + closeMapAndSetMainMapActive, +} from '../map.slice'; import { checkIfIsMapUpdateActionValid } from './checkIfIsMapUpdateActionValid'; import { getUpdatedModel } from './getUpdatedModel'; +import { mapOpenedMapPositionByIdSelector } from '../map.selectors'; export const mapListenerMiddleware = createListenerMiddleware(); const startListening = mapListenerMiddleware.startListening as AppStartListening; -/* prettier-ignore */ -export const dispatchMapDataWithPosition = - (updatedMapData: GetUpdatedMapDataResult) => - (dispatch: AppDispatch): void => { - dispatch(setMapData(updatedMapData)); - dispatch(setMapPosition(updatedMapData)); - }; - export const mapDataMiddlewareListener = async ( action: Action, { getOriginalState, dispatch }: AppListenerEffectAPI, @@ -31,12 +30,18 @@ export const mapDataMiddlewareListener = async ( } const background = currentBackgroundSelector(state); - const updatedMapData = getUpdatedMapData({ model: updatedModel, background }); + const modelId = updatedModel.idObject; + const lastPosition = mapOpenedMapPositionByIdSelector(state, modelId); + const updatedMapData = getUpdatedMapData({ + model: updatedModel, + position: { initial: lastPosition, last: lastPosition }, + background, + }); dispatch(setMapData(updatedMapData)); dispatch(setMapPosition(updatedMapData)); }; startListening({ - type: 'map/setMapData', + matcher: isAnyOf(setMapData, setActiveMap, openMapAndSetActive, closeMapAndSetMainMapActive), effect: mapDataMiddlewareListener, }); diff --git a/src/redux/mirnas/mirnas.mock.ts b/src/redux/mirnas/mirnas.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..233c3558ef20fe6eb8a1e607c64b8940ad8aaac7 --- /dev/null +++ b/src/redux/mirnas/mirnas.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { MirnasState } from './mirnas.types'; + +export const MIRNAS_INITIAL_STATE_MOCK: MirnasState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/models/models.mock.ts b/src/redux/models/models.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e45763d82516d7ff30c12ee7512a8da5f2378cdf --- /dev/null +++ b/src/redux/models/models.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ModelsState } from './models.types'; + +export const MODELS_INITIAL_STATE_MOCK: ModelsState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index 071b06826fa06e98e6e71ef6a528032f1caa0e38..70a6671c5bb2f6aef66ac96c76c9f447a12e314b 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -1,6 +1,7 @@ import { rootSelector } from '@/redux/root/root.selectors'; import { createSelector } from '@reduxjs/toolkit'; import { mapDataSelector } from '../map/map.selectors'; +import { MODEL_ID_DEFAULT } from '../map/map.constants'; export const modelsSelector = createSelector(rootSelector, state => state.models); @@ -15,5 +16,12 @@ export const currentModelSelector = createSelector( export const currentModelIdSelector = createSelector( currentModelSelector, // eslint-disable-next-line no-magic-numbers - model => model?.idObject || 0, + model => model?.idObject || MODEL_ID_DEFAULT, ); +export const modelByIdSelector = createSelector( + [modelsSelector, (_state, modelId: number): number => modelId], + (models, modelId) => (models?.data || []).find(({ idObject }) => idObject === modelId), +); + +const MAIN_MAP = 0; +export const mainMapModelSelector = createSelector(modelsDataSelector, models => models[MAIN_MAP]); diff --git a/src/redux/overlays/overlays.mock.ts b/src/redux/overlays/overlays.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a8037b6ba6e1ca5a7d096a71ae79a6f3c388fd5 --- /dev/null +++ b/src/redux/overlays/overlays.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { OverlaysState } from './overlays.types'; + +export const OVERLAYS_INITIAL_STATE_MOCK: OverlaysState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/project/project.mock.ts b/src/redux/project/project.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..036d26346ca92cb0a8108cb83b7c7584db660381 --- /dev/null +++ b/src/redux/project/project.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ProjectState } from './project.types'; + +export const PROJECT_STATE_INITIAL_MOCK: ProjectState = { + data: undefined, + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/reactions/reactions.mock.ts b/src/redux/reactions/reactions.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..f8d9614b5a568fb8af107b76bff3180766d31c46 --- /dev/null +++ b/src/redux/reactions/reactions.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ReactionsState } from './reactions.types'; + +export const REACTIONS_STATE_INITIAL_MOCK: ReactionsState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/root/init.selectors.ts b/src/redux/root/init.selectors.ts index 5095956a5633679600d67a86d458ecffb7227ac2..67cdfa08df4a16ab64f183a41c1c741ccc62bb74 100644 --- a/src/redux/root/init.selectors.ts +++ b/src/redux/root/init.selectors.ts @@ -13,7 +13,15 @@ export const initDataLoadingInitialized = createSelector( (...selectors) => selectors.every(selector => selector.loading !== 'idle'), ); -export const initDataLoadingFinished = createSelector( +export const initDataLoadingFinishedSelector = createSelector( + projectSelector, + backgroundsSelector, + modelsSelector, + overlaysSelector, + (...selectors) => selectors.every(selector => selector.loading === 'succeeded'), +); + +export const initDataAndMapLoadingFinished = createSelector( projectSelector, backgroundsSelector, modelsSelector, diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..c91e96efa82dbbd6ab9d2dd628fb4ff5f8770728 --- /dev/null +++ b/src/redux/root/init.thunks.ts @@ -0,0 +1,40 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { PROJECT_ID } from '@/constants'; +import { AppDispatch } from '@/redux/store'; +import { QueryData } from '@/types/query'; +import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; +import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; +import { getModels } from '../models/models.thunks'; +import { getProjectById } from '../project/project.thunks'; +import { + initMapBackground, + initMapPosition, + initMapSizeAndModelId, + initOpenedMaps, +} from '../map/map.thunks'; + +interface InitializeAppParams { + queryData: QueryData; +} + +export const fetchInitialAppData = createAsyncThunk< + void, + InitializeAppParams, + { dispatch: AppDispatch } +>('appInit/fetchInitialAppData', async ({ queryData }, { dispatch }): Promise<void> => { + /** Fetch all data required for renderin map */ + await Promise.all([ + dispatch(getProjectById(PROJECT_ID)), + dispatch(getAllBackgroundsByProjectId(PROJECT_ID)), + dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)), + dispatch(getModels()), + ]); + /** Set map properties to allow rendering. If map params (modelId,backgroundId,position) are not provided in query -> it will be set to map default */ + await Promise.all([ + dispatch(initMapSizeAndModelId({ queryData })), + dispatch(initMapPosition({ queryData })), + dispatch(initMapBackground({ queryData })), + ]); + /** Create tabs for maps / submaps */ + dispatch(initOpenedMaps({ queryData })); +}); diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..b310aceb4dd5c6ee53311b332ca00a6ef9b7963c --- /dev/null +++ b/src/redux/root/root.fixtures.ts @@ -0,0 +1,28 @@ +import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; +import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; +import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; +import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture'; +import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock'; +import { initialMapStateFixture } from '../map/map.fixtures'; +import { MIRNAS_INITIAL_STATE_MOCK } from '../mirnas/mirnas.mock'; +import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; +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 { RootState } from '../store'; + +export const INITIAL_STORE_STATE_MOCK: RootState = { + search: SEARCH_STATE_INITIAL_MOCK, + project: PROJECT_STATE_INITIAL_MOCK, + drugs: DRUGS_INITIAL_STATE_MOCK, + mirnas: MIRNAS_INITIAL_STATE_MOCK, + chemicals: CHEMICALS_INITIAL_STATE_MOCK, + models: MODELS_INITIAL_STATE_MOCK, + bioEntity: BIOENTITY_INITIAL_STATE_MOCK, + backgrounds: BACKGROUND_INITIAL_STATE_MOCK, + drawer: drawerInitialStateMock, + map: initialMapStateFixture, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + reactions: REACTIONS_STATE_INITIAL_MOCK, +}; diff --git a/src/redux/search/search.mock.ts b/src/redux/search/search.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..83aa9e6ce6b0602f9027f54ae9c29c36dfa1d40a --- /dev/null +++ b/src/redux/search/search.mock.ts @@ -0,0 +1,6 @@ +import { SearchState } from './search.types'; + +export const SEARCH_STATE_INITIAL_MOCK: SearchState = { + searchValue: '', + loading: 'idle', +}; diff --git a/src/types/query.ts b/src/types/query.ts index a715a34a3397f9f4bb7b2a3eaa7e82657d7d1463..bd9cb48cdd9983d0bde3e12c4b0bb63e2cceb6f3 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -13,3 +13,11 @@ export interface QueryDataParams { y?: number; z?: number; } + +export interface QueryDataRouterParams { + modelId?: string; + backgroundId?: string; + x?: string; + y?: string; + z?: string; +} diff --git a/src/utils/canvas/getCanvas.ts b/src/utils/canvas/getCanvas.ts new file mode 100644 index 0000000000000000000000000000000000000000..390bba041a91febfdbf7945eb97c0be8ac4bdb80 --- /dev/null +++ b/src/utils/canvas/getCanvas.ts @@ -0,0 +1,12 @@ +export const getCanvas = ({ + width, + height, +}: { + width: number; + height: number; +}): HTMLCanvasElement => { + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + return canvas; +}; diff --git a/src/utils/canvas/getFontSizeToFit.ts b/src/utils/canvas/getFontSizeToFit.ts new file mode 100644 index 0000000000000000000000000000000000000000..c74b4b0540cdb103bc4b1b79811250f76e33fac9 --- /dev/null +++ b/src/utils/canvas/getFontSizeToFit.ts @@ -0,0 +1,9 @@ +export const getFontSizeToFit = ( + ctx: CanvasRenderingContext2D, + text: string, + fontFace: string, + maxWidth: number, +): number => { + ctx.font = `1px ${fontFace}`; + return maxWidth / ctx.measureText(text).width; +}; diff --git a/src/utils/initialize/useInitializeStore.ts b/src/utils/initialize/useInitializeStore.ts index 207bd0ca713ab57733715d36e24c72aac3702b2d..9722dd6173ea1fe82164f422f0b7d6dc106a22de 100644 --- a/src/utils/initialize/useInitializeStore.ts +++ b/src/utils/initialize/useInitializeStore.ts @@ -1,43 +1,30 @@ -import { PROJECT_ID } from '@/constants'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { initMapData, initMapPosition } from '@/redux/map/map.thunks'; -import { getProjectById } from '@/redux/project/project.thunks'; -import { initDataLoadingInitialized } from '@/redux/root/init.selectors'; -import { AppDispatch } from '@/redux/store'; -import { QueryData } from '@/types/query'; import { useRouter } from 'next/router'; -import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; -import { getQueryData } from '../query-manager/getQueryData'; - -interface GetInitStoreDataArgs { - queryData: QueryData; -} +import { useEffect, useMemo } from 'react'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { + initDataLoadingFinishedSelector, + initDataLoadingInitialized, +} from '@/redux/root/init.selectors'; +import { fetchInitialAppData } from '@/redux/root/init.thunks'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { parseQueryToTypes } from '../parseQueryToTypes'; -/* prettier-ignore */ -export const getInitStoreData = - ({ queryData }: GetInitStoreDataArgs) => - (dispatch: AppDispatch): void => { - dispatch(getProjectById(PROJECT_ID)); - dispatch(initMapData({ queryData })); - dispatch(initMapPosition({ queryData })); - }; +/** + * 1. Initialise all required data before app starts: Project info, available Backgrounds, available Overlays, available Models (maps,submaps) + * 2. Based on that set required map data to correctly display view. If query params are available -> use them to set map data + */ export const useInitializeStore = (): void => { const dispatch = useAppDispatch(); - const isInitialized = useSelector(initDataLoadingInitialized); + const isInitialized = useAppSelector(initDataLoadingInitialized); + const isInitDataLoadingFinished = useAppSelector(initDataLoadingFinishedSelector); const { query, isReady: isRouterReady } = useRouter(); + const isQueryReady = useMemo(() => query && isRouterReady, [query, isRouterReady]); useEffect(() => { - const isQueryReady = query && isRouterReady; if (isInitialized || !isQueryReady) { return; } - - dispatch( - getInitStoreData({ - queryData: getQueryData(query), - }), - ); - }, [dispatch, query, isInitialized, isRouterReady]); + dispatch(fetchInitialAppData({ queryData: parseQueryToTypes(query) })); + }, [dispatch, isInitialized, query, isQueryReady, isInitDataLoadingFinished]); }; diff --git a/src/utils/map/getUpdatedMapData.ts b/src/utils/map/getUpdatedMapData.ts index dbfcf755181fbc08a8d066d71a22b66a2afaeacd..6baeeae09b8f1693b0605e6aafd4a9ba7ca460ec 100644 --- a/src/utils/map/getUpdatedMapData.ts +++ b/src/utils/map/getUpdatedMapData.ts @@ -1,10 +1,6 @@ import { DEFAULT_ZOOM } from '@/constants/map'; import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; -import { - MapData, - SetMapDataActionPayload, - SetMapPositionDataActionPayload, -} from '@/redux/map/map.types'; +import { GetUpdatedMapDataResult, MapData } from '@/redux/map/map.types'; import { MapBackground, MapModel } from '@/types/models'; import { DeepPartial } from '@reduxjs/toolkit'; import { getPointMerged } from '../object/getPointMerged'; @@ -15,8 +11,6 @@ interface GetUpdatedMapDataArgs { background?: MapBackground; } -export type GetUpdatedMapDataResult = SetMapDataActionPayload & SetMapPositionDataActionPayload; - const HALF = 2; export const getUpdatedMapData = ({ diff --git a/src/utils/parseQueryToTypes.test.ts b/src/utils/parseQueryToTypes.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8151e0c92e2ff939d747db75b3bc68d62f2ec2e0 --- /dev/null +++ b/src/utils/parseQueryToTypes.test.ts @@ -0,0 +1,28 @@ +import { parseQueryToTypes } from './parseQueryToTypes'; + +describe('parseQueryToTypes', () => { + it('should return valid data', () => { + expect({}).toEqual({}); + + expect(parseQueryToTypes({ modelId: '666' })).toEqual({ + modelId: 666, + backgroundId: undefined, + initialPosition: { x: undefined, y: undefined, z: undefined }, + }); + expect(parseQueryToTypes({ x: '2137' })).toEqual({ + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: 2137, y: undefined, z: undefined }, + }); + expect(parseQueryToTypes({ y: '1372' })).toEqual({ + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: undefined, y: 1372, z: undefined }, + }); + expect(parseQueryToTypes({ z: '3721' })).toEqual({ + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: undefined, y: undefined, z: 3721 }, + }); + }); +}); diff --git a/src/utils/parseQueryToTypes.ts b/src/utils/parseQueryToTypes.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1b3f297cb084fdc71c10e98798df7edbc5c084e --- /dev/null +++ b/src/utils/parseQueryToTypes.ts @@ -0,0 +1,11 @@ +import { QueryData, QueryDataRouterParams } from '@/types/query'; + +export const parseQueryToTypes = (query: QueryDataRouterParams): QueryData => ({ + modelId: Number(query.modelId) || undefined, + backgroundId: Number(query.backgroundId) || undefined, + initialPosition: { + x: Number(query.x) || undefined, + y: Number(query.y) || undefined, + z: Number(query.z) || undefined, + }, +}); diff --git a/src/utils/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts index 2963f999eaf62520de711a7684963b46df83f3e7..dc4bc4b6595a1966de2cabb7c3a54143e033ef42 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.test.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.test.ts @@ -1,4 +1,4 @@ -import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; import { Loading } from '@/types/loadingState'; import { renderHook, waitFor } from '@testing-library/react'; import mockRouter from 'next-router-mock'; @@ -10,7 +10,7 @@ describe('useReduxBusQueryManager - util', () => { const { Wrapper } = getReduxWrapperWithStore(); jest.mock('./../../redux/root/init.selectors', () => ({ - initDataLoadingFinished: jest.fn().mockImplementation(() => false), + initDataAndMapLoadingFinished: jest.fn().mockImplementation(() => false), })); it('should not update query', () => { @@ -51,6 +51,7 @@ describe('useReduxBusQueryManager - util', () => { }, }, }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, backgrounds: loadedDataMock, models: loadedDataMock, diff --git a/src/utils/query-manager/useReduxBusQueryManager.ts b/src/utils/query-manager/useReduxBusQueryManager.ts index 4ad04c417f2044f420782544930cffada60388b9..80d277dd03a6954af2085dbc98fe7c75cf169663 100644 --- a/src/utils/query-manager/useReduxBusQueryManager.ts +++ b/src/utils/query-manager/useReduxBusQueryManager.ts @@ -2,12 +2,12 @@ import { queryDataParamsSelector } from '@/redux/root/query.selectors'; import { useRouter } from 'next/router'; import { useCallback, useEffect } from 'react'; import { useSelector } from 'react-redux'; -import { initDataLoadingFinished } from '../../redux/root/init.selectors'; +import { initDataAndMapLoadingFinished } from '../../redux/root/init.selectors'; export const useReduxBusQueryManager = (): void => { const router = useRouter(); const queryData = useSelector(queryDataParamsSelector); - const isDataLoaded = useSelector(initDataLoadingFinished); + const isDataLoaded = useSelector(initDataAndMapLoadingFinished); const handleChangeQuery = useCallback( () => diff --git a/yarn.lock b/yarn.lock index 8c7c992b13f50fb1b7aea03170a5358cf03dfb1c..e5bd596f461837f6b40744e75a28af01684831e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -847,9 +847,9 @@ "resolved" "https://registry.npmjs.org/@next/font/-/font-13.5.4.tgz" "version" "13.5.4" -"@next/swc-darwin-arm64@13.4.19": - "integrity" "sha512-vv1qrjXeGbuF2mOkhkdxMDtv9np7W4mcBtaDnHU+yJG+bBwa6rYsYSCI/9Xm5+TuF5SbZbrWO6G1NfTh1TMjvQ==" - "resolved" "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.19.tgz" +"@next/swc-darwin-x64@13.4.19": + "integrity" "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==" + "resolved" "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz" "version" "13.4.19" "@next/swc-darwin-x64@13.4.19":