diff --git a/src/components/FunctionalArea/Modal/Modal.component.test.tsx b/src/components/FunctionalArea/Modal/Modal.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62644f6644ee65f34abf71dcf5208dd7f195e542 --- /dev/null +++ b/src/components/FunctionalArea/Modal/Modal.component.test.tsx @@ -0,0 +1,85 @@ +import { MODAL_INITIAL_STATE } from '@/redux/modal/modal.constants'; +import { modalSelector } from '@/redux/modal/modal.selector'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { Modal } from './Modal.component'; + +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); + return ( + render( + <Wrapper> + <Modal /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('Modal - Component', () => { + describe('when modal is hidden', () => { + beforeEach(() => { + renderComponent({ + modal: { + ...MODAL_INITIAL_STATE, + isOpen: false, + modalTitle: 'Modal Hidden Title', + }, + }); + }); + + it('should modal have hidden class', () => { + const modalElement = screen.getByRole('modal'); + + expect(modalElement).toBeInTheDocument(); + expect(modalElement).toHaveClass('hidden'); + }); + }); + + describe('when modal is shown', () => { + let store: StoreType; + + beforeEach(() => { + const { store: newStore } = renderComponent({ + modal: { + ...MODAL_INITIAL_STATE, + isOpen: true, + modalTitle: 'Modal Opened Title', + }, + }); + + store = newStore; + }); + + it('should modal NOT have hidden class', () => { + const modalElement = screen.getByRole('modal'); + + expect(modalElement).toBeInTheDocument(); + expect(modalElement).not.toHaveClass('hidden'); + }); + + it('shows modal title', () => { + expect(screen.getByText('Modal Opened Title', { exact: false })).toBeInTheDocument(); + }); + + it('shows modal close button', () => { + expect(screen.getByLabelText('close button')).toBeInTheDocument(); + }); + + it('closes modal on close button click', () => { + const closeButton = screen.getByLabelText('close button'); + + closeButton.click(); + + const { isOpen } = modalSelector(store.getState()); + + expect(isOpen).toBeFalsy(); + }); + }); +}); diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4ca533eda91cab87734412be669566e14aed77ae --- /dev/null +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -0,0 +1,39 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { modalSelector } from '@/redux/modal/modal.selector'; +import { closeModal } from '@/redux/modal/modal.slice'; +import { Icon } from '@/shared/Icon'; +import { twMerge } from 'tailwind-merge'; +import { MODAL_ROLE } from './Modal.constants'; +import { OverviewImagesModal } from './OverviewImagesModal'; + +export const Modal = (): React.ReactNode => { + const dispatch = useAppDispatch(); + const { isOpen, modalName, modalTitle } = useAppSelector(modalSelector); + + const handleCloseModal = (): void => { + dispatch(closeModal()); + }; + + return ( + <div + className={twMerge( + 'absolute left-0 top-0 z-10 h-full w-full bg-cetacean-blue/[.48]', + isOpen ? '' : 'hidden', + )} + role={MODAL_ROLE} + > + <div className="flex h-full w-full items-center justify-center"> + <div className="flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg"> + <div className="flex items-center justify-between bg-white p-[24px] text-xl"> + <div>{modalTitle}</div> + <button type="button" onClick={handleCloseModal} aria-label="close button"> + <Icon name="close" className="fill-font-500" /> + </button> + </div> + {isOpen && modalName === 'overview-images' && <OverviewImagesModal />} + </div> + </div> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/Modal.constants.ts b/src/components/FunctionalArea/Modal/Modal.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..b31cdcfb5d1c572a9a9bc64409d465267c937779 --- /dev/null +++ b/src/components/FunctionalArea/Modal/Modal.constants.ts @@ -0,0 +1 @@ +export const MODAL_ROLE = 'modal'; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29c97f248fc1fbefe6fab9c85a89fee2c2461c0b --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/OverviewImagesModal.component.tsx @@ -0,0 +1,5 @@ +import * as React from 'react'; + +export const OverviewImagesModal: React.FC = () => { + return <div className="h-[200px] w-[500px] bg-white " />; +}; diff --git a/src/components/FunctionalArea/Modal/OverviewImagesModal/index.ts b/src/components/FunctionalArea/Modal/OverviewImagesModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac9770703829e7c1a497cb41bd1f70be03b17c59 --- /dev/null +++ b/src/components/FunctionalArea/Modal/OverviewImagesModal/index.ts @@ -0,0 +1 @@ +export { OverviewImagesModal } from './OverviewImagesModal.component'; diff --git a/src/components/FunctionalArea/Modal/index.ts b/src/components/FunctionalArea/Modal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b0252805da7d8464ee7be91501219c1544a97310 --- /dev/null +++ b/src/components/FunctionalArea/Modal/index.ts @@ -0,0 +1 @@ +export { Modal } from './Modal.component'; diff --git a/src/components/Map/Map.component.tsx b/src/components/Map/Map.component.tsx index 27cfcff4ab4b05cde8055df31095e7deddc31739..90492655373b3e87dd2a97428207bd356f5c6525 100644 --- a/src/components/Map/Map.component.tsx +++ b/src/components/Map/Map.component.tsx @@ -1,6 +1,6 @@ import { Drawer } from '@/components/Map/Drawer'; -import { MapViewer } from './MapViewer/MapViewer.component'; import { MapAdditionalOptions } from './MapAdditionalOptions'; +import { MapViewer } from './MapViewer/MapViewer.component'; export const Map = (): JSX.Element => ( <div className="relative z-0 h-screen w-full bg-black" data-testid="map-container"> diff --git a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx index 1dfdf365a4f01fe8a1ef1ecba56dee2045c99d57..4fddb0e309317922694a548ab88f3ecb5422d23a 100644 --- a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx +++ b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.test.tsx @@ -1,9 +1,17 @@ -import { StoreType } from '@/redux/store'; -import { render, screen } from '@testing-library/react'; +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { + BACKGROUNDS_MOCK, + BACKGROUND_INITIAL_STATE_MOCK, +} from '@/redux/backgrounds/background.mock'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { AppDispatch, RootState, StoreType } from '@/redux/store'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { InitialStoreState, getReduxWrapperWithStore, } from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { MockStoreEnhanced } from 'redux-mock-store'; import { MapAdditionalOptions } from './MapAdditionalOptions.component'; const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { @@ -21,9 +29,47 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St ); }; +const renderComponentWithActionListener = ( + initialStoreState: InitialStoreState = {}, +): { store: MockStoreEnhanced<Partial<RootState>, AppDispatch> } => { + const { Wrapper, store } = getReduxStoreWithActionsListener(initialStoreState); + + return ( + render( + <Wrapper> + <MapAdditionalOptions /> + </Wrapper>, + ), + { + store, + } + ); +}; + describe('MapAdditionalOptions - component', () => { it('should display background selector', () => { renderComponent(); expect(screen.getByTestId('background-selector')).toBeInTheDocument(); }); + + it('should render browse overview images button', () => { + renderComponent(); + expect(screen.getByText('Browse overview images')).toBeInTheDocument(); + }); + + it('should open overview image modal on button click', () => { + const { store } = renderComponentWithActionListener({ + map: initialMapStateFixture, + backgrounds: { ...BACKGROUND_INITIAL_STATE_MOCK, data: BACKGROUNDS_MOCK }, + }); + + const overviewImageButton = screen.getByText('Browse overview images'); + overviewImageButton.click(); + + const actions = store.getActions(); + expect(actions[FIRST_ARRAY_ELEMENT]).toStrictEqual({ + payload: undefined, + type: 'modal/openOverviewImagesModal', + }); + }); }); diff --git a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.tsx b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.tsx index a7845ac5ea8635064290ac1edfefdb289215b57e..1435e5584ba55490c2b4d1c66d6460536f120db9 100644 --- a/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.tsx +++ b/src/components/Map/MapAdditionalOptions/MapAdditionalOptions.component.tsx @@ -1,10 +1,24 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { openOverviewImagesModal } from '@/redux/modal/modal.slice'; +import { Button } from '@/shared/Button'; import { twMerge } from 'tailwind-merge'; import { BackgroundSelector } from './BackgroundsSelector'; // top-[calc(64px+40px+24px)] -> TOP_BAR_HEIGHT+MAP_NAVIGATION_HEIGHT+DISTANCE_FROM_MAP_NAVIGATION -export const MapAdditionalOptions = (): JSX.Element => ( - <div className={twMerge('absolute right-6 top-[calc(64px+40px+24px)] z-10')}> - <BackgroundSelector /> - </div> -); +export const MapAdditionalOptions = (): JSX.Element => { + const dispatch = useAppDispatch(); + + const handleBrowseOverviewImagesClick = (): void => { + dispatch(openOverviewImagesModal()); + }; + + return ( + <div className={twMerge('absolute right-6 top-[calc(64px+40px+24px)] z-10 flex')}> + <Button className="mr-4" onClick={handleBrowseOverviewImagesClick}> + Browse overview images + </Button> + <BackgroundSelector /> + </div> + ); +}; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index 3376b1ca88b4dbbd938d828d5e69b95675af1aad..ebcd9438755191d1d06fa402e12ebedb105ec0a4 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -4,6 +4,7 @@ import { manrope } from '@/constants/font'; import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryManager'; import { twMerge } from 'tailwind-merge'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; +import { Modal } from '../FunctionalArea/Modal'; export const MinervaSPA = (): JSX.Element => { useInitializeStore(); @@ -13,6 +14,7 @@ export const MinervaSPA = (): JSX.Element => { <div className={twMerge('relative', manrope.variable)}> <FunctionalArea /> <Map /> + <Modal /> </div> ); }; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..90ee066d340c2a6dde4b423d6f844dbbed82bbcc --- /dev/null +++ b/src/redux/modal/modal.constants.ts @@ -0,0 +1,7 @@ +import { ModalState } from './modal.types'; + +export const MODAL_INITIAL_STATE: ModalState = { + isOpen: false, + modalName: 'none', + modalTitle: '', +}; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..f145fba0355bec8fd70ee9bfd73431adf8c48759 --- /dev/null +++ b/src/redux/modal/modal.mock.ts @@ -0,0 +1,7 @@ +import { ModalState } from './modal.types'; + +export const MODAL_INITIAL_STATE_MOCK: ModalState = { + isOpen: false, + modalName: 'none', + modalTitle: '', +}; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..17687aca85709fada1de1ad607ab063f17ac3f69 --- /dev/null +++ b/src/redux/modal/modal.reducers.ts @@ -0,0 +1,19 @@ +import { ModalName } from '@/types/modal'; +import { PayloadAction } from '@reduxjs/toolkit'; +import { ModalState } from './modal.types'; + +export const openModalReducer = (state: ModalState, action: PayloadAction<ModalName>): void => { + state.isOpen = true; + state.modalName = action.payload; +}; + +export const closeModalReducer = (state: ModalState): void => { + state.isOpen = false; + state.modalName = 'none'; +}; + +export const openOverviewImagesModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'overview-images'; + state.modalTitle = 'Overview images'; +}; diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..6221d93ca0f02dfebb5af2e2455ff31808c8a793 --- /dev/null +++ b/src/redux/modal/modal.selector.ts @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const modalSelector = createSelector(rootSelector, state => state.modal); + +export const isModalOpenSelector = createSelector(modalSelector, state => state.isOpen); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ba21287e7cad925a28f88135950b5c8629d2143 --- /dev/null +++ b/src/redux/modal/modal.slice.ts @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { MODAL_INITIAL_STATE } from './modal.constants'; +import { + closeModalReducer, + openModalReducer, + openOverviewImagesModalReducer, +} from './modal.reducers'; + +const modalSlice = createSlice({ + name: 'modal', + initialState: MODAL_INITIAL_STATE, + reducers: { + openModal: openModalReducer, + closeModal: closeModalReducer, + openOverviewImagesModal: openOverviewImagesModalReducer, + }, +}); + +export const { openModal, closeModal, openOverviewImagesModal } = modalSlice.actions; + +export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..77b0e71fabc18cd84b1df6145d393d4b134325c6 --- /dev/null +++ b/src/redux/modal/modal.types.ts @@ -0,0 +1,7 @@ +import { ModalName } from '@/types/modal'; + +export interface ModalState { + isOpen: boolean; + modalName: ModalName; + modalTitle: string; +} diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index bf70be504fd7054b479dc020bdcc052654ecd349..0d592254a3d1b9d746425aa9dffd1d2b8d4c239a 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -4,6 +4,7 @@ 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 { MODAL_INITIAL_STATE_MOCK } from '../modal/modal.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'; @@ -23,4 +24,5 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { map: initialMapStateFixture, overlays: OVERLAYS_INITIAL_STATE_MOCK, reactions: REACTIONS_STATE_INITIAL_MOCK, + modal: MODAL_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index d98c85c37d21d8db4d07fa4c8ba5e9dbe7ab8056..e60dd6df4fc4ff82f442c87b317179e45d3eb00f 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -4,6 +4,7 @@ import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; import drugsReducer from '@/redux/drugs/drugs.slice'; import mapReducer from '@/redux/map/map.slice'; +import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; @@ -25,6 +26,7 @@ export const reducers = { chemicals: chemicalsReducer, bioEntity: bioEntityReducer, drawer: drawerReducer, + modal: modalReducer, map: mapReducer, backgrounds: backgroundsReducer, overlays: overlaysReducer, diff --git a/src/types/modal.ts b/src/types/modal.ts new file mode 100644 index 0000000000000000000000000000000000000000..edfac1fd79b3807f8832ed64e269e612d4d816c7 --- /dev/null +++ b/src/types/modal.ts @@ -0,0 +1 @@ +export type ModalName = 'none' | 'overview-images'; diff --git a/tailwind.config.ts b/tailwind.config.ts index 62c3f4f71cfba9d10d68444821d10e04a1366db3..75ce9dc54480bc8bb8e9b0da255d2e772bec2ac2 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -28,6 +28,7 @@ const config: Config = { orange: '#f48c40', purple: '#6400e3', pink: '#f1009f', + 'cetacean-blue': '#070130', }, height: { 'calc-drawer': 'calc(100% - 104px)',