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/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/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/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 6ff16dd9408560c69b4e2d7a7baf3b117dcfbc3d..03c9a51438616ef42a331b543a19f732cc4a5c99 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/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/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/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..2826117268dd476e2d73a17f640269ad273bbc2e --- /dev/null +++ b/src/redux/map/map.fixtures.ts @@ -0,0 +1,34 @@ +import { MapData, 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, + }, +}; diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts index 7dc80074bba59f069adb94676a3634860a7d8ee1..e6cacdbc8d7dab2d5ed856098d0db344a35cc9c3 100644 --- a/src/redux/map/map.reducers.ts +++ b/src/redux/map/map.reducers.ts @@ -1,7 +1,16 @@ 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'; export const setMapDataReducer = (state: MapState, action: SetMapDataAction): void => { const payload = action.payload || {}; @@ -26,13 +35,58 @@ export const setMapPositionReducer = (state: MapState, action: SetMapPositionDat }; }; +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 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 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.data = { ...state.data, ...payload.data }; + state.openedMaps = payload.openedMaps; state.loading = 'succeeded'; }); builder.addCase(initMapData.rejected, state => { @@ -41,7 +95,7 @@ export const getMapReducers = (builder: ActionReducerMapBuilder<MapState>): void }); }; -export const getMapPositionReducers = (builder: ActionReducerMapBuilder<MapState>): void => { +export const initMapPositionReducers = (builder: ActionReducerMapBuilder<MapState>): void => { builder.addCase(initMapPosition.pending, state => { state.loading = 'pending'; }); 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..5c28c40e41bf546fd6ece105d1284951d0a39984 100644 --- a/src/redux/map/map.slice.ts +++ b/src/redux/map/map.slice.ts @@ -1,9 +1,13 @@ 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, + closeMapAndSetMainMapActiveReducer, + closeMapReducer, getMapReducers, + openMapAndSetActiveReducer, + setActiveMapReducer, setMapDataReducer, + initMapPositionReducers, setMapPositionReducer, } from './map.reducers'; import { MapState } from './map.types'; @@ -12,6 +16,7 @@ const initialState: MapState = { data: MAP_DATA_INITIAL_STATE, loading: 'idle', error: { name: '', message: '' }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }; const mapSlice = createSlice({ @@ -19,14 +24,25 @@ 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); }, }); -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..39b347d21fc1a0fe0f942b8fa54f9f9da0940476 100644 --- a/src/redux/map/map.thunks.test.ts +++ b/src/redux/map/map.thunks.test.ts @@ -61,10 +61,29 @@ describe('map thunks', () => { it('should return valid payload', () => { const FIRST = 0; - expect(payload).toMatchObject({ - modelId: modelsFixture[FIRST].idObject, - backgroundId: backgroundsFixture[FIRST].id, + data: { + modelId: modelsFixture[FIRST].idObject, + backgroundId: backgroundsFixture[FIRST].id, + size: { + width: 66.1207745783031, + height: -54.25165700726211, + tileSize: 85.73858779855072, + minZoom: 19.16961562819779, + maxZoom: 78.78634324297309, + }, + position: { + initial: { x: -47.612417908385396, y: -27.125828503631055, z: -97.42596028372645 }, + last: { x: -47.612417908385396, y: -27.125828503631055, z: -97.42596028372645 }, + }, + }, + openedMaps: [ + { + modelId: 63.59699326567352, + modelName: 'Main map', + lastPosition: { x: 0, y: 0, z: 0 }, + }, + ], }); }); }); @@ -88,8 +107,11 @@ describe('map thunks', () => { .payload as InitMapDataActionPayload; }); - it('should return empty payload', () => { - expect(payload).toStrictEqual({}); + it('should return empty values for data and openedMaps in payload', () => { + expect(payload).toStrictEqual({ + data: {}, + openedMaps: [{ modelId: 0, modelName: 'Main map', lastPosition: { x: 0, y: 0, z: 0 } }], + }); }); }); }); diff --git a/src/redux/map/map.thunks.ts b/src/redux/map/map.thunks.ts index 0ebf33265655e61b9e1ad0e382a479b9c8076a31..45251da5653023fb04184c79c696f170b8ffff8e 100644 --- a/src/redux/map/map.thunks.ts +++ b/src/redux/map/map.thunks.ts @@ -1,6 +1,8 @@ +/* eslint-disable no-magic-numbers */ import { PROJECT_ID } from '@/constants'; import { QueryData } from '@/types/query'; -import { GetUpdatedMapDataResult, getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; +import { ZERO } from '@/constants/common'; +import { getUpdatedMapData } from '@/utils/map/getUpdatedMapData'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { backgroundsDataSelector } from '../backgrounds/background.selectors'; import { getAllBackgroundsByProjectId } from '../backgrounds/backgrounds.thunks'; @@ -9,10 +11,13 @@ import { getModels } from '../models/models.thunks'; import { getAllPublicOverlaysByProjectId } from '../overlays/overlays.thunks'; import type { AppDispatch, RootState } from '../store'; import { + GetUpdatedMapDataResult, InitMapDataActionParams, InitMapDataActionPayload, + OppenedMap, SetMapPositionDataActionPayload, } from './map.types'; +import { DEFAULT_POSITION, MAIN_MAP } from './map.constants'; const getInitMapDataPayload = ( state: RootState, @@ -21,7 +26,7 @@ const getInitMapDataPayload = ( const FIRST = 0; const models = modelsDataSelector(state); const backgrounds = backgroundsDataSelector(state); - const modelId = queryData?.modelId || models?.[FIRST]?.idObject; + const modelId = queryData?.modelId || models?.[FIRST]?.idObject || ZERO; // TS does not get the type correctly. It might be undefined so fallback to 0 is needed const backgroundId = queryData?.backgroundId || backgrounds?.[FIRST]?.id; const model = models.find(({ idObject }) => idObject === modelId); const background = backgrounds.find(({ id }) => id === backgroundId); @@ -41,6 +46,18 @@ const getInitMapDataPayload = ( }); }; +const getUpdatedOpenedMapWithMainMap = (state: RootState): OppenedMap[] => { + const FIRST = 0; + const models = modelsDataSelector(state); + const mainMapId = models?.[FIRST]?.idObject || ZERO; + + const openedMaps: OppenedMap[] = [ + { modelId: mainMapId, modelName: MAIN_MAP, lastPosition: DEFAULT_POSITION }, + ]; + + return openedMaps; +}; + export const initMapData = createAsyncThunk< InitMapDataActionPayload, InitMapDataActionParams, @@ -55,18 +72,21 @@ export const initMapData = createAsyncThunk< ]); const state = getState(); - return getInitMapDataPayload(state, queryData); + const mapDataPayload = getInitMapDataPayload(state, queryData); + const openedMapsPayload = getUpdatedOpenedMapWithMainMap(state); + return { data: mapDataPayload, openedMaps: openedMapsPayload }; }, ); - export const initMapPosition = createAsyncThunk< - InitMapDataActionPayload, + SetMapPositionDataActionPayload, InitMapDataActionParams, { dispatch: AppDispatch; state: RootState } >( 'map/initMapPosition', - async ({ queryData }, { getState }): Promise<SetMapPositionDataActionPayload> => { + async ({ queryData }, { getState }): Promise<GetUpdatedMapDataResult | object> => { const state = getState(); - return getInitMapDataPayload(state, queryData); + + const mapDataPayload = getInitMapDataPayload(state, queryData); + return mapDataPayload; }, ); diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts index db099d1113ae97fda4e7186da1db597566aee802..c08d05a466a649dc7c22bca06e2c71ad6030ec15 100644 --- a/src/redux/map/map.types.ts +++ b/src/redux/map/map.types.ts @@ -11,6 +11,12 @@ export interface MapSize { maxZoom: number; } +export type OppenedMap = { + modelId: number; + modelName: string; + lastPosition: Point; +}; + export type MapData = { projectId: string; meshId: string; @@ -28,7 +34,7 @@ export type MapData = { }; }; -export type MapState = FetchDataState<MapData, MapData>; +export type MapState = FetchDataState<MapData, MapData> & { openedMaps: OppenedMap[] }; export type SetMapDataActionPayload = | (Omit<Partial<MapData>, 'position' | 'projectId'> & { @@ -36,17 +42,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 +73,19 @@ 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 +>; 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/utils/initialize/useInitializeStore.ts b/src/utils/initialize/useInitializeStore.ts index 207bd0ca713ab57733715d36e24c72aac3702b2d..6c620809c1eb16f4892a3da5695e8983ffbb01ae 100644 --- a/src/utils/initialize/useInitializeStore.ts +++ b/src/utils/initialize/useInitializeStore.ts @@ -19,6 +19,7 @@ export const getInitStoreData = ({ queryData }: GetInitStoreDataArgs) => (dispatch: AppDispatch): void => { dispatch(getProjectById(PROJECT_ID)); + // when app loads dispatch(initMapData({ queryData })); dispatch(initMapPosition({ queryData })); }; 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/query-manager/useReduxBusQueryManager.test.ts b/src/utils/query-manager/useReduxBusQueryManager.test.ts index 2963f999eaf62520de711a7684963b46df83f3e7..c79d8bc9201ac9e75e1520ef98139ace4a79e959 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'; @@ -51,6 +51,7 @@ describe('useReduxBusQueryManager - util', () => { }, }, }, + openedMaps: OPENED_MAPS_INITIAL_STATE, }, backgrounds: loadedDataMock, models: loadedDataMock,