diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index b47a4bf5b2403b606a53a5d22f77e5c0416e3a13..1361a03e17568ddd5ed5576fcfb06b444d8776a0 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -2,7 +2,10 @@ import lensIcon from '@/assets/vectors/icons/lens.svg'; import { isDrawerOpenSelector } from '@/redux/drawer/drawer.selectors'; import { openSearchDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { isPendingSearchStatusSelector } from '@/redux/search/search.selectors'; +import { + isPendingSearchStatusSelector, + perfectMatchSelector, +} from '@/redux/search/search.selectors'; import { getSearchData } from '@/redux/search/search.thunks'; import Image from 'next/image'; import { ChangeEvent, KeyboardEvent, useEffect, useState } from 'react'; @@ -15,6 +18,7 @@ const ENTER_KEY_CODE = 'Enter'; export const SearchBar = (): JSX.Element => { const isPendingSearchStatus = useSelector(isPendingSearchStatusSelector); const isDrawerOpen = useSelector(isDrawerOpenSelector); + const isPerfectMatch = useSelector(perfectMatchSelector); const [searchValue, setSearchValue] = useState<string>(''); const dispatch = useAppDispatch(); const { query } = useRouter(); @@ -37,7 +41,7 @@ export const SearchBar = (): JSX.Element => { const onSearchClick = (): void => { const searchValues = getSearchValuesArrayAndTrimToSeven(searchValue); - dispatch(getSearchData(searchValues)); + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })); openSearchDrawerIfClosed(); }; @@ -45,7 +49,7 @@ export const SearchBar = (): JSX.Element => { const searchValues = getSearchValuesArrayAndTrimToSeven(searchValue); if (event.code === ENTER_KEY_CODE) { - dispatch(getSearchData(searchValues)); + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })); openSearchDrawerIfClosed(); } }; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx index 28aac2b5b95fc3ef1c86b687a6cc1ad63c2631f3..8301ff6cb2cffc776db895a3346d28f1f8011e52 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx @@ -2,9 +2,6 @@ import { BioEntitiesAccordion } from '@/components/Map/Drawer/SearchDrawerWrappe import { DrugsAccordion } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion'; import { ChemicalsAccordion } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion'; import { MirnaAccordion } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion'; -import { closeDrawer } from '@/redux/drawer/drawer.slice'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { IconButton } from '@/shared/IconButton'; import { Accordion } from '@/shared/Accordion'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { searchValueSelector } from '@/redux/search/search.selectors'; @@ -12,13 +9,8 @@ import { searchValueSelector } from '@/redux/search/search.selectors'; export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; export const GroupedSearchResults = (): JSX.Element => { - const dispatch = useAppDispatch(); const searchValue = useAppSelector(searchValueSelector); - const handleCloseDrawer = (): void => { - dispatch(closeDrawer()); - }; - return ( <div className="flex flex-col" data-testid="grouped-search-results"> <div className="flex items-center justify-between border-b border-b-divide px-6"> @@ -26,13 +18,6 @@ export const GroupedSearchResults = (): JSX.Element => { <span className="font-normal">Search: </span> <span className="font-semibold">{searchValue}</span> </div> - <IconButton - className="bg-white-pearl" - classNameIcon="fill-font-500" - icon="close" - role={CLOSE_BUTTON_ROLE} - onClick={handleCloseDrawer} - /> </div> <div className="px-6"> <Accordion allowZeroExpanded> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2631f91b3e40514399ee92ab44cc6c2ccf1f5cc7 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.test.tsx @@ -0,0 +1,65 @@ +import { act } from 'react-dom/test-utils'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { render, screen } from '@testing-library/react'; +import { SEARCH_STATE_INITIAL_MOCK } from '@/redux/search/search.mock'; +import { PerfectMatchSwitch } from './PerfectMatchSwitch.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <PerfectMatchSwitch /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('PerfectMatchSwitch - component', () => { + it('should initialy be set to false when perfectMatch is not in query or set to false', () => { + renderComponent({ search: SEARCH_STATE_INITIAL_MOCK }); + + const checkbox = screen.getByRole<HTMLInputElement>('checkbox'); + + expect(checkbox.checked).toBe(false); + }); + it('should initialy be set to true when perfectMatch query is set to true', () => { + renderComponent({ search: { ...SEARCH_STATE_INITIAL_MOCK, perfectMatch: true } }); + + const checkbox = screen.getByRole<HTMLInputElement>('checkbox'); + + expect(checkbox.checked).toBe(true); + }); + it('should set checkbox to true and update store', async () => { + const { store } = renderComponent({ search: SEARCH_STATE_INITIAL_MOCK }); + expect(store.getState().search.perfectMatch).toBe(false); + + const checkbox = screen.getByRole<HTMLInputElement>('checkbox'); + act(() => { + checkbox.click(); + }); + + expect(store.getState().search.perfectMatch).toBe(true); + }); + it('should set checkbox to false and update store', async () => { + const { store } = renderComponent({ + search: { ...SEARCH_STATE_INITIAL_MOCK, perfectMatch: true }, + }); + expect(store.getState().search.perfectMatch).toBe(true); + + const checkbox = screen.getByRole<HTMLInputElement>('checkbox'); + act(() => { + checkbox.click(); + }); + + expect(store.getState().search.perfectMatch).toBe(false); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1e38a26326a0d6e59b01de2302bac725f3be7320 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.component.tsx @@ -0,0 +1,31 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { perfectMatchSelector } from '@/redux/search/search.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { setPerfectMatch } from '@/redux/search/search.slice'; +import { ChangeEvent } from 'react'; + +export const PerfectMatchSwitch = (): JSX.Element => { + const dispatch = useAppDispatch(); + const isChecked = useAppSelector(perfectMatchSelector); + + const setChecked = (event: ChangeEvent<HTMLInputElement>): void => { + dispatch(setPerfectMatch(event.target.checked)); + }; + + return ( + <div className="mr-6 flex items-center"> + <span className="mr-2 text-sm font-bold">Perfect match</span> + <label className="relative inline-flex cursor-pointer items-center"> + <input + type="checkbox" + value="" + className="peer sr-only" + checked={isChecked} + onChange={setChecked} + /> + <div className="peer h-6 w-11 rounded-full bg-greyscale-500 after:absolute after:start-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked:bg-med-sea-green peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:outline-none rtl:peer-checked:after:-translate-x-full" /> + </label> + </div> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.module.css b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.module.css new file mode 100644 index 0000000000000000000000000000000000000000..bb6b3d11c31140f1363a0a3bb630227b558c770d --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/PerfectMatchSwitch.module.css @@ -0,0 +1,9 @@ +.toggle-checkbox:checked { + @apply: right-0 border-green-400; + right: 0; + border-color: #68d391; +} +.toggle-checkbox:checked + .toggle-label { + @apply: bg-green-400; + background-color: #68d391; +} diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1d3e60daba49666d30969595780fdac1a683c313 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/PerfectMatchSwitch/index.ts @@ -0,0 +1 @@ +export { PerfectMatchSwitch } from './PerfectMatchSwitch.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/SearchDrawerHeader.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/SearchDrawerHeader.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94800b8d5c451c50f0c4d6d231c3380fbd66f3e6 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/SearchDrawerHeader.component.tsx @@ -0,0 +1,32 @@ +import { closeDrawer } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { CLOSE_BUTTON_ROLE } from '@/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.constants'; +import { IconButton } from '@/shared/IconButton'; +import { PerfectMatchSwitch } from './PerfectMatchSwitch'; + +export const SearchDrawerHeader = (): JSX.Element => { + const dispatch = useAppDispatch(); + + const handleCloseDrawer = (): void => { + dispatch(closeDrawer()); + }; + + return ( + <div + data-testid="search-drawer-header" + className="flex flex-row flex-nowrap items-center justify-between p-6 text-xl font-bold" + > + <p>Search results</p> + <div className="flex flex-row items-center"> + <PerfectMatchSwitch /> + <IconButton + className="bg-white-pearl" + classNameIcon="fill-font-500" + icon="close" + role={CLOSE_BUTTON_ROLE} + onClick={handleCloseDrawer} + /> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..1da49cd9c067a191e18ac16422b0bfdffa6cc079 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerHeader/index.ts @@ -0,0 +1 @@ +export { SearchDrawerHeader } from './SearchDrawerHeader.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx index 23aa97631a454e639c903932d0b297ad644b60ce..2b48f8c790dae592830355e95da66f87fa4ea3b2 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx @@ -7,6 +7,7 @@ import { import { useSelector } from 'react-redux'; import { ResultsList } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList'; import { GroupedSearchResults } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults'; +import { SearchDrawerHeader } from './SearchDrawerHeader'; export const SearchDrawerWrapper = (): JSX.Element => { const currentStep = useSelector(currentStepDrawerStateSelector); @@ -16,27 +17,30 @@ export const SearchDrawerWrapper = (): JSX.Element => { const isChemicalsDrugsOrMirnaType = DRUGS_CHEMICALS_MIRNA.includes(stepType); return ( - <div data-testid="search-drawer-content"> - {/* first step for displaying search results, drawers etc */} - {currentStep === STEP.FIRST && <GroupedSearchResults />} - {/* 2nd step for bioEntities aka content */} - {currentStep === STEP.SECOND && isBioEntityType && ( - <div data-testid="search-second-step">The second step</div> - )} - {/* 2nd step for drugs,chemicals,mirna */} - {currentStep === STEP.SECOND && isChemicalsDrugsOrMirnaType && ( - <div data-testid="search-second-step"> - <ResultsList /> - </div> - )} - {/* last step for bioentity */} - {currentStep === STEP.THIRD && isBioEntityType && ( - <div data-testid="search-third-step">The third step</div> - )} - {/* last step for drugs,chemicals,mirna */} - {currentStep === STEP.THIRD && isChemicalsDrugsOrMirnaType && ( - <div data-testid="search-third-step">The third step</div> - )} - </div> + <> + <SearchDrawerHeader /> + <div data-testid="search-drawer-content"> + {/* first step for displaying search results, drawers etc */} + {currentStep === STEP.FIRST && <GroupedSearchResults />} + {/* 2nd step for bioEntities aka content */} + {currentStep === STEP.SECOND && isBioEntityType && ( + <div data-testid="search-second-step">The second step</div> + )} + {/* 2nd step for drugs,chemicals,mirna */} + {currentStep === STEP.SECOND && isChemicalsDrugsOrMirnaType && ( + <div data-testid="search-second-step"> + <ResultsList /> + </div> + )} + {/* last step for bioentity */} + {currentStep === STEP.THIRD && isBioEntityType && ( + <div data-testid="search-third-step">The third step</div> + )} + {/* last step for drugs,chemicals,mirna */} + {currentStep === STEP.THIRD && isChemicalsDrugsOrMirnaType && ( + <div data-testid="search-third-step">The third step</div> + )} + </div> + </> ); }; diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts index d23bffc005c9cb4587b70e60f574aee2720198af..cff53cc7dea23bfe0c0b2519cd9ed4e0fccfe770 100644 --- a/src/redux/apiPath.test.ts +++ b/src/redux/apiPath.test.ts @@ -15,8 +15,15 @@ describe('api path', () => { }); it('should return url string for bio entity content', () => { - expect(apiPath.getBioEntityContentsStringWithQuery('park7')).toBe( - `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000`, + expect( + apiPath.getBioEntityContentsStringWithQuery({ searchQuery: 'park7', isPerfectMatch: false }), + ).toBe( + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000&perfectMatch=false`, + ); + expect( + apiPath.getBioEntityContentsStringWithQuery({ searchQuery: 'park7', isPerfectMatch: true }), + ).toBe( + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000&perfectMatch=true`, ); }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 776993edc272561e3cdead6f3d8b6fc2f3e58706..d52b027dab73bbc5ec153a67bb3742d08506fa2b 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -1,8 +1,12 @@ import { PROJECT_ID } from '@/constants'; +import { PerfectSearchParams } from '@/types/search'; export const apiPath = { - getBioEntityContentsStringWithQuery: (searchQuery: string): string => - `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000`, + getBioEntityContentsStringWithQuery: ({ + searchQuery, + isPerfectMatch, + }: PerfectSearchParams): string => + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000&perfectMatch=${isPerfectMatch}`, getDrugsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, getMirnasStringWithQuery: (searchQuery: string): string => diff --git a/src/redux/bioEntity/bioEntity.reducers.test.ts b/src/redux/bioEntity/bioEntity.reducers.test.ts index 6708cd6d0fbcf9136383dc1448bd288267999145..52062b406da85f623f5d3a273ead35a9dabb59ae 100644 --- a/src/redux/bioEntity/bioEntity.reducers.test.ts +++ b/src/redux/bioEntity/bioEntity.reducers.test.ts @@ -34,10 +34,20 @@ describe('bioEntity reducer', () => { it('should update store after succesfull getBioEntity query', async () => { mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const { type } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { type } = await store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); const { data } = store.getState().bioEntity; const bioEnityWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, @@ -54,10 +64,20 @@ describe('bioEntity reducer', () => { it('should update store after failed getBioEntity query', async () => { mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) .reply(HttpStatusCode.NotFound, bioEntityResponseFixture); - const { type } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { type } = await store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); const { data } = store.getState().bioEntity; const bioEnityWithSearchElement = data.find( @@ -74,10 +94,20 @@ describe('bioEntity reducer', () => { it('should update store on loading getBioEntity query', async () => { mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const bioEntityContentsPromise = store.dispatch(getBioEntity(SEARCH_QUERY)); + const bioEntityContentsPromise = store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); const { data } = store.getState().bioEntity; const bioEnityWithSearchElement = data.find( diff --git a/src/redux/bioEntity/bioEntity.reducers.ts b/src/redux/bioEntity/bioEntity.reducers.ts index 47c4cf71db689d347e19cfc2e3805ed8152b9962..e5e5acd762879a6a30ec579cdc06a3485a0b6ba7 100644 --- a/src/redux/bioEntity/bioEntity.reducers.ts +++ b/src/redux/bioEntity/bioEntity.reducers.ts @@ -8,7 +8,7 @@ export const getBioEntityContentsReducer = ( ): void => { builder.addCase(getBioEntity.pending, (state, action) => { state.data.push({ - searchQueryElement: action.meta.arg, + searchQueryElement: action.meta.arg.searchQuery, data: undefined, loading: 'pending', error: DEFAULT_ERROR, @@ -16,7 +16,7 @@ export const getBioEntityContentsReducer = ( }); builder.addCase(getBioEntity.fulfilled, (state, action) => { const bioEntities = state.data.find( - bioEntity => bioEntity.searchQueryElement === action.meta.arg, + bioEntity => bioEntity.searchQueryElement === action.meta.arg.searchQuery, ); if (bioEntities) { bioEntities.data = action.payload; @@ -24,7 +24,9 @@ export const getBioEntityContentsReducer = ( } }); builder.addCase(getBioEntity.rejected, (state, action) => { - const chemicals = state.data.find(chemical => chemical.searchQueryElement === action.meta.arg); + const chemicals = state.data.find( + chemical => chemical.searchQueryElement === action.meta.arg.searchQuery, + ); if (chemicals) { chemicals.loading = 'failed'; // TODO: error management to be discussed in the team diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index b5b51d289aa49fa406b619e3e1fa1924a5afd9f1..1f60c7d169fb2c12583a1722c0b31a4d41047fbb 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -21,18 +21,38 @@ describe('bioEntityContents thunks', () => { describe('getBioEntityContents', () => { it('should return data when data response from API is valid', async () => { mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { payload } = await store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); expect(payload).toEqual(bioEntityResponseFixture.content); }); it('should return undefined when data response from API is not valid ', async () => { mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); - const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { payload } = await store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); expect(payload).toEqual(undefined); }); }); diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts index cc91d21b489d792f5aeecc67c39b06f6c19b027b..7ecbc82a2935bf096b0645e0bf59c7ab0db21b56 100644 --- a/src/redux/bioEntity/bioEntity.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -1,3 +1,4 @@ +import { PerfectMultiSearchParams, PerfectSearchParams } from '@/types/search'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse } from '@/types/models'; @@ -5,11 +6,16 @@ import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { apiPath } from '@/redux/apiPath'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; +type GetBioEntityProps = PerfectSearchParams; + export const getBioEntity = createAsyncThunk( 'project/getBioEntityContents', - async (searchQuery: string): Promise<BioEntityContent[] | undefined> => { + async ({ + searchQuery, + isPerfectMatch, + }: GetBioEntityProps): Promise<BioEntityContent[] | undefined> => { const response = await axiosInstanceNewAPI.get<BioEntityResponse>( - apiPath.getBioEntityContentsStringWithQuery(searchQuery), + apiPath.getBioEntityContentsStringWithQuery({ searchQuery, isPerfectMatch }), ); const isDataValid = validateDataUsingZodSchema(response.data, bioEntityResponseSchema); @@ -18,11 +24,16 @@ export const getBioEntity = createAsyncThunk( }, ); +type GetMultiBioEntityProps = PerfectMultiSearchParams; + export const getMultiBioEntity = createAsyncThunk( 'project/getMultiBioEntity', - async (searchQueries: string[], { dispatch }): Promise<void> => { + async ( + { searchQueries, isPerfectMatch }: GetMultiBioEntityProps, + { dispatch }, + ): Promise<void> => { const asyncGetMirnasFunctions = searchQueries.map(searchQuery => - dispatch(getBioEntity(searchQuery)), + dispatch(getBioEntity({ searchQuery, isPerfectMatch })), ); await Promise.all(asyncGetMirnasFunctions); diff --git a/src/redux/map/map.thunks.test.ts b/src/redux/map/map.thunks.test.ts index 69a2e9e110194858eb8e5a986dc2ce0201afefdb..cd8333b440180b45d7b81d7896d2f27b0321f003 100644 --- a/src/redux/map/map.thunks.test.ts +++ b/src/redux/map/map.thunks.test.ts @@ -11,18 +11,21 @@ const EMPTY_QUERY_DATA: QueryData = { modelId: undefined, backgroundId: undefined, initialPosition: undefined, + perfectMatch: false, }; const QUERY_DATA_WITH_BG: QueryData = { modelId: undefined, backgroundId: 21, initialPosition: undefined, + perfectMatch: false, }; const QUERY_DATA_WITH_MODELID: QueryData = { modelId: 5054, backgroundId: undefined, initialPosition: undefined, + perfectMatch: false, }; const QUERY_DATA_WITH_POSITION: QueryData = { @@ -33,6 +36,7 @@ const QUERY_DATA_WITH_POSITION: QueryData = { y: 3, z: 7, }, + perfectMatch: false, }; const STATE_WITH_MODELS: RootState = { diff --git a/src/redux/root/init.thunks.ts b/src/redux/root/init.thunks.ts index a627aefc7ec98a48c0f4fd5a4445116d272c9149..eba37976c86cf1758aadf8983d113c4f01b66acf 100644 --- a/src/redux/root/init.thunks.ts +++ b/src/redux/root/init.thunks.ts @@ -14,6 +14,7 @@ import { initOpenedMaps, } from '../map/map.thunks'; import { getSearchData } from '../search/search.thunks'; +import { setPerfectMatch } from '../search/search.slice'; interface InitializeAppParams { queryData: QueryData; @@ -42,7 +43,13 @@ export const fetchInitialAppData = createAsyncThunk< /** Trigger search */ if (queryData.searchValue) { - dispatch(getSearchData(queryData.searchValue)); + dispatch(setPerfectMatch(queryData.perfectMatch)); + dispatch( + getSearchData({ + searchQueries: queryData.searchValue, + isPerfectMatch: queryData.perfectMatch, + }), + ); dispatch(openSearchDrawer()); } }); diff --git a/src/redux/root/query.selectors.ts b/src/redux/root/query.selectors.ts index 0439c5f84fd2df6cc0826814f2276909dc33dff3..55929fcae655ffdf2eb0e668bf105286f508b39c 100644 --- a/src/redux/root/query.selectors.ts +++ b/src/redux/root/query.selectors.ts @@ -1,13 +1,15 @@ import { QueryDataParams } from '@/types/query'; import { createSelector } from '@reduxjs/toolkit'; import { mapDataSelector } from '../map/map.selectors'; -import { searchValueSelector } from '../search/search.selectors'; +import { perfectMatchSelector, searchValueSelector } from '../search/search.selectors'; export const queryDataParamsSelector = createSelector( searchValueSelector, + perfectMatchSelector, mapDataSelector, - (searchValue, { modelId, backgroundId, position }): QueryDataParams => ({ + (searchValue, perfectMatch, { modelId, backgroundId, position }): QueryDataParams => ({ searchValue: searchValue.join(';'), + perfectMatch, modelId, backgroundId, ...position.last, diff --git a/src/redux/search/search.mock.ts b/src/redux/search/search.mock.ts index b7538b87ca3ffc7fa259d56329f994f753b83da6..16f5498f21913e2f8baac144f58daf87ae1c5551 100644 --- a/src/redux/search/search.mock.ts +++ b/src/redux/search/search.mock.ts @@ -2,5 +2,6 @@ import { SearchState } from './search.types'; export const SEARCH_STATE_INITIAL_MOCK: SearchState = { searchValue: [''], + perfectMatch: false, loading: 'idle', }; diff --git a/src/redux/search/search.reducers.test.ts b/src/redux/search/search.reducers.test.ts index 09c1a62f0f48475f3fd22944f7656b85461445c4..37f9a59de282f419fdc6519dea8987ff64f8802e 100644 --- a/src/redux/search/search.reducers.test.ts +++ b/src/redux/search/search.reducers.test.ts @@ -10,6 +10,7 @@ const SEARCH_QUERY = ['Corticosterone']; const INITIAL_STATE: SearchState = { searchValue: [''], + perfectMatch: false, loading: 'idle', }; @@ -26,7 +27,7 @@ describe.skip('search reducer', () => { }); it('should update store after succesfull getSearchData query', async () => { - await store.dispatch(getSearchData(SEARCH_QUERY)); + await store.dispatch(getSearchData({ searchQueries: SEARCH_QUERY, isPerfectMatch: false })); const { searchValue, loading } = store.getState().search; expect(searchValue).toEqual(SEARCH_QUERY); @@ -34,7 +35,9 @@ describe.skip('search reducer', () => { }); it('should update store on loading getSearchData query', async () => { - const searchPromise = store.dispatch(getSearchData(SEARCH_QUERY)); + const searchPromise = store.dispatch( + getSearchData({ searchQueries: SEARCH_QUERY, isPerfectMatch: false }), + ); const { searchValue, loading } = store.getState().search; expect(searchValue).toEqual(SEARCH_QUERY); diff --git a/src/redux/search/search.reducers.ts b/src/redux/search/search.reducers.ts index 21e30af3a9bc50deca0e7800746589bb792ef7fd..d4e555cda88fb3bf7281cfb9f5be985efed1a56e 100644 --- a/src/redux/search/search.reducers.ts +++ b/src/redux/search/search.reducers.ts @@ -1,15 +1,15 @@ // updating state import { getSearchData } from '@/redux/search/search.thunks'; -import { SearchState } from '@/redux/search/search.types'; +import { SearchState, SetPerfectMatchAction } from '@/redux/search/search.types'; import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; export const getSearchDataReducer = (builder: ActionReducerMapBuilder<SearchState>): void => { builder.addCase(getSearchData.pending, (state, action) => { - state.searchValue = action.meta.arg; + state.searchValue = action.meta.arg.searchQueries; + state.perfectMatch = action.meta.arg.isPerfectMatch; state.loading = 'pending'; }); - builder.addCase(getSearchData.fulfilled, (state, action) => { - state.searchValue = action.meta.arg; + builder.addCase(getSearchData.fulfilled, state => { state.loading = 'succeeded'; }); builder.addCase(getSearchData.rejected, state => { @@ -17,3 +17,7 @@ export const getSearchDataReducer = (builder: ActionReducerMapBuilder<SearchStat // TODO: error management to be discussed in the team }); }; + +export const setPerfectMatchReducer = (state: SearchState, action: SetPerfectMatchAction): void => { + state.perfectMatch = action.payload; +}; diff --git a/src/redux/search/search.selectors.ts b/src/redux/search/search.selectors.ts index 143488fe3fa882c94f86691cec3479d4590791b6..f2c3b256db453d8093dd6c37bccd74425f311529 100644 --- a/src/redux/search/search.selectors.ts +++ b/src/redux/search/search.selectors.ts @@ -13,3 +13,5 @@ export const isPendingSearchStatusSelector = createSelector( loadingSearchStatusSelector, state => state === PENDING_STATUS, ); + +export const perfectMatchSelector = createSelector(searchSelector, state => state.perfectMatch); diff --git a/src/redux/search/search.slice.ts b/src/redux/search/search.slice.ts index a00dd13c0545441bbd0daf9efcac053ca3882ec4..08223dc6fe578d1b3abfe7e1129f4821c87253e9 100644 --- a/src/redux/search/search.slice.ts +++ b/src/redux/search/search.slice.ts @@ -1,19 +1,24 @@ -import { getSearchDataReducer } from '@/redux/search/search.reducers'; +import { getSearchDataReducer, setPerfectMatchReducer } from '@/redux/search/search.reducers'; import { SearchState } from '@/redux/search/search.types'; import { createSlice } from '@reduxjs/toolkit'; const initialState: SearchState = { searchValue: [''], + perfectMatch: false, loading: 'idle', }; export const searchSlice = createSlice({ name: 'search', initialState, - reducers: {}, + reducers: { + setPerfectMatch: setPerfectMatchReducer, + }, extraReducers(builder) { getSearchDataReducer(builder); }, }); +export const { setPerfectMatch } = searchSlice.actions; + export default searchSlice.reducer; diff --git a/src/redux/search/search.thunks.ts b/src/redux/search/search.thunks.ts index 8b2a018bc9a309bbcda7f0866065410c81758f0a..910e70912215a0b28215a380d7ceca0e0f75afc0 100644 --- a/src/redux/search/search.thunks.ts +++ b/src/redux/search/search.thunks.ts @@ -2,13 +2,16 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; import { getMultiChemicals } from '@/redux/chemicals/chemicals.thunks'; import { getMultiDrugs } from '@/redux/drugs/drugs.thunks'; import { getMultiMirnas } from '@/redux/mirnas/mirnas.thunks'; +import { PerfectMultiSearchParams } from '@/types/search'; import { createAsyncThunk } from '@reduxjs/toolkit'; +type GetSearchDataProps = PerfectMultiSearchParams; + export const getSearchData = createAsyncThunk( 'project/getSearchData', - async (searchQueries: string[], { dispatch }): Promise<void> => { + async ({ searchQueries, isPerfectMatch }: GetSearchDataProps, { dispatch }): Promise<void> => { await Promise.all([ - dispatch(getMultiBioEntity(searchQueries)), + dispatch(getMultiBioEntity({ searchQueries, isPerfectMatch })), dispatch(getMultiDrugs(searchQueries)), dispatch(getMultiChemicals(searchQueries)), dispatch(getMultiMirnas(searchQueries)), diff --git a/src/redux/search/search.types.ts b/src/redux/search/search.types.ts index 4e95b214fa7c5bf91b7f9ed6655efbe2be25bb8f..9525aa08ce6afc6ce7623fe462c95d3ab3fe13fc 100644 --- a/src/redux/search/search.types.ts +++ b/src/redux/search/search.types.ts @@ -1,6 +1,11 @@ import { Loading } from '@/types/loadingState'; +import { PayloadAction } from '@reduxjs/toolkit'; export interface SearchState { searchValue: string[]; + perfectMatch: boolean; loading: Loading; } + +export type SetPerfectMatchPayload = boolean; +export type SetPerfectMatchAction = PayloadAction<SetPerfectMatchPayload>; diff --git a/src/types/query.ts b/src/types/query.ts index cf67cf2f12ee7d218f19d6477ec7882a369b1588..a6696aa9ab6676ade0473673db26b60f8d2e9004 100644 --- a/src/types/query.ts +++ b/src/types/query.ts @@ -2,13 +2,15 @@ import { Point } from './map'; export interface QueryData { searchValue?: string[]; + perfectMatch: boolean; modelId?: number; backgroundId?: number; initialPosition?: Partial<Point>; } export interface QueryDataParams { - searchValue: string; + searchValue?: string; + perfectMatch: boolean; modelId?: number; backgroundId?: number; x?: number; @@ -18,6 +20,7 @@ export interface QueryDataParams { export interface QueryDataRouterParams { searchValue?: string; + perfectMatch?: string; modelId?: string; backgroundId?: string; x?: string; diff --git a/src/types/search.ts b/src/types/search.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ec4a95387f4f77f607a419a7a8ab75ffa030d65 --- /dev/null +++ b/src/types/search.ts @@ -0,0 +1,9 @@ +export type PerfectMultiSearchParams = { + searchQueries: string[]; + isPerfectMatch: boolean; +}; + +export type PerfectSearchParams = { + searchQuery: string; + isPerfectMatch: boolean; +}; diff --git a/src/utils/parseQueryToTypes.test.ts b/src/utils/parseQueryToTypes.test.ts index d1e1597510da79515009e55a4441dfb79ccda094..83d991077b5544c69fac4e36f0f423a6129e0a41 100644 --- a/src/utils/parseQueryToTypes.test.ts +++ b/src/utils/parseQueryToTypes.test.ts @@ -6,6 +6,23 @@ describe('parseQueryToTypes', () => { expect(parseQueryToTypes({ searchValue: 'nadh;aspirin' })).toEqual({ searchValue: ['nadh', 'aspirin'], + perfectMatch: false, + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: undefined, y: undefined, z: undefined }, + }); + + expect(parseQueryToTypes({ perfectMatch: 'true' })).toEqual({ + searchValue: undefined, + perfectMatch: true, + modelId: undefined, + backgroundId: undefined, + initialPosition: { x: undefined, y: undefined, z: undefined }, + }); + + expect(parseQueryToTypes({ perfectMatch: 'false' })).toEqual({ + searchValue: undefined, + perfectMatch: false, modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, @@ -13,24 +30,28 @@ describe('parseQueryToTypes', () => { expect(parseQueryToTypes({ modelId: '666' })).toEqual({ searchValue: undefined, + perfectMatch: false, modelId: 666, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: undefined }, }); expect(parseQueryToTypes({ x: '2137' })).toEqual({ searchValue: undefined, + perfectMatch: false, modelId: undefined, backgroundId: undefined, initialPosition: { x: 2137, y: undefined, z: undefined }, }); expect(parseQueryToTypes({ y: '1372' })).toEqual({ searchValue: undefined, + perfectMatch: false, modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: 1372, z: undefined }, }); expect(parseQueryToTypes({ z: '3721' })).toEqual({ searchValue: undefined, + perfectMatch: false, modelId: undefined, backgroundId: undefined, initialPosition: { x: undefined, y: undefined, z: 3721 }, diff --git a/src/utils/parseQueryToTypes.ts b/src/utils/parseQueryToTypes.ts index 4123eebff6e3a751596b53aa9cd601266d3bb9db..9dc659c0eaada11d7dae247f577a4a7df87f6d00 100644 --- a/src/utils/parseQueryToTypes.ts +++ b/src/utils/parseQueryToTypes.ts @@ -2,6 +2,7 @@ import { QueryData, QueryDataRouterParams } from '@/types/query'; export const parseQueryToTypes = (query: QueryDataRouterParams): QueryData => ({ searchValue: query.searchValue?.split(';'), + perfectMatch: query?.perfectMatch === 'true' || false, modelId: Number(query.modelId) || undefined, backgroundId: Number(query.backgroundId) || undefined, initialPosition: { diff --git a/src/utils/query-manager/getQueryData.test.ts b/src/utils/query-manager/getQueryData.test.ts deleted file mode 100644 index 6c5051d61d2f8a78ee8d61927e2b49839ae4b148..0000000000000000000000000000000000000000 --- a/src/utils/query-manager/getQueryData.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { QueryData } from '@/types/query'; -import { ParsedUrlQuery } from 'querystring'; -import { getQueryData, getQueryFieldNumberCurry } from './getQueryData'; - -describe('getQueryFieldNumber - util', () => { - const query: ParsedUrlQuery = { - numberString: '123', - stringString: 'abcd', - emptyString: '', - }; - - const getQueryFieldNumber = getQueryFieldNumberCurry(query); - - it('should return the number on key value is number string', () => { - expect(getQueryFieldNumber('numberString')).toBe(123); - }); - - it('should return undefined on key value is non-number string', () => { - expect(getQueryFieldNumber('stringString')).toBe(undefined); - }); - - it('should return undefined on key value is empty', () => { - expect(getQueryFieldNumber('emptyString')).toBe(undefined); - }); -}); - -describe('getQueryData - util', () => { - it('should return valid query data on valid query params', () => { - const queryParams: ParsedUrlQuery = { - x: '2354', - y: '5321', - z: '6', - modelId: '54', - backgroundId: '13', - }; - - const queryData: QueryData = { - modelId: 54, - backgroundId: 13, - initialPosition: { - x: 2354, - y: 5321, - z: 6, - }, - }; - - expect(getQueryData(queryParams)).toMatchObject(queryData); - }); - - it('should return partial query data on partial query params', () => { - const queryParams: ParsedUrlQuery = { - x: '2354', - modelId: '54', - }; - - const queryData: QueryData = { - modelId: 54, - backgroundId: undefined, - initialPosition: { - x: 2354, - y: undefined, - z: undefined, - }, - }; - - expect(getQueryData(queryParams)).toMatchObject(queryData); - }); -}); diff --git a/src/utils/query-manager/getQueryData.ts b/src/utils/query-manager/getQueryData.ts deleted file mode 100644 index 8c8c17858e7ee666991ca2efa39cc434244e521b..0000000000000000000000000000000000000000 --- a/src/utils/query-manager/getQueryData.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { QueryData } from '@/types/query'; -import { ParsedUrlQuery } from 'querystring'; - -/* prettier-ignore */ -export const getQueryFieldNumberCurry = - (query: ParsedUrlQuery) => - (key: string): number | undefined => - parseInt(`${query?.[key]}`, 10) || undefined; - -export const getQueryData = (query: ParsedUrlQuery): QueryData => { - const getQueryFieldNumber = getQueryFieldNumberCurry(query); - - const initialPosition = { - x: getQueryFieldNumber('x'), - y: getQueryFieldNumber('y'), - z: getQueryFieldNumber('z'), - }; - - return { - modelId: getQueryFieldNumber('modelId'), - backgroundId: getQueryFieldNumber('backgroundId'), - initialPosition, - }; -};