From 855f9c29a23251a27eadc0dfa0b0b548c6a46ce5 Mon Sep 17 00:00:00 2001 From: mateuszmiko <dmastah92@gmail.com> Date: Tue, 10 Oct 2023 15:55:26 +0200 Subject: [PATCH] feat: connect search with input field search triggered by lens click and enter (MIN-63) --- pages/redux-api-poc.tsx | 34 ------------ .../NavBar/NavBar.component.test.tsx | 22 ++++++-- .../SearchBar/SearchBar.component.test.tsx | 52 +++++++++++++++++-- .../TopBar/SearchBar/SearchBar.component.tsx | 49 +++++++++++++---- .../TopBar/TopBar.component.test.tsx | 21 +++++++- src/hooks/usePrevious.tsx | 11 ++++ .../bioEntityContents.selectors.ts | 12 +++++ src/redux/chemicals/chemicals.selectors.ts | 9 ++++ src/redux/drugs/drugs.selectors.ts | 6 +++ src/redux/mirnas/mirnas.selectors.ts | 6 +++ src/redux/search/search.reducers.ts | 17 ++++-- src/redux/search/search.selectors.ts | 17 ++++-- src/redux/search/search.slice.ts | 12 ++--- src/redux/search/search.thunks.ts | 17 ++++++ src/redux/search/search.types.ts | 3 ++ src/redux/store.ts | 14 +++-- src/utils/renderComponentWithProvider.tsx | 6 --- tsconfig.json | 1 - 18 files changed, 233 insertions(+), 76 deletions(-) delete mode 100644 pages/redux-api-poc.tsx create mode 100644 src/hooks/usePrevious.tsx create mode 100644 src/redux/bioEntityContents/bioEntityContents.selectors.ts create mode 100644 src/redux/chemicals/chemicals.selectors.ts create mode 100644 src/redux/drugs/drugs.selectors.ts create mode 100644 src/redux/mirnas/mirnas.selectors.ts create mode 100644 src/redux/search/search.thunks.ts delete mode 100644 src/utils/renderComponentWithProvider.tsx diff --git a/pages/redux-api-poc.tsx b/pages/redux-api-poc.tsx deleted file mode 100644 index f6add11e..00000000 --- a/pages/redux-api-poc.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { getBioEntityContents } from '@/redux/bioEntityContents/bioEntityContents.thunks'; -import { getChemicals } from '@/redux/chemicals/chemicals.thunks'; -import { getDrugs } from '@/redux/drugs/drugs.thunks'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { getMirnas } from '@/redux/mirnas/mirnas.thunks'; -import { selectSearchValue } from '@/redux/search/search.selectors'; -import { setSearchValue } from '@/redux/search/search.slice'; -import { useSelector } from 'react-redux'; - -const ReduxPage = (): JSX.Element => { - const dispatch = useAppDispatch(); - const searchValue = useSelector(selectSearchValue); - - const triggerSyncUpdate = (): void => { - // eslint-disable-next-line prefer-template - const newValue = searchValue + 'test'; - dispatch(setSearchValue(newValue)); - dispatch(getDrugs('aspirin')); - dispatch(getMirnas('hsa-miR-302b-3p')); - dispatch(getBioEntityContents('park7')); - dispatch(getChemicals('Corticosterone')); - }; - - return ( - <div> - {searchValue} - <button type="button" onClick={triggerSyncUpdate}> - sync update - </button> - </div> - ); -}; - -export default ReduxPage; diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx index 8b38fda6..c5bff4c3 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx @@ -1,8 +1,24 @@ -import { RenderResult, screen } from '@testing-library/react'; -import { renderComponentWithProvider } from '@/utils/renderComponentWithProvider'; +import drawerReducer from '@/redux/drawer/drawer.slice'; +import type { DrawerState } from '@/redux/drawer/drawer.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { render, screen } from '@testing-library/react'; import { NavBar } from './NavBar.component'; -const renderComponent = (): RenderResult => renderComponentWithProvider(<NavBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<DrawerState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('drawer', drawerReducer); + + return ( + render( + <Wrapper> + <NavBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('NavBar - component', () => { it('Should contain navigation buttons and logos with powered by info', () => { diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx index f3db33ec..f5ac55b0 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx @@ -1,15 +1,59 @@ -import { screen, render, RenderResult, fireEvent } from '@testing-library/react'; +import searchReducer from '@/redux/search/search.slice'; +import type { SearchState } from '@/redux/search/search.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { fireEvent, render, screen } from '@testing-library/react'; import { SearchBar } from './SearchBar.component'; -const renderComponent = (): RenderResult => render(<SearchBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<SearchState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('search', searchReducer); + + return ( + render( + <Wrapper> + <SearchBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('SearchBar - component', () => { it('should let user type text', () => { renderComponent(); - const input = screen.getByTestId('search-input'); + const input = screen.getByTestId<HTMLInputElement>('search-input'); fireEvent.change(input, { target: { value: 'test value' } }); - expect(screen.getByDisplayValue('test value')).toBeInTheDocument(); + expect(input.value).toBe('test value'); + }); + + it('should disable button when the user clicks the lens button', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + expect(input.value).toBe('park7'); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(button).toBeDisabled(); + }); + + it('should disable input when the user clicks the Enter', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + expect(input.value).toBe('park7'); + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(input).toBeDisabled(); }); }); diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index 0c7cdd15..1932b5a2 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,12 +1,32 @@ -import Image from 'next/image'; -import { ChangeEvent, useState } from 'react'; import lensIcon from '@/assets/vectors/icons/lens.svg'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { + isPendingSearchStatusSelector, + searchValueSelector, +} from '@/redux/search/search.selectors'; +import { getSearchData } from '@/redux/search/search.thunks'; +import Image from 'next/image'; +import { ChangeEvent, KeyboardEvent, useState } from 'react'; +import { useSelector } from 'react-redux'; + +const ENTER_KEY_CODE = 'Enter'; export const SearchBar = (): JSX.Element => { const [searchValue, setSearchValue] = useState<string>(''); + const dispatch = useAppDispatch(); + const isPendingSearchStatus = useSelector(isPendingSearchStatusSelector); + const prevSearchValue = useSelector(searchValueSelector); + + const isSameSearchValue = prevSearchValue === searchValue; - const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => { + const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => setSearchValue(event.target.value); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const onSearchClick = () => !isSameSearchValue && dispatch(getSearchData(searchValue)); + + const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>): void => { + if (!isSameSearchValue && event.code === ENTER_KEY_CODE) dispatch(getSearchData(searchValue)); }; return ( @@ -16,16 +36,25 @@ export const SearchBar = (): JSX.Element => { name="search-input" aria-label="search-input" data-testid="search-input" + onKeyDown={handleKeyPress} onChange={onSearchChange} + disabled={isPendingSearchStatus} className="h-9 w-72 rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" /> - <Image - src={lensIcon} - alt="lens icon" - height={16} - width={16} - className="absolute right-4 top-2.5" - /> + <button + disabled={isPendingSearchStatus} + type="button" + className="bg-transparent" + onClick={onSearchClick} + > + <Image + src={lensIcon} + alt="lens icon" + height={16} + width={16} + className="absolute right-4 top-2.5" + /> + </button> </div> ); }; diff --git a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx index a611ffd3..6ff1d849 100644 --- a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx @@ -1,7 +1,24 @@ -import { screen, render, RenderResult } from '@testing-library/react'; +import searchReducer from '@/redux/search/search.slice'; +import type { SearchState } from '@/redux/search/search.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { render, screen } from '@testing-library/react'; import { TopBar } from './TopBar.component'; -const renderComponent = (): RenderResult => render(<TopBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<SearchState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('search', searchReducer); + + return ( + render( + <Wrapper> + <TopBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('TopBar - component', () => { it('Should contain user avatar, search bar', () => { diff --git a/src/hooks/usePrevious.tsx b/src/hooks/usePrevious.tsx new file mode 100644 index 00000000..a5233144 --- /dev/null +++ b/src/hooks/usePrevious.tsx @@ -0,0 +1,11 @@ +import { useEffect, useRef } from 'react'; + +export default function usePrevious<T>(state: T): T | undefined { + const ref = useRef<T>(); + + useEffect(() => { + ref.current = state; + }); + + return ref.current; +} diff --git a/src/redux/bioEntityContents/bioEntityContents.selectors.ts b/src/redux/bioEntityContents/bioEntityContents.selectors.ts new file mode 100644 index 00000000..80a88cb0 --- /dev/null +++ b/src/redux/bioEntityContents/bioEntityContents.selectors.ts @@ -0,0 +1,12 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const bioEntityContentsSelector = createSelector( + rootSelector, + state => state.bioEntityContents, +); + +export const loadingBioEntityStatusSelector = createSelector( + bioEntityContentsSelector, + state => state.loading, +); diff --git a/src/redux/chemicals/chemicals.selectors.ts b/src/redux/chemicals/chemicals.selectors.ts new file mode 100644 index 00000000..b6c7cb1c --- /dev/null +++ b/src/redux/chemicals/chemicals.selectors.ts @@ -0,0 +1,9 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const chemicalsSelector = createSelector(rootSelector, state => state.chemicals); + +export const loadingChemicalsStatusSelector = createSelector( + chemicalsSelector, + state => state.loading, +); diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts new file mode 100644 index 00000000..b67a8cb0 --- /dev/null +++ b/src/redux/drugs/drugs.selectors.ts @@ -0,0 +1,6 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const drugsSelector = createSelector(rootSelector, state => state.drugs); + +export const loadingDrugsStatusSelector = createSelector(drugsSelector, state => state.loading); diff --git a/src/redux/mirnas/mirnas.selectors.ts b/src/redux/mirnas/mirnas.selectors.ts new file mode 100644 index 00000000..5344f037 --- /dev/null +++ b/src/redux/mirnas/mirnas.selectors.ts @@ -0,0 +1,6 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const mirnasSelector = createSelector(rootSelector, state => state.mirnas); + +export const loadingMirnasStatusSelector = createSelector(mirnasSelector, state => state.loading); diff --git a/src/redux/search/search.reducers.ts b/src/redux/search/search.reducers.ts index 4f6f747d..28226ca9 100644 --- a/src/redux/search/search.reducers.ts +++ b/src/redux/search/search.reducers.ts @@ -1,7 +1,18 @@ // updating state +import { getSearchData } from '@/redux/search/search.thunks'; import { SearchState } from '@/redux/search/search.types'; -import { PayloadAction } from '@reduxjs/toolkit'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -export const setSearchValueReducer = (state: SearchState, action: PayloadAction<string>): void => { - state.searchValue = action.payload; +export const getSearchDataReducer = (builder: ActionReducerMapBuilder<SearchState>): void => { + builder.addCase(getSearchData.pending, (state, action) => { + state.searchValue = action.meta.arg; + state.loading = 'pending'; + }); + builder.addCase(getSearchData.fulfilled, state => { + state.loading = 'succeeded'; + }); + builder.addCase(getSearchData.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); }; diff --git a/src/redux/search/search.selectors.ts b/src/redux/search/search.selectors.ts index c845eecd..143488fe 100644 --- a/src/redux/search/search.selectors.ts +++ b/src/redux/search/search.selectors.ts @@ -1,4 +1,15 @@ -import type { RootState } from '@/redux/store'; +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; -// THIS IS EXAMPLE, it's not memoised!!!! Check redux-tookit docs. -export const selectSearchValue = (state: RootState): string => state.search.searchValue; +const PENDING_STATUS = 'pending'; + +export const searchSelector = createSelector(rootSelector, state => state.search); + +export const searchValueSelector = createSelector(searchSelector, state => state.searchValue); + +export const loadingSearchStatusSelector = createSelector(searchSelector, state => state.loading); + +export const isPendingSearchStatusSelector = createSelector( + loadingSearchStatusSelector, + state => state === PENDING_STATUS, +); diff --git a/src/redux/search/search.slice.ts b/src/redux/search/search.slice.ts index 92357f8c..73930e33 100644 --- a/src/redux/search/search.slice.ts +++ b/src/redux/search/search.slice.ts @@ -1,6 +1,6 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { getSearchDataReducer } from '@/redux/search/search.reducers'; import { SearchState } from '@/redux/search/search.types'; -import { setSearchValueReducer } from '@/redux/search/search.reducers'; +import { createSlice } from '@reduxjs/toolkit'; const initialState: SearchState = { searchValue: '', @@ -8,16 +8,16 @@ const initialState: SearchState = { content: '', drugs: '', }, + loading: 'idle', }; export const searchSlice = createSlice({ name: 'search', initialState, - reducers: { - setSearchValue: setSearchValueReducer, + reducers: {}, + extraReducers(builder) { + getSearchDataReducer(builder); }, }); -export const { setSearchValue } = searchSlice.actions; - export default searchSlice.reducer; diff --git a/src/redux/search/search.thunks.ts b/src/redux/search/search.thunks.ts new file mode 100644 index 00000000..2724826c --- /dev/null +++ b/src/redux/search/search.thunks.ts @@ -0,0 +1,17 @@ +import { getBioEntityContents } from '@/redux/bioEntityContents/bioEntityContents.thunks'; +import { getChemicals } from '@/redux/chemicals/chemicals.thunks'; +import { getDrugs } from '@/redux/drugs/drugs.thunks'; +import { getMirnas } from '@/redux/mirnas/mirnas.thunks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const getSearchData = createAsyncThunk( + 'project/getSearchData', + async (searchQuery: string, { dispatch }): Promise<void> => { + await Promise.all([ + dispatch(getDrugs(searchQuery)), + dispatch(getBioEntityContents(searchQuery)), + dispatch(getChemicals(searchQuery)), + dispatch(getMirnas(searchQuery)), + ]); + }, +); diff --git a/src/redux/search/search.types.ts b/src/redux/search/search.types.ts index 6b6316a8..1faf1b72 100644 --- a/src/redux/search/search.types.ts +++ b/src/redux/search/search.types.ts @@ -1,3 +1,5 @@ +import { Loading } from '@/types/loadingState'; + export interface SearchResult { content: string; drugs: string; @@ -6,4 +8,5 @@ export interface SearchResult { export interface SearchState { searchValue: string; searchResult: SearchResult; + loading: Loading; } diff --git a/src/redux/store.ts b/src/redux/store.ts index 081acb38..a095912b 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,14 +1,20 @@ -import { configureStore } from '@reduxjs/toolkit'; -import searchReducer from '@/redux/search/search.slice'; -import projectSlice from '@/redux/project/project.slice'; -import drugsReducer from '@/redux/drugs/drugs.slice'; +import bioEntityContentsReducer from '@/redux/bioEntityContents/bioEntityContents.slice'; +import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; +import drugsReducer from '@/redux/drugs/drugs.slice'; +import mirnasReducer from '@/redux/mirnas/mirnas.slice'; +import projectSlice from '@/redux/project/project.slice'; +import searchReducer from '@/redux/search/search.slice'; +import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ reducer: { search: searchReducer, project: projectSlice, drugs: drugsReducer, + mirnas: mirnasReducer, + chemicals: chemicalsReducer, + bioEntityContents: bioEntityContentsReducer, drawer: drawerReducer, }, devTools: true, diff --git a/src/utils/renderComponentWithProvider.tsx b/src/utils/renderComponentWithProvider.tsx deleted file mode 100644 index c62bc5d9..00000000 --- a/src/utils/renderComponentWithProvider.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { RenderResult, render } from '@testing-library/react'; -import { AppWrapper } from '@/components/AppWrapper'; -import type { ReactNode } from 'react'; - -export const renderComponentWithProvider = (children: ReactNode): RenderResult => - render(<AppWrapper>{children}</AppWrapper>); diff --git a/tsconfig.json b/tsconfig.json index 338c7309..a44f42a5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,6 @@ "**/*.tsx", ".next/types/**/*.ts", "pages", - "@types/images.d.ts", "jest.config.ts", "setupTests.ts" ], -- GitLab