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