From 1007c757dcf22b8cd5b4159f666b7e1b83d6ceb2 Mon Sep 17 00:00:00 2001 From: mateuszmiko <dmastah92@gmail.com> Date: Mon, 16 Oct 2023 16:39:44 +0200 Subject: [PATCH] feat: query parameters (MIN-55) --- package-lock.json | 61 +++++++++++++++++++ package.json | 2 + setupTests.ts | 2 + .../SearchBar/SearchBar.component.test.tsx | 54 +++++++++++++++- .../TopBar/SearchBar/SearchBar.component.tsx | 10 ++- .../TopBar/SearchBar/hooks/useParamsQuery.ts | 38 ++++++++++++ src/redux/search/search.reducers.test.ts | 51 ++++++++++++++++ src/redux/search/search.reducers.ts | 3 +- 8 files changed, 217 insertions(+), 4 deletions(-) create mode 100644 src/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery.ts create mode 100644 src/redux/search/search.reducers.test.ts diff --git a/package-lock.json b/package-lock.json index 6dfd0fd8..733a0ea6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "eslint-config-next": "13.4.19", "next": "13.4.19", "postcss": "8.4.29", + "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", @@ -58,6 +59,7 @@ "jest-junit": "^16.0.0", "jest-watch-typeahead": "^2.2.2", "lint-staged": "^14.0.1", + "next-router-mock": "^0.9.10", "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", "prettier-plugin-tailwindcss": "^0.5.4", @@ -4308,6 +4310,14 @@ "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==", "dev": true }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "engines": { + "node": ">=0.10" + } + }, "node_modules/dedent": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz", @@ -5811,6 +5821,14 @@ "node": ">=8" } }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/find-node-modules": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/find-node-modules/-/find-node-modules-2.1.3.tgz", @@ -9445,6 +9463,16 @@ } } }, + "node_modules/next-router-mock": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/next-router-mock/-/next-router-mock-0.9.10.tgz", + "integrity": "sha512-bK6sRb/xGNFgHVUZuvuApn6KJBAKTPiP36A7a9mO77U4xQO5ukJx9WHlU67Tv8AuySd09pk0+Hu8qMVIAmLO6A==", + "dev": true, + "peerDependencies": { + "next": ">=10.0.0", + "react": ">=17.0.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.14.tgz", @@ -10376,6 +10404,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -11204,6 +11249,14 @@ "integrity": "sha512-lpT8hSQp9jAKp9mhtBU4Xjon8LPGBvLIuBiSVhMEtmLecTh2mO0tlqrAMp47tBXzMr13NJMQ2lf7RpQGLJ3HsQ==", "dev": true }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "engines": { + "node": ">=6" + } + }, "node_modules/split2": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/split2/-/split2-3.2.2.tgz", @@ -11285,6 +11338,14 @@ "node": ">=10.0.0" } }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "engines": { + "node": ">=4" + } + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", diff --git a/package.json b/package.json index a7feaab5..264666d7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "eslint-config-next": "13.4.19", "next": "13.4.19", "postcss": "8.4.29", + "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", @@ -72,6 +73,7 @@ "jest-junit": "^16.0.0", "jest-watch-typeahead": "^2.2.2", "lint-staged": "^14.0.1", + "next-router-mock": "^0.9.10", "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", "prettier-plugin-tailwindcss": "^0.5.4", diff --git a/setupTests.ts b/setupTests.ts index 7b0828bf..c74f9623 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -1 +1,3 @@ import '@testing-library/jest-dom'; + +jest.mock('next/router', () => require('next-router-mock')); diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx index a8c934f4..e61a7403 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx @@ -1,6 +1,7 @@ -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { fireEvent, render, screen } from '@testing-library/react'; +import mockedRouter from 'next-router-mock'; import { SearchBar } from './SearchBar.component'; const renderComponent = (): { store: StoreType } => { @@ -50,4 +51,55 @@ describe('SearchBar - component', () => { expect(input).toBeDisabled(); }); + + it('should set parameters on the url when the user enters a value in the search bar and clicks Enter', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(button).toBeDisabled(); + + expect(mockedRouter).toMatchObject({ + asPath: '/?search=park7', + pathname: '/', + query: { search: 'park7' }, + }); + }); + + it('should set parameters on the url when the user enters a value in the search bar and clicks lens button', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(input).toBeDisabled(); + + expect(mockedRouter).toMatchObject({ + asPath: '/?search=park7', + pathname: '/', + query: { search: 'park7' }, + }); + }); + + it('should set the value on the input filed when the user has query parameters in the url', () => { + renderComponent(); + + mockedRouter.push('/?search=park7'); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + + expect(input.value).toBe('park7'); + + expect(mockedRouter).toMatchObject({ + asPath: '/?search=park7', + pathname: '/', + query: { search: 'park7' }, + }); + }); }); diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index 66f5fc8f..a1c26e88 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,4 +1,5 @@ import lensIcon from '@/assets/vectors/icons/lens.svg'; +import { useParamsQuery } from '@/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery'; import { isDrawerOpenSelector } from '@/redux/drawer/drawer.selectors'; import { clearSearchDrawerState, openDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; @@ -14,12 +15,15 @@ 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 isDrawerOpen = useSelector(isDrawerOpenSelector); + const { setSearchQueryInRouter, searchParams } = useParamsQuery(); + + const [searchValue, setSearchValue] = useState<string>((searchParams?.search as string) || ''); + const dispatch = useAppDispatch(); + const isSameSearchValue = prevSearchValue === searchValue; const openSearchDrawerIfClosed = (): void => { @@ -35,6 +39,7 @@ export const SearchBar = (): JSX.Element => { const onSearchClick = (): void => { if (!isSameSearchValue) { dispatch(getSearchData(searchValue)); + setSearchQueryInRouter(searchValue); openSearchDrawerIfClosed(); } }; @@ -42,6 +47,7 @@ export const SearchBar = (): JSX.Element => { const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>): void => { if (!isSameSearchValue && event.code === ENTER_KEY_CODE) { dispatch(getSearchData(searchValue)); + setSearchQueryInRouter(searchValue); openSearchDrawerIfClosed(); } }; diff --git a/src/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery.ts b/src/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery.ts new file mode 100644 index 00000000..c332beda --- /dev/null +++ b/src/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery.ts @@ -0,0 +1,38 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { getSearchData } from '@/redux/search/search.thunks'; +import { useRouter } from 'next/router'; +import type { ParsedQuery } from 'query-string'; +import qs from 'query-string'; +import { useEffect } from 'react'; + +type UseParamsQuery = { + setSearchQueryInRouter: (searchValue: string) => void; + searchParams: ParsedQuery<string>; +}; + +export const useParamsQuery = (): UseParamsQuery => { + const router = useRouter(); + const dispatch = useAppDispatch(); + + const path = router.asPath; + + // The number of the character from which to cut the characters from path. + const sliceFromCharNumber = 2; + // The number of the character at which to end the cut string from path. + const sliceToCharNumber = path.length; + const searchParams = qs.parse(path.slice(sliceFromCharNumber, sliceToCharNumber)); + + const setSearchQueryInRouter = (searchValue: string): void => { + const searchQuery = { + search: searchValue, + }; + + router.push(`?${qs.stringify(searchQuery)}`); + }; + + useEffect(() => { + if (searchParams?.search) dispatch(getSearchData(searchParams.search as string)); + }, [dispatch]); + + return { setSearchQueryInRouter, searchParams }; +}; diff --git a/src/redux/search/search.reducers.test.ts b/src/redux/search/search.reducers.test.ts new file mode 100644 index 00000000..46495b26 --- /dev/null +++ b/src/redux/search/search.reducers.test.ts @@ -0,0 +1,51 @@ +import { getSearchData } from '@/redux/search/search.thunks'; +import type { SearchState } from '@/redux/search/search.types'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import searchReducer from './search.slice'; + +const SEARCH_QUERY = 'Corticosterone'; + +const INITIAL_STATE: SearchState = { + searchValue: '', + loading: 'idle', +}; + +describe('search reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<SearchState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('search', searchReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(searchReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + + it('should update store after succesfull getSearchData query', async () => { + await store.dispatch(getSearchData(SEARCH_QUERY)); + + const { searchValue, loading } = store.getState().search; + expect(searchValue).toEqual(SEARCH_QUERY); + expect(loading).toEqual('succeeded'); + }); + + it('should update store on loading getSearchData query', async () => { + const searchPromise = store.dispatch(getSearchData(SEARCH_QUERY)); + + const { searchValue, loading } = store.getState().search; + expect(searchValue).toEqual(SEARCH_QUERY); + expect(loading).toEqual('pending'); + + searchPromise.then(() => { + const { searchValue: searchValueFulfilled, loading: promiseFulfilled } = + store.getState().search; + + expect(searchValueFulfilled).toEqual(SEARCH_QUERY); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/search/search.reducers.ts b/src/redux/search/search.reducers.ts index 28226ca9..21e30af3 100644 --- a/src/redux/search/search.reducers.ts +++ b/src/redux/search/search.reducers.ts @@ -8,7 +8,8 @@ export const getSearchDataReducer = (builder: ActionReducerMapBuilder<SearchStat state.searchValue = action.meta.arg; state.loading = 'pending'; }); - builder.addCase(getSearchData.fulfilled, state => { + builder.addCase(getSearchData.fulfilled, (state, action) => { + state.searchValue = action.meta.arg; state.loading = 'succeeded'; }); builder.addCase(getSearchData.rejected, state => { -- GitLab