diff --git a/pages/redux-api-poc.tsx b/pages/redux-api-poc.tsx deleted file mode 100644 index f6add11e9d67d6f2fa1d73b3f6f89ac7a1ed6da2..0000000000000000000000000000000000000000 --- 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 8b38fda6785369dffa380a4849599412e8387f09..c5bff4c3eade40bfea3f82240ff36741467ab2e9 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 f3db33ecd32d76c2d7318382b391de148348b23c..f5ac55b046d8c3ab4aac649601a79ca602e42c35 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 0c7cdd153ad0db61420f0d1efbe0a6d069ee396e..1932b5a2e240252e93ae09000b4fe94a5c5b4539 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 a611ffd3fa009a1d98b6d663bd9af6dd9fb7a73a..6ff1d8495eaa23d3d947a52f707b6e03a3ba85ac 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 0000000000000000000000000000000000000000..a5233144ca680a146795cc40f679cabd31ee0171 --- /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 0000000000000000000000000000000000000000..80a88cb0bec55d0ebf43ba8dd85827b44b429121 --- /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 0000000000000000000000000000000000000000..b6c7cb1ccdcc7bbacce65c6f539d5499ab66e8a0 --- /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 0000000000000000000000000000000000000000..b67a8cb086dfed2ffdebe53f6d446865358ee459 --- /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 0000000000000000000000000000000000000000..5344f0370c031a7a21d9e9b673d7a7d95f1d1208 --- /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 4f6f747d357f19c9a48c43041c484c4052c21600..28226ca9200f8dfef935eae8ff1f6b45fb6f3337 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 c845eecd0c4220dc245e95335f575ece55ecb804..143488fe3fa882c94f86691cec3479d4590791b6 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 92357f8c943fea34aae15b7978266df4c89ae760..73930e3374bc72c333bc89ec8be066d1e961c241 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 0000000000000000000000000000000000000000..2724826c65aa793de889176cc3fe75764509ec1c --- /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 6b6316a881f83aed58588d03b75c2f0ca0d5d9a1..1faf1b72e2f18b7a340e425c0af6a611712e95d8 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 081acb384c7b0247810a8e614de74d979f8bdbc8..a095912bdda990fb9677b2b8814e0cd848014d6a 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 c62bc5d9438bc3742549ae737db262e5567ca1dc..0000000000000000000000000000000000000000 --- 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 338c730927fddced861320bac441ccaa9568de16..a44f42a56e52544084e6f465ad04da481689ef14 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" ],