Skip to content
Snippets Groups Projects
Commit 590a3eff authored by Tadeusz Miesiąc's avatar Tadeusz Miesiąc
Browse files

Merge branch 'feature/perfect-match' into 'feature/multisearch'

Feature/perfect match

See merge request !59
parents c8d8a561 62207997
No related branches found
No related tags found
3 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!59Feature/perfect match,!49feat(multisearch): rewrited redux store to support multisearch
Pipeline #81747 passed
Showing
with 289 additions and 66 deletions
......@@ -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();
}
};
......
......@@ -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>
......
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);
});
});
/* 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>
);
};
.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;
}
export { PerfectMatchSwitch } from './PerfectMatchSwitch.component';
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>
);
};
export { SearchDrawerHeader } from './SearchDrawerHeader.component';
......@@ -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>
</>
);
};
......@@ -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`,
);
});
......
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 =>
......
......@@ -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(
......
......@@ -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
......
......@@ -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);
});
});
......
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);
......
......@@ -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 = {
......
......@@ -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());
}
});
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,
......
......@@ -2,5 +2,6 @@ import { SearchState } from './search.types';
export const SEARCH_STATE_INITIAL_MOCK: SearchState = {
searchValue: [''],
perfectMatch: false,
loading: 'idle',
};
......@@ -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);
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment