diff --git a/setupTests.ts b/setupTests.ts index db87ae92bfff490a04acbd652192bbc003c28b03..e8c65391c7967eb34c7359010aaef4e93a7dd653 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -9,3 +9,26 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ })); jest.mock('next/router', () => require('next-router-mock')); + +const localStorageMock = (() => { + let store: { + [key: PropertyKey]: string; + } = {}; + + return { + getItem: jest.fn((key: PropertyKey) => store[key] || null), + setItem: jest.fn((key: PropertyKey, value: any) => { + store[key] = value.toString(); + }), + removeItem: jest.fn((key: PropertyKey) => { + delete store[key]; + }), + clear: jest.fn(() => { + store = {}; + }), + }; +})(); + +Object.defineProperty(global, 'localStorage', { + value: localStorageMock, +}); diff --git a/src/components/FunctionalArea/CookieBanner/CookieBanner.component.test.tsx b/src/components/FunctionalArea/CookieBanner/CookieBanner.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f4c495825584af08cf7ef7695cc7a969438a67df --- /dev/null +++ b/src/components/FunctionalArea/CookieBanner/CookieBanner.component.test.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { CookieBannerState } from '@/redux/cookieBanner/cookieBanner.types'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice'; +import { act } from 'react-dom/test-utils'; +import { CookieBanner } from './CookieBanner.component'; +import { + USER_ACCEPTED_COOKIES_COOKIE_NAME, + USER_ACCEPTED_COOKIES_COOKIE_VALUE, +} from './CookieBanner.constants'; + +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<CookieBannerState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('cookieBanner', cookieBannerReducer); + + return ( + render( + <Wrapper> + <CookieBanner /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('CookieBanner component', () => { + beforeEach(() => { + localStorage.clear(); + }); + it('renders cookie banner correctly first time', () => { + renderComponent(); + expect(localStorage.getItem).toHaveBeenCalledWith(USER_ACCEPTED_COOKIES_COOKIE_NAME); + + const button = screen.getByLabelText(/accept cookies/i); + expect(button).toBeInTheDocument(); + }); + + it('hides the banner after accepting cookies', () => { + renderComponent(); + const button = screen.getByLabelText(/accept cookies/i); + act(() => { + button.click(); + }); + + expect(button).not.toBeInTheDocument(); + expect(localStorage.setItem).toHaveBeenCalledWith( + USER_ACCEPTED_COOKIES_COOKIE_NAME, + USER_ACCEPTED_COOKIES_COOKIE_VALUE.ACCEPTED, + ); + }); + + it('does not render the cookies banner when cookies are accepted', () => { + renderComponent(); + + const button = screen.getByLabelText(/accept cookies/i); + expect(button).toBeInTheDocument(); + + act(() => { + button.click(); + }); + + renderComponent(); + + expect(localStorage.getItem).toHaveBeenCalledWith(USER_ACCEPTED_COOKIES_COOKIE_NAME); + expect(button).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/FunctionalArea/CookieBanner/CookieBanner.component.tsx b/src/components/FunctionalArea/CookieBanner/CookieBanner.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f27ac87c1a7192929a5a7276e599aa8eb1c60b65 --- /dev/null +++ b/src/components/FunctionalArea/CookieBanner/CookieBanner.component.tsx @@ -0,0 +1,59 @@ +import { selectCookieBanner } from '@/redux/cookieBanner/cookieBanner.selector'; +import { acceptCookies, showBanner } from '@/redux/cookieBanner/cookieBanner.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { Button } from '@/shared/Button'; +import Link from 'next/link'; +import { useEffect } from 'react'; +import { + USER_ACCEPTED_COOKIES_COOKIE_NAME, + USER_ACCEPTED_COOKIES_COOKIE_VALUE, +} from './CookieBanner.constants'; + +export const CookieBanner = (): React.ReactNode => { + const dispatch = useAppDispatch(); + const { visible, accepted } = useAppSelector(selectCookieBanner); + + useEffect(() => { + const isAccepted = + localStorage.getItem(USER_ACCEPTED_COOKIES_COOKIE_NAME) === + USER_ACCEPTED_COOKIES_COOKIE_VALUE.ACCEPTED; + if (isAccepted) { + dispatch(acceptCookies()); + } else { + dispatch(showBanner()); + } + }, [dispatch]); + + const handleAcceptCookies = (): void => { + dispatch(acceptCookies()); + localStorage.setItem( + USER_ACCEPTED_COOKIES_COOKIE_NAME, + USER_ACCEPTED_COOKIES_COOKIE_VALUE.ACCEPTED, + ); + }; + + if (!visible || accepted) { + return null; + } + + return ( + <div className="absolute bottom-8 left-1/2 z-10 -translate-x-1/2 rounded-lg bg-white p-6 drop-shadow"> + <h4 className="text-2xl font-bold">We use cookies!</h4> + <p className="my-4 leading-loose"> + Minerva platform uses essential cookies to ensure its proper operation. For any queries in + relation to our policy on cookies and your choices, please{' '} + <Link href="/" className="font-semibold text-[#1C00DE]"> + read here + </Link> + </p> + <Button + className="h-10 w-36 justify-center" + onClick={handleAcceptCookies} + aria-label="accept cookies" + > + Ok + </Button> + </div> + ); +}; diff --git a/src/components/FunctionalArea/CookieBanner/CookieBanner.constants.ts b/src/components/FunctionalArea/CookieBanner/CookieBanner.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..348c713c8438af90801986c556cd235696297d29 --- /dev/null +++ b/src/components/FunctionalArea/CookieBanner/CookieBanner.constants.ts @@ -0,0 +1,6 @@ +export const USER_ACCEPTED_COOKIES_COOKIE_NAME = 'cookiesAccepted'; + +export const USER_ACCEPTED_COOKIES_COOKIE_VALUE = { + ACCEPTED: 'true', + DECLINED: 'false', +}; diff --git a/src/components/FunctionalArea/CookieBanner/index.ts b/src/components/FunctionalArea/CookieBanner/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d9728d649f4d9c5fd385b3e267274acbd0791164 --- /dev/null +++ b/src/components/FunctionalArea/CookieBanner/index.ts @@ -0,0 +1 @@ +export { CookieBanner } from './CookieBanner.component'; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index ebcd9438755191d1d06fa402e12ebedb105ec0a4..0ba9c6dbcc2194991f63b9d1543fde988b3add77 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -5,6 +5,7 @@ import { useReduxBusQueryManager } from '@/utils/query-manager/useReduxBusQueryM import { twMerge } from 'tailwind-merge'; import { useInitializeStore } from '../../utils/initialize/useInitializeStore'; import { Modal } from '../FunctionalArea/Modal'; +import { CookieBanner } from '../FunctionalArea/CookieBanner'; export const MinervaSPA = (): JSX.Element => { useInitializeStore(); @@ -15,6 +16,7 @@ export const MinervaSPA = (): JSX.Element => { <FunctionalArea /> <Map /> <Modal /> + <CookieBanner /> </div> ); }; diff --git a/src/redux/cookieBanner/cookieBanner.mock.ts b/src/redux/cookieBanner/cookieBanner.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc8892b0f471d79ba36bffb11711f3a035b17b66 --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.mock.ts @@ -0,0 +1,6 @@ +import { CookieBannerState } from './cookieBanner.types'; + +export const COOKIE_BANNER_INITIAL_STATE_MOCK: CookieBannerState = { + visible: false, + accepted: false, +}; diff --git a/src/redux/cookieBanner/cookieBanner.reducers.test.ts b/src/redux/cookieBanner/cookieBanner.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4b274ae7d3f66b5f4bf455ea54e756165eb9b59 --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.reducers.test.ts @@ -0,0 +1,47 @@ +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { CookieBannerState } from './cookieBanner.types'; +import cookieBannerReducer, { acceptCookies, hideBanner, showBanner } from './cookieBanner.slice'; + +const INITIAL_STATE: CookieBannerState = { + accepted: false, + visible: false, +}; + +describe('cookieBanner reducers', () => { + let store = {} as ToolkitStoreWithSingleSlice<CookieBannerState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('cookieBanner', cookieBannerReducer); + }); + it('should match initial state', () => { + const action = { type: 'unknown' }; + expect(cookieBannerReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should handle showBanner action', () => { + store.dispatch(showBanner()); + + const { visible } = store.getState().cookieBanner; + expect(visible).toEqual(true); + }); + + it('should handle hideBanner action', () => { + store.dispatch(hideBanner()); + const { visible } = store.getState().cookieBanner; + + expect(visible).toEqual(false); + }); + + it('should handle acceptCookies action', () => { + store.dispatch(acceptCookies()); + + const expectedState: CookieBannerState = { + visible: false, + accepted: true, + }; + + const currentState = store.getState().cookieBanner; + expect(currentState).toEqual(expectedState); + }); +}); diff --git a/src/redux/cookieBanner/cookieBanner.reducers.ts b/src/redux/cookieBanner/cookieBanner.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..6992330caa449524ed7d701bd9c7564bf0fbcd3c --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.reducers.ts @@ -0,0 +1,14 @@ +import { CookieBannerState } from './cookieBanner.types'; + +export const showBannerReducer = (state: CookieBannerState): void => { + state.visible = true; +}; + +export const hideBannerReducer = (state: CookieBannerState): void => { + state.visible = false; +}; + +export const acceptCookiesReducer = (state: CookieBannerState): void => { + state.accepted = true; + state.visible = false; +}; diff --git a/src/redux/cookieBanner/cookieBanner.selector.ts b/src/redux/cookieBanner/cookieBanner.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..6601aeb961ce72399b9721b7ae19daf8ac58a4d2 --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.selector.ts @@ -0,0 +1,4 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const selectCookieBanner = createSelector(rootSelector, state => state.cookieBanner); diff --git a/src/redux/cookieBanner/cookieBanner.slice.ts b/src/redux/cookieBanner/cookieBanner.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..84edf3bca63bbca2b8fbd5b796d21d45949063ae --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.slice.ts @@ -0,0 +1,26 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { + acceptCookiesReducer, + hideBannerReducer, + showBannerReducer, +} from './cookieBanner.reducers'; +import { CookieBannerState } from './cookieBanner.types'; + +const initialState: CookieBannerState = { + visible: false, + accepted: false, +}; + +const cookieBannerSlice = createSlice({ + name: 'cookieBanner', + initialState, + reducers: { + showBanner: showBannerReducer, + hideBanner: hideBannerReducer, + acceptCookies: acceptCookiesReducer, + }, +}); + +export const { showBanner, hideBanner, acceptCookies } = cookieBannerSlice.actions; + +export default cookieBannerSlice.reducer; diff --git a/src/redux/cookieBanner/cookieBanner.types.ts b/src/redux/cookieBanner/cookieBanner.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a996adf411d20b3a8dd03d2426c091c0b657ddc --- /dev/null +++ b/src/redux/cookieBanner/cookieBanner.types.ts @@ -0,0 +1,4 @@ +export type CookieBannerState = { + accepted: boolean; + visible: boolean; +}; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index b4a62ed48d62f6479660876206a9c51c77b97578..07c1742f36c270a2ba9f69a11f92e05870864aac 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -1,6 +1,7 @@ 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 { COOKIE_BANNER_INITIAL_STATE_MOCK } from '../cookieBanner/cookieBanner.mock'; import { CONFIGURATION_INITIAL_STATE } from '../configuration/configuration.adapter'; import { initialStateFixture as drawerInitialStateMock } from '../drawer/drawerFixture'; import { DRUGS_INITIAL_STATE_MOCK } from '../drugs/drugs.mock'; @@ -30,5 +31,6 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { configuration: CONFIGURATION_INITIAL_STATE, overlayBioEntity: OVERLAY_BIO_ENTITY_INITIAL_STATE_MOCK, modal: MODAL_INITIAL_STATE_MOCK, + cookieBanner: COOKIE_BANNER_INITIAL_STATE_MOCK, user: USER_INITIAL_STATE_MOCK, }; diff --git a/src/redux/store.ts b/src/redux/store.ts index b20bb765db9076ea021ba29bb7e51d0c0f2b1e03..13b519f8e81a4265234621117bb1f7e9d1964669 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,6 +10,7 @@ import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; import searchReducer from '@/redux/search/search.slice'; +import cookieBannerReducer from '@/redux/cookieBanner/cookieBanner.slice'; import userReducer from '@/redux/user/user.slice'; import configurationReducer from '@/redux/configuration/configuration.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; @@ -35,6 +36,7 @@ export const reducers = { overlays: overlaysReducer, models: modelsReducer, reactions: reactionsReducer, + cookieBanner: cookieBannerReducer, user: userReducer, configuration: configurationReducer, overlayBioEntity: overlayBioEntityReducer,