diff --git a/.env b/.env index 099bf13e4dc69a665944e4dab0c04a5a76d454fb..3f191e1fdfb47f26281eeb4f6ea52ea423ed0d8b 100644 --- a/.env +++ b/.env @@ -1,4 +1,5 @@ -NEXT_PUBLIC_BASE_API_URL = 'https://corsproxy.io/?https://pdmap.uni.lu/minerva/api' -BASE_MAP_IMAGES_URL = 'https://pdmap.uni.lu' -NEXT_PUBLIC_PROJECT_ID = 'pd_map_winter_23' \ No newline at end of file +NEXT_PUBLIC_BASE_API_URL = 'https://corsproxy.io/?https://lux1.atcomp.pl/minerva/api' +NEXT_PUBLIC_BASE_NEW_API_URL = 'https://corsproxy.io/?https://lux1.atcomp.pl/minerva/new_api/' +NEXT_PUBLIC_PROJECT_ID = 'pdmap_appu_test' +ZOD_SEED = 997 diff --git a/.eslintrc.json b/.eslintrc.json index f7df93bc3522f3a2dd376c7b93534d03b083103a..e60a21f06faa469391143c2f16c8d14f11ee5e97 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -71,7 +71,7 @@ "packageDir": "./" } ], - "indent": ["error", 2], + "indent": ["error", 2, { "SwitchCase": 1 }], "react/jsx-indent": ["error", 2], "react/jsx-indent-props": ["error", 2], "linebreak-style": ["error", "unix"], diff --git a/Group.svg b/Group.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c9f4a0e0768cd405fe0060c439a9f15a9766563 --- /dev/null +++ b/Group.svg @@ -0,0 +1,4 @@ +<svg width="18" height="24" viewBox="0 0 18 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M9 0C4.575 0 0 3.375 0 9C0 14.325 8.1 22.65 8.475 23.025C8.625 23.175 8.775 23.25 9 23.25C9.225 23.25 9.375 23.175 9.525 23.025C9.9 22.65 18 14.4 18 9C18 3.375 13.425 0 9 0ZM9 12C7.35 12 6 10.65 6 9C6 7.35 7.35 6 9 6C10.65 6 12 7.35 12 9C12 10.65 10.65 12 9 12Z" fill="#E17221"/> +<circle cx="9.0002" cy="8.99922" r="4.8" fill="#E17221"/> +</svg> diff --git a/next.config.js b/next.config.js index 1b307ecc05742d534f2bf1906896a393b59b587e..eebeaf4de2ae021a46f39f16ce07cdfdacc14066 100644 --- a/next.config.js +++ b/next.config.js @@ -8,7 +8,7 @@ const nextConfig = { BASE_API_URL: process.env.NEXT_PUBLIC_BASE_API_URL || '', BASE_MAP_IMAGES_URL: process.env.BASE_MAP_IMAGES_URL || '', PROJECT_ID: process.env.NEXT_PUBLIC_PROJECT_ID || '', - ZOD_SEED: 997, + ZOD_SEED: process.env.ZOD_SEED || 123, }, }; diff --git a/package-lock.json b/package-lock.json index fa64f7b752cadc8f6f8b91f40f1efd0d84d7bcf2..cbe12b20544abcdb8444e4320827acbf4ece6cee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "next": "13.4.19", "ol": "^8.1.0", "postcss": "8.4.29", + "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", @@ -60,6 +61,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.6", @@ -4320,6 +4322,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", @@ -5828,6 +5838,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", @@ -9494,6 +9512,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", @@ -10467,6 +10495,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", @@ -11316,6 +11361,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", @@ -11397,6 +11450,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 6afb47c7d872becfb1673f987cc935bade3ed043..c359766c7f78ada52b5de72261d4290a64af0c10 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "next": "13.4.19", "ol": "^8.1.0", "postcss": "8.4.29", + "query-string": "7.1.3", "react": "18.2.0", "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", @@ -74,6 +75,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.6", 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/setupTests.ts b/setupTests.ts index 9a7ee97d64de797d760a3e615afe78853b820392..11a7da9a6d761c10f3f1336c1c4c2c4a13c3e6fb 100644 --- a/setupTests.ts +++ b/setupTests.ts @@ -6,3 +6,5 @@ global.ResizeObserver = jest.fn().mockImplementation(() => ({ unobserve: jest.fn(), disconnect: jest.fn(), })); + +jest.mock('next/router', () => require('next-router-mock')); diff --git a/src/assets/vectors/icons/Group.svg b/src/assets/vectors/icons/Group.svg new file mode 100644 index 0000000000000000000000000000000000000000..6d6e49d3b0756b321ce2ee1e317b6452f6cafc59 --- /dev/null +++ b/src/assets/vectors/icons/Group.svg @@ -0,0 +1,5 @@ +<svg width="18" height="24" viewBox="0 0 18 24" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path d="M9 0C4.575 0 0 3.375 0 9C0 14.325 8.1 22.65 8.475 23.025C8.625 23.175 8.775 23.25 9 23.25C9.225 23.25 9.375 23.175 9.525 23.025C9.9 22.65 18 14.4 18 9C18 3.375 13.425 0 9 0ZM9 12C7.35 12 6 10.65 6 9C6 7.35 7.35 6 9 6C10.65 6 12 7.35 12 9C12 10.65 10.65 12 9 12Z" fill="#E17221"/> +<circle cx="9.0002" cy="8.99922" r="4.8" fill="#E17221"/> +<path d="M8.846 15V7.344L7.22 8.322V7.332L8.846 6.36H9.74V15H8.846Z" fill="white"/> +</svg> 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/NavBar/NavBar.component.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.tsx index 14af66353e1e7f94c1b79dbf41bf5f4b6dc424bd..6783d04b7a443687caba8e69a88d753c5a865738 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.tsx @@ -1,9 +1,9 @@ -import Image from 'next/image'; -import { IconButton } from '@/shared/IconButton'; import logoImg from '@/assets/images/logo.png'; import luxembourgLogoImg from '@/assets/images/luxembourg-logo.png'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { openDrawer } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { IconButton } from '@/shared/IconButton'; +import Image from 'next/image'; export const NavBar = (): JSX.Element => { const dispatch = useAppDispatch(); diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx index f3db33ecd32d76c2d7318382b391de148348b23c..e61a7403b87cc2c30538e1946f58e5456be9b530 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx @@ -1,15 +1,105 @@ -import { screen, render, RenderResult, fireEvent } from '@testing-library/react'; +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 = (): RenderResult => render(<SearchBar />); +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(); + + return ( + render( + <Wrapper> + <SearchBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('SearchBar - component', () => { it('should let user type text', () => { renderComponent(); + const input = screen.getByTestId<HTMLInputElement>('search-input'); - const input = screen.getByTestId('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' } }); + + 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' } }); + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + 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 0c7cdd153ad0db61420f0d1efbe0a6d069ee396e..e683755aba3e0111f6c1c650e25f4ba115af4818 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,12 +1,54 @@ -import Image from 'next/image'; -import { ChangeEvent, useState } from 'react'; 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 { openSearchDrawer } from '@/redux/drawer/drawer.slice'; +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 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 onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => { + const openSearchDrawerIfClosed = (): void => { + if (!isDrawerOpen) { + dispatch(openSearchDrawer()); + } + }; + + const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => setSearchValue(event.target.value); + + const onSearchClick = (): void => { + if (!isSameSearchValue) { + dispatch(getSearchData(searchValue)); + setSearchQueryInRouter(searchValue); + openSearchDrawerIfClosed(); + } + }; + + const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>): void => { + if (!isSameSearchValue && event.code === ENTER_KEY_CODE) { + dispatch(getSearchData(searchValue)); + setSearchQueryInRouter(searchValue); + openSearchDrawerIfClosed(); + } }; return ( @@ -16,16 +58,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/SearchBar/hooks/useParamsQuery.ts b/src/components/FunctionalArea/TopBar/SearchBar/hooks/useParamsQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..c332beda9c2f6ce79499c2435ddca59b7c7fba19 --- /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/components/FunctionalArea/TopBar/TopBar.component.test.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx index a611ffd3fa009a1d98b6d663bd9af6dd9fb7a73a..e6e44ac13532f839d4b82fbde10dfdd537fc49ce 100644 --- a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx @@ -1,7 +1,22 @@ -import { screen, render, RenderResult } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; import { TopBar } from './TopBar.component'; -const renderComponent = (): RenderResult => render(<TopBar />); +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(); + + return ( + render( + <Wrapper> + <TopBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('TopBar - component', () => { it('Should contain user avatar, search bar', () => { diff --git a/src/components/Map/Drawer/Drawer.component.test.tsx b/src/components/Map/Drawer/Drawer.component.test.tsx index a0663188edef546d4eb44d1fde6591bbcdbaa1f5..26c7bd9828544a863a6198988441c7b9cdb7927b 100644 --- a/src/components/Map/Drawer/Drawer.component.test.tsx +++ b/src/components/Map/Drawer/Drawer.component.test.tsx @@ -1,13 +1,11 @@ -import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; -import { DrawerState } from '@/redux/drawer/drawer.types'; -import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; -import { screen, render, act, fireEvent } from '@testing-library/react'; -import drawerReducer, { openDrawer } from '@/redux/drawer/drawer.slice'; +import { openSearchDrawer } from '@/redux/drawer/drawer.slice'; +import { StoreType } from '@/redux/store'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { Drawer } from './Drawer.component'; -const renderComponent = (): { store: ToolkitStoreWithSingleSlice<DrawerState> } => { - const { Wrapper, store } = getReduxWrapperUsingSliceReducer('drawer', drawerReducer); - +const renderComponent = (): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(); return ( render( <Wrapper> @@ -40,7 +38,7 @@ describe('Drawer - component', () => { expect(screen.queryByTestId('search-drawer-content')).not.toBeInTheDocument(); await act(() => { - store.dispatch(openDrawer('search')); + store.dispatch(openSearchDrawer()); }); expect(screen.getByTestId('search-drawer-content')).toBeInTheDocument(); @@ -50,7 +48,7 @@ describe('Drawer - component', () => { const { store } = renderComponent(); await act(() => { - store.dispatch(openDrawer('search')); + store.dispatch(openSearchDrawer()); }); expect(screen.getByTestId('search-drawer-content')).toBeInTheDocument(); diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 4e692262a8a87a4e45a2a079b6db8c3551b4b4af..6415d40280dc158cb40cdde23b7a379432a76a0f 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -1,32 +1,21 @@ -import dynamic from 'next/dynamic'; -import { twMerge } from 'tailwind-merge'; -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { drawerDataSelector } from '@/redux/drawer/drawer.selectors'; import { DRAWER_ROLE } from '@/components/Map/Drawer/Drawer.constants'; - -const SearchDrawerContent = dynamic( - async () => - import('@/components/Map/Drawer/SearchDrawerContent').then( - module => module.SearchDrawerContent, - ), - { - ssr: false, - }, -); +import { drawerSelector } from '@/redux/drawer/drawer.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { twMerge } from 'tailwind-merge'; +import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrapper'; export const Drawer = (): JSX.Element => { - const { open, drawerName } = useAppSelector(drawerDataSelector); + const { isOpen, drawerName } = useAppSelector(drawerSelector); return ( <div className={twMerge( 'absolute left-[88px] top-[104px] z-10 h-calc-drawer w-[432px] -translate-x-full transform bg-white-pearl text-font-500 transition-all duration-500', - open && 'translate-x-0', + isOpen && 'translate-x-0', )} role={DRAWER_ROLE} > - {open && drawerName === 'search' && <SearchDrawerContent />} - {/* other drawers comes here, should use dynamic import */} + {isOpen && drawerName === 'search' && <SearchDrawerContent />} </div> ); }; diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx deleted file mode 100644 index ff006e6b0d2f9cc5ba046e81ec909ac018bf7995..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import { - Accordion, - AccordionItem, - AccordionItemButton, - AccordionItemPanel, - AccordionItemHeading, -} from '@/shared/Accordion'; - -import { BioEntitiesSubmapItem } from '@/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem'; - -export const BioEntitiesAccordion = (): JSX.Element => { - const entity = { mapName: 'main map', numberOfEntities: 21 }; - return ( - <Accordion allowZeroExpanded> - <AccordionItem> - <AccordionItemHeading> - <AccordionItemButton>Content (2137)</AccordionItemButton> - </AccordionItemHeading> - <AccordionItemPanel className=""> - <BioEntitiesSubmapItem - mapName={entity.mapName} - numberOfEntities={entity.numberOfEntities} - /> - <BioEntitiesSubmapItem - mapName={entity.mapName} - numberOfEntities={entity.numberOfEntities} - /> - <BioEntitiesSubmapItem - mapName={entity.mapName} - numberOfEntities={entity.numberOfEntities} - /> - <BioEntitiesSubmapItem - mapName={entity.mapName} - numberOfEntities={entity.numberOfEntities} - /> - </AccordionItemPanel> - </AccordionItem> - </Accordion> - ); -}; diff --git a/src/components/Map/Drawer/SearchDrawerContent/index.ts b/src/components/Map/Drawer/SearchDrawerContent/index.ts deleted file mode 100644 index 72074d52d0f624c1d2bb20a782da720e40692531..0000000000000000000000000000000000000000 --- a/src/components/Map/Drawer/SearchDrawerContent/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SearchDrawerContent } from './SearchDrawerContent.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fc02472aa6d90d9dc2453dd2457bf230a3f56b34 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.test.tsx @@ -0,0 +1,65 @@ +import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { MODELS_MOCK } from '@/models/mocks/modelsMock'; +import { StoreType } from '@/redux/store'; +import { Accordion } from '@/shared/Accordion'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { BioEntitiesAccordion } from './BioEntitiesAccordion.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Accordion> + <BioEntitiesAccordion /> + </Accordion> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('BioEntitiesAccordion - component', () => { + it('should display loading indicator when bioEntity search is pending', () => { + renderComponent({ + bioEntity: { + data: undefined, + loading: 'pending', + error: { name: '', message: '' }, + }, + models: { + data: [], + loading: 'pending', + error: { name: '', message: '' }, + }, + }); + + expect(screen.getByText('Content (Loading...)')).toBeInTheDocument(); + }); + + it('should render list of maps with number of entities after succeeded bio entity search', () => { + renderComponent({ + bioEntity: { + data: bioEntitiesContentFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + models: { + data: MODELS_MOCK, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + }); + + expect(screen.getByText('Content (10)')).toBeInTheDocument(); + expect(screen.getByText('Core PD map (8)')).toBeInTheDocument(); + expect(screen.getByText('Histamine signaling (2)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..081247d86a38000347a6de859b5f7b820590542a --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx @@ -0,0 +1,40 @@ +import { + AccordionItem, + AccordionItemButton, + AccordionItemPanel, + AccordionItemHeading, +} from '@/shared/Accordion'; + +import { BioEntitiesSubmapItem } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + loadingBioEntityStatusSelector, + numberOfBioEntitiesPerModelSelector, + numberOfBioEntitiesSelector, +} from '@/redux/bioEntity/bioEntity.selectors'; + +export const BioEntitiesAccordion = (): JSX.Element => { + const bioEntitiesNumber = useAppSelector(numberOfBioEntitiesSelector); + const bioEntitiesState = useAppSelector(loadingBioEntityStatusSelector); + const numberOfBioEntitiesPerModel = useAppSelector(numberOfBioEntitiesPerModelSelector); + + return ( + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton> + Content {bioEntitiesState === 'pending' && ' (Loading...)'} + {bioEntitiesState === 'succeeded' && ` (${bioEntitiesNumber})`} + </AccordionItemButton> + </AccordionItemHeading> + <AccordionItemPanel> + {numberOfBioEntitiesPerModel.map(model => ( + <BioEntitiesSubmapItem + key={model.modelName} + mapName={model.modelName} + numberOfEntities={model.numberOfEntities} + /> + ))} + </AccordionItemPanel> + </AccordionItem> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx similarity index 100% rename from src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx rename to src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx similarity index 100% rename from src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx rename to src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts similarity index 100% rename from src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts rename to src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/index.ts similarity index 100% rename from src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/index.ts rename to src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/index.ts diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd116dd9e519875d002638bbea32f5a015d85781 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.test.tsx @@ -0,0 +1,87 @@ +import { act, render, screen } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { Accordion } from '@/shared/Accordion'; +import { drawerSearchStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { ChemicalsAccordion } from './ChemicalsAccordion.component'; + +const SECOND_STEP = 2; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Accordion> + <ChemicalsAccordion /> + </Accordion> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('DrugsAccordion - component', () => { + it('should display drugs number after succesfull chemicals search', () => { + renderComponent({ + chemicals: { data: chemicalsFixture, loading: 'succeeded', error: { name: '', message: '' } }, + }); + expect(screen.getByText('Chemicals (2)')).toBeInTheDocument(); + }); + it('should display loading indicator while waiting for chemicals search response', () => { + renderComponent({ + chemicals: { data: [], loading: 'pending', error: { name: '', message: '' } }, + }); + expect(screen.getByText('Chemicals (Loading...)')).toBeInTheDocument(); + }); + it('should navigate user to chemical results list after clicking button', async () => { + const { store } = renderComponent({ + chemicals: { + data: chemicalsFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + await act(() => { + navigationButton.click(); + }); + + const { + drawer: { + searchDrawerState: { stepType, selectedValue, currentStep }, + }, + } = store.getState(); + + expect(stepType).toBe('chemicals'); + expect(selectedValue).toBe(undefined); + expect(currentStep).toBe(SECOND_STEP); + }); + it('should disable navigation button when there is no chemicals', async () => { + renderComponent({ + chemicals: { data: [], loading: 'succeeded', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); + it('should disable navigation button when waiting for api response', async () => { + renderComponent({ + chemicals: { data: [], loading: 'pending', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..36dc7c61cab95a7f43df3f3e5c7ead3bf8f40225 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/ChemicalsAccordion.component.tsx @@ -0,0 +1,39 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { + numberOfChemicalsSelector, + loadingChemicalsStatusSelector, +} from '@/redux/chemicals/chemicals.selectors'; +import { displayChemicalsList } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { AccordionItem, AccordionItemHeading, AccordionItemButton } from '@/shared/Accordion'; + +export const ChemicalsAccordion = (): JSX.Element => { + const dispatch = useAppDispatch(); + const chemicalsNumber = useAppSelector(numberOfChemicalsSelector); + const chemicalsState = useAppSelector(loadingChemicalsStatusSelector); + + const isPending = chemicalsState === 'pending'; + const isSucceeded = chemicalsState === 'succeeded'; + const isChemicalsEmpty = chemicalsNumber === SIZE_OF_EMPTY_ARRAY; + + const onAccordionClick = (): void => { + dispatch(displayChemicalsList()); + }; + + return ( + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton + variant="non-expandable" + onClick={onAccordionClick} + disabled={isPending || isChemicalsEmpty} + > + Chemicals + {isPending && ' (Loading...)'} + {isSucceeded && ` (${chemicalsNumber})`} + </AccordionItemButton> + </AccordionItemHeading> + </AccordionItem> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9fc44924d2be7c38dcb00d5a0a5289b7bf237a1a --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/ChemicalsAccordion/index.ts @@ -0,0 +1 @@ +export { ChemicalsAccordion } from './ChemicalsAccordion.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff2cc2dbf7fc8816c358cf5845a45fc269fb0840 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.test.tsx @@ -0,0 +1,87 @@ +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { StoreType } from '@/redux/store'; +import { Accordion } from '@/shared/Accordion'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { act, render, screen } from '@testing-library/react'; +import { drawerSearchStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { DrugsAccordion } from './DrugsAccordion.component'; + +const SECOND_STEP = 2; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Accordion> + <DrugsAccordion /> + </Accordion> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('DrugsAccordion - component', () => { + it('should display drugs number after succesfull drug search', () => { + renderComponent({ + drugs: { data: drugsFixture, loading: 'succeeded', error: { name: '', message: '' } }, + }); + expect(screen.getByText('Drugs (2)')).toBeInTheDocument(); + }); + it('should display loading indicator while waiting for drug search response', () => { + renderComponent({ + drugs: { data: [], loading: 'pending', error: { name: '', message: '' } }, + }); + expect(screen.getByText('Drugs (Loading...)')).toBeInTheDocument(); + }); + it('should navigate user to chemical results list after clicking button', async () => { + const { store } = renderComponent({ + drugs: { + data: drugsFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + await act(() => { + navigationButton.click(); + }); + + const { + drawer: { + searchDrawerState: { stepType, selectedValue, currentStep }, + }, + } = store.getState(); + + expect(stepType).toBe('drugs'); + expect(selectedValue).toBe(undefined); + expect(currentStep).toBe(SECOND_STEP); + }); + it('should disable navigation button when there is no drugs', async () => { + renderComponent({ + drugs: { data: [], loading: 'succeeded', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); + it('should disable navigation button when waiting for api response', async () => { + renderComponent({ + drugs: { data: [], loading: 'pending', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e92ba2bd1d565d99c5200c69bf73e70fd0820603 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/DrugsAccordion.component.tsx @@ -0,0 +1,36 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { displayDrugsList } from '@/redux/drawer/drawer.slice'; +import { loadingDrugsStatusSelector, numberOfDrugsSelector } from '@/redux/drugs/drugs.selectors'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { AccordionItem, AccordionItemHeading, AccordionItemButton } from '@/shared/Accordion'; + +export const DrugsAccordion = (): JSX.Element => { + const drugsNumber = useAppSelector(numberOfDrugsSelector); + const drugsState = useAppSelector(loadingDrugsStatusSelector); + const dispatch = useAppDispatch(); + + const isPending = drugsState === 'pending'; + const isSucceeded = drugsState === 'succeeded'; + const isDrugsEmpty = drugsNumber === SIZE_OF_EMPTY_ARRAY; + + const onAccordionClick = (): void => { + dispatch(displayDrugsList()); + }; + + return ( + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton + variant="non-expandable" + onClick={onAccordionClick} + disabled={isPending || isDrugsEmpty} + > + Drugs + {isPending && ' (Loading...)'} + {isSucceeded && ` (${drugsNumber})`} + </AccordionItemButton> + </AccordionItemHeading> + </AccordionItem> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..36be6a7acb3a6c9fb32efdfa5cbb70efac87a909 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/DrugsAccordion/index.ts @@ -0,0 +1 @@ +export { DrugsAccordion } from './DrugsAccordion.component'; diff --git a/src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx similarity index 55% rename from src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx rename to src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx index ad1b350ce514b1077ec72147ee250400c3c1174f..29a6076e9937a13aea95d1245c0e8c3c2ac48834 100644 --- a/src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx @@ -1,11 +1,15 @@ -import { BioEntitiesAccordion } from '@/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion'; +import { BioEntitiesAccordion } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion'; +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'; export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; -export const SearchDrawerContent = (): JSX.Element => { +export const GroupedSearchResults = (): JSX.Element => { const dispatch = useAppDispatch(); const handleCloseDrawer = (): void => { @@ -13,7 +17,7 @@ export const SearchDrawerContent = (): JSX.Element => { }; return ( - <div className="flex flex-col" data-testid="search-drawer-content"> + <div className="flex flex-col" data-testid="grouped-search-results"> <div className="flex items-center justify-between border-b border-b-divide px-6"> <div className=" py-8 text-xl"> <span className="font-normal">Search: </span> @@ -28,7 +32,12 @@ export const SearchDrawerContent = (): JSX.Element => { /> </div> <div className="px-6"> - <BioEntitiesAccordion /> + <Accordion allowZeroExpanded> + <BioEntitiesAccordion /> + <DrugsAccordion /> + <ChemicalsAccordion /> + <MirnaAccordion /> + </Accordion> </div> </div> ); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..334fce860b2ba396d967c6af477f6273946cb28f --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.test.tsx @@ -0,0 +1,87 @@ +import { act, render, screen } from '@testing-library/react'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { mirnasFixture } from '@/models/fixtures/mirnasFixture'; +import { Accordion } from '@/shared/Accordion'; +import { drawerSearchStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { MirnaAccordion } from './MirnaAccordion.component'; + +const SECOND_STEP = 2; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <Accordion> + <MirnaAccordion /> + </Accordion> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('DrugsAccordion - component', () => { + it('should display drugs number after succesfull chemicals search', () => { + renderComponent({ + mirnas: { data: mirnasFixture, loading: 'succeeded', error: { name: '', message: '' } }, + }); + expect(screen.getByText('MiRNA (2)')).toBeInTheDocument(); + }); + it('should display loading indicator while waiting for chemicals search response', () => { + renderComponent({ + mirnas: { data: [], loading: 'pending', error: { name: '', message: '' } }, + }); + expect(screen.getByText('MiRNA (Loading...)')).toBeInTheDocument(); + }); + it('should navigate user to mirnas results list after clicking button', async () => { + const { store } = renderComponent({ + mirnas: { + data: mirnasFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + await act(() => { + navigationButton.click(); + }); + + const { + drawer: { + searchDrawerState: { stepType, selectedValue, currentStep }, + }, + } = store.getState(); + + expect(stepType).toBe('mirna'); + expect(selectedValue).toBe(undefined); + expect(currentStep).toBe(SECOND_STEP); + }); + it('should disable navigation button when there is no mirnas', async () => { + renderComponent({ + mirnas: { data: [], loading: 'succeeded', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); + it('should disable navigation button when waiting for api response', async () => { + renderComponent({ + mirnas: { data: [], loading: 'pending', error: { name: '', message: '' } }, + drawer: drawerSearchStepOneFixture, + }); + + const navigationButton = screen.getByTestId('accordion-item-button'); + expect(navigationButton).toBeDisabled(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6fdf742e0651d879fc8ebfcddbf25feb1683c93 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/MirnaAccordion.component.tsx @@ -0,0 +1,38 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { displayMirnaList } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { + numberOfMirnasSelector, + loadingMirnasStatusSelector, +} from '@/redux/mirnas/mirnas.selectors'; +import { AccordionItem, AccordionItemHeading, AccordionItemButton } from '@/shared/Accordion'; + +export const MirnaAccordion = (): JSX.Element => { + const dispatch = useAppDispatch(); + const mirnaNumber = useAppSelector(numberOfMirnasSelector); + const mirnaState = useAppSelector(loadingMirnasStatusSelector); + + const isPending = mirnaState === 'pending'; + const isSucceeded = mirnaState === 'succeeded'; + const isDrugsEmpty = mirnaNumber === SIZE_OF_EMPTY_ARRAY; + + const onAccordionClick = (): void => { + dispatch(displayMirnaList()); + }; + return ( + <AccordionItem> + <AccordionItemHeading> + <AccordionItemButton + variant="non-expandable" + onClick={onAccordionClick} + disabled={isPending || isDrugsEmpty} + > + MiRNA + {isPending && ' (Loading...)'} + {isSucceeded && ` (${mirnaNumber})`} + </AccordionItemButton> + </AccordionItemHeading> + </AccordionItem> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..69f2736c465dba1da3c0737fa07a3a8ac5268b8c --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/MirnaAccordion/index.ts @@ -0,0 +1 @@ +export { MirnaAccordion } from './MirnaAccordion.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..99f53f8e7845637008091edf91ce2a73464e8dc1 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/index.ts @@ -0,0 +1 @@ +export { GroupedSearchResults } from './GroupedSearchResults.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..676b58b498fe22ae75ade2b35bc7b5655b6b4bd3 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.test.tsx @@ -0,0 +1,60 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { drawerSearchDrugsStepTwoFixture } from '@/redux/drawer/drawerFixture'; +import { PinsList } from './PinsList.component'; + +const PINS_LIST = drugsFixture.map(drug => ({ + id: drug.id, + name: drug.name, + data: drug, +})); + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <PinsList pinsList={PINS_LIST} type="drugs" /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('PinsList component', () => { + it('should render list of pins', () => { + renderComponent(); + + const fristDrugName = drugsFixture[0].name; + const secondDrugName = drugsFixture[1].name; + + expect(screen.getByText(fristDrugName)).toBeInTheDocument(); + expect(screen.getByText(secondDrugName)).toBeInTheDocument(); + }); + it('should navigate to details step on pin click', () => { + const { store } = renderComponent({ drawer: drawerSearchDrugsStepTwoFixture }); + + const firstPin = screen.getAllByRole('button')[0]; + firstPin.click(); + + const { + drawer: { + searchDrawerState: { currentStep, stepType, selectedValue }, + }, + } = store.getState(); + const drug = drugsFixture[0]; + + expect(currentStep).toBe(3); + expect(stepType).toBe('drugs'); + expect(selectedValue).toEqual(drug); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..caa8ea04e9a0ee89c4aea8dfe5e041ad00236366 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx @@ -0,0 +1,31 @@ +import { BioEntityContent, Chemical, Drug, Mirna } from '@/types/models'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { displayEntityDetails } from '@/redux/drawer/drawer.slice'; +import { PinItem, PinType } from './PinsList.types'; +import { PinsListItem } from './PinsListItem'; + +interface PinsListProps { + pinsList: PinItem[]; + type: PinType; +} + +export const PinsList = ({ pinsList, type }: PinsListProps): JSX.Element => { + const dispatch = useAppDispatch(); + + const onPinClick = (data: BioEntityContent | Drug | Chemical | Mirna): void => { + dispatch(displayEntityDetails(data)); + }; + + return ( + <ul className="px-6 py-2"> + {pinsList.map(pin => ( + <PinsListItem + key={pin.id} + name={pin.name} + type={type} + onClick={(): void => onPinClick(pin.data)} + /> + ))} + </ul> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5369b84d3ce8b27b358e24251e62cbdb79d544d8 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types.tsx @@ -0,0 +1,9 @@ +import { BioEntityContent, Drug, Chemical, Mirna } from '@/types/models'; + +export type PinItem = { + id: string | number; + name: string; + data: Drug | Chemical | Mirna | BioEntityContent; +}; + +export type PinType = 'chemicals' | 'drugs' | 'mirna' | 'bioEntity' | 'none'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ae58c0eae102094aa29f85a315b97d57fe326dee --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.tsx @@ -0,0 +1,22 @@ +import { twMerge } from 'tailwind-merge'; +import { Icon } from '@/shared/Icon'; +import { getPinColor } from './PinsListItem.component.utils'; +import { PinType } from '../PinsList.types'; + +interface PinsListItemProps { + name: string; + type: PinType; + onClick: () => void; +} + +export const PinsListItem = ({ name, type, onClick }: PinsListItemProps): JSX.Element => ( + <button + className="flex w-full flex-row items-center justify-between pt-4" + onClick={onClick} + type="button" + > + <Icon name="pin" className={twMerge('mr-2 shrink-0', getPinColor(type))} /> + <p className="w-full text-left">{name}</p> + <Icon name="chevron-right" className="h-6 w-6 shrink-0" /> + </button> +); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e4cb8ee6b0063fdad8e39d39f0d63f823201f38 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/PinsListItem.component.utils.ts @@ -0,0 +1,13 @@ +import { PinType } from '../PinsList.types'; + +export const getPinColor = (type: PinType): string => { + const pinColors: Record<PinType, string> = { + bioEntity: 'fill-primary-500', + drugs: 'fill-orange', + chemicals: 'fill-purple', + mirna: 'fill-primary-500', + none: 'none', + }; + + return pinColors[type]; +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..89b9aebca900ed5e4282564c6b0221ff861325d5 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsListItem/index.ts @@ -0,0 +1 @@ +export { PinsListItem } from './PinsListItem.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c9d388b05a673ede7c8f42b9611fabba0eae00d7 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/index.ts @@ -0,0 +1 @@ +export { PinsList } from './PinsList.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7e310b6ba19f3df32f889526f1f555c303f36808 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.test.tsx @@ -0,0 +1,86 @@ +/* eslint-disable no-magic-numbers */ +import { act, render, screen } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { StoreType } from '@/redux/store'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { ResultsList } from './ResultsList.component'; + +const INITIAL_STATE: InitialStoreState = { + search: { + searchValue: 'aspirin', + loading: 'idle', + }, + drawer: { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 2, + stepType: 'drugs', + selectedValue: undefined, + }, + }, + drugs: { + data: drugsFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + }, +}; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ResultsList /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ResultsList - component ', () => { + it('should render results and navigation panel', () => { + renderComponent(INITIAL_STATE); + + expect(screen.getByText('drugs:')).toBeInTheDocument(); + expect(screen.getByText('aspirin')).toBeInTheDocument(); + + const fristDrugName = drugsFixture[0].name; + const secondDrugName = drugsFixture[1].name; + + expect(screen.getByText(fristDrugName)).toBeInTheDocument(); + expect(screen.getByText(secondDrugName)).toBeInTheDocument(); + }); + it('should navigate to grouped search results after backward button click', async () => { + const { store } = renderComponent(INITIAL_STATE); + + const { + drawer: { + searchDrawerState: { currentStep, stepType }, + }, + } = store.getState(); + + expect(currentStep).toBe(2); + expect(stepType).toBe('drugs'); + + const backwardButton = screen.getByRole('close-drawer-button'); + await act(() => { + backwardButton.click(); + }); + + const { + drawer: { + searchDrawerState: { currentStep: updatedStep, stepType: updatedStepType }, + }, + } = store.getState(); + + expect(updatedStep).toBe(1); + expect(updatedStepType).toBe('none'); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..25000b8f48fd60afb648915f6a8302a9bfa3341e --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx @@ -0,0 +1,29 @@ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { resultListSelector, stepTypeDrawerSelector } from '@/redux/drawer/drawer.selectors'; +import { DrawerHeadingBackwardButton } from '@/shared/DrawerHeadingBackwardButton'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { displayGroupedSearchResults } from '@/redux/drawer/drawer.slice'; +import { searchValueSelector } from '@/redux/search/search.selectors'; +import { PinsList } from './PinsList'; + +export const ResultsList = (): JSX.Element => { + const dispatch = useAppDispatch(); + const data = useAppSelector(resultListSelector); + const stepType = useAppSelector(stepTypeDrawerSelector); + const searchValue = useAppSelector(searchValueSelector); + + const navigateToGroupedSearchResults = (): void => { + dispatch(displayGroupedSearchResults()); + }; + + return ( + <div> + <DrawerHeadingBackwardButton + title={stepType} + value={searchValue} + backwardFunction={navigateToGroupedSearchResults} + /> + {data && <PinsList pinsList={data} type={stepType} />} + </div> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5ca1e342f9ed184a55d957f019d6cdb36488117b --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/index.ts @@ -0,0 +1 @@ +export { ResultsList } from './ResultsList.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6322951135c2f2c34a38c96226e93783a3af004b --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx @@ -0,0 +1,99 @@ +import { SearchDrawerWrapper } from '@/components/Map/Drawer/SearchDrawerWrapper'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { drugFixture } from '@/models/fixtures/drugFixtures'; +import { drawerSearchStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <SearchDrawerWrapper /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('SearchDrawerWrapper - component', () => { + it('should display the first step for search', () => { + renderComponent({ + drawer: drawerSearchStepOneFixture, + }); + + expect(screen.getByTestId('grouped-search-results')).toBeInTheDocument(); + }); + + it('should display the second step for value type bioEntity', () => { + renderComponent({ + drawer: { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 2, + stepType: 'bioEntity', + selectedValue: undefined, + }, + }, + }); + + expect(screen.getByTestId('search-second-step')).toBeInTheDocument(); + }); + + it('should display the second step for value type drugs', () => { + renderComponent({ + drawer: { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 2, + stepType: 'drugs', + selectedValue: undefined, + }, + }, + }); + + expect(screen.getByTestId('search-second-step')).toBeInTheDocument(); + }); + + it('should display the third step for value type bioEntity', () => { + renderComponent({ + drawer: { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 3, + stepType: 'bioEntity', + selectedValue: bioEntityContentFixture, + }, + }, + }); + + expect(screen.getByTestId('search-third-step')).toBeInTheDocument(); + }); + + it('should display the third step for value type drugs', () => { + renderComponent({ + drawer: { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 3, + stepType: 'drugs', + selectedValue: drugFixture, + }, + }, + }); + + expect(screen.getByTestId('search-third-step')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..23aa97631a454e639c903932d0b297ad644b60ce --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx @@ -0,0 +1,42 @@ +import { BIO_ENTITY, DRUGS_CHEMICALS_MIRNA } from '@/constants'; +import { STEP } from '@/constants/searchDrawer'; +import { + currentStepDrawerStateSelector, + stepTypeDrawerSelector, +} from '@/redux/drawer/drawer.selectors'; +import { useSelector } from 'react-redux'; +import { ResultsList } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList'; +import { GroupedSearchResults } from '@/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults'; + +export const SearchDrawerWrapper = (): JSX.Element => { + const currentStep = useSelector(currentStepDrawerStateSelector); + const stepType = useSelector(stepTypeDrawerSelector); + + const isBioEntityType = stepType === BIO_ENTITY; + 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> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/index.ts b/src/components/Map/Drawer/SearchDrawerWrapper/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee8e9189191c28b0933c04d1af693c2c22cd86a9 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerWrapper/index.ts @@ -0,0 +1 @@ +export { SearchDrawerWrapper } from './SearchDrawerWrapper.component'; diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index c4b61c47c268f3421b3e2fa908b7853e2b6ff8e6..c9b5381584b0a04df6a647779e15e82e4bff4962 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -2,6 +2,9 @@ import { Manrope } from '@next/font/google'; import { twMerge } from 'tailwind-merge'; import { Map } from '@/components/Map'; import { FunctionalArea } from '@/components/FunctionalArea'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useEffect } from 'react'; +import { getModels } from '@/redux/models/models.thunks'; const manrope = Manrope({ variable: '--font-manrope', @@ -10,9 +13,17 @@ const manrope = Manrope({ subsets: ['latin'], }); -export const MinervaSPA = (): JSX.Element => ( - <div className={twMerge('relative', manrope.variable)}> - <FunctionalArea /> - <Map /> - </div> -); +export const MinervaSPA = (): JSX.Element => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(getModels()); + }, [dispatch]); + + return ( + <div className={twMerge('relative', manrope.variable)}> + <FunctionalArea /> + <Map /> + </div> + ); +}; diff --git a/src/constants/common.ts b/src/constants/common.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee434dc7c1ebfa6b43b4f2b6004ef5d042a41861 --- /dev/null +++ b/src/constants/common.ts @@ -0,0 +1 @@ +export const SIZE_OF_EMPTY_ARRAY = 0; diff --git a/src/constants/index.ts b/src/constants/index.ts index 2b1791dc989e6b1798d2cc13dea9efa66687ff29..e85e24dc568f5deb331a245f1fc3d927791b4fa2 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,9 @@ +/* eslint-disable no-magic-numbers */ + export const BASE_API_URL = process.env.NEXT_PUBLIC_BASE_API_URL || ''; export const BASE_MAP_IMAGES_URL = process.env.BASE_MAP_IMAGES_URL || ''; +export const BASE_NEW_API_URL = process.env.NEXT_PUBLIC_BASE_NEW_API_URL || ''; export const PROJECT_ID = process.env.NEXT_PUBLIC_PROJECT_ID || ''; -export const ZOD_SEED = 997; +export const ZOD_SEED = parseInt(process.env.ZOD_SEED || '123', 10); +export const BIO_ENTITY = 'bioEntity'; +export const DRUGS_CHEMICALS_MIRNA = ['drugs', 'chemicals', 'mirna']; diff --git a/src/constants/mocks.ts b/src/constants/mocks.ts new file mode 100644 index 0000000000000000000000000000000000000000..d7156f1ea3552ebde078a9d4dd4a84070f50f26e --- /dev/null +++ b/src/constants/mocks.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line no-magic-numbers +export const MODEL_IDS_MOCK = [5052, 5053, 5054]; diff --git a/src/constants/searchDrawer.ts b/src/constants/searchDrawer.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc1b7da72a652c9b0b755f71034b61cd477c6ccd --- /dev/null +++ b/src/constants/searchDrawer.ts @@ -0,0 +1,5 @@ +export const STEP = { + FIRST: 1, + SECOND: 2, + THIRD: 3, +}; 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/models/arrowSchema.ts b/src/models/arrowSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..68c5a069a21b62038fbc3008bd56d671327c9d26 --- /dev/null +++ b/src/models/arrowSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const arrowSchema = z.object({ + arrowType: z.string(), + angle: z.number(), + lineType: z.string(), + length: z.number(), +}); diff --git a/src/models/authorSchema.ts b/src/models/authorSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dfa82ec918b797f2a3b1cd8816e197274cd2f1c --- /dev/null +++ b/src/models/authorSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const authorSchema = z.string(); diff --git a/src/models/bioEntityContentSchema.ts b/src/models/bioEntityContentSchema.ts index 1eb0d70467b9684f15aeaaedfeea1106e5fbdaa7..4ff85815794b73a621cf5909903b3de1f3443745 100644 --- a/src/models/bioEntityContentSchema.ts +++ b/src/models/bioEntityContentSchema.ts @@ -1,7 +1,8 @@ import { z } from 'zod'; +import { bioEntitySchema } from './bioEntitySchema'; export const bioEntityContentSchema = z.object({ - id: z.number(), - modelId: z.number(), - type: z.string(), + bioEntity: bioEntitySchema, + /** indicates if bioEntity matches perfect match even if not provided in query */ + perfect: z.boolean(), }); diff --git a/src/models/bioEntityResponseSchema.ts b/src/models/bioEntityResponseSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4b4827bfb60345751424d36fb8d4f68be030b5e --- /dev/null +++ b/src/models/bioEntityResponseSchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod'; +import { bioEntityContentSchema } from './bioEntityContentSchema'; + +export const bioEntityResponseSchema = z.object({ + content: z.array(bioEntityContentSchema), + totalPages: z.number(), + totalElements: z.number(), + numberOfElements: z.number(), + size: z.number(), + number: z.number(), +}); diff --git a/src/models/bioEntitySchema.ts b/src/models/bioEntitySchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..51f1880c44af38be603d8633c84bb0b8d7dc1432 --- /dev/null +++ b/src/models/bioEntitySchema.ts @@ -0,0 +1,80 @@ +import { z } from 'zod'; +import { referenceSchema } from './referenceSchema'; +import { glyphSchema } from './glyphSchema'; +import { modificationResiduesSchema } from './modificationResiduesSchema'; +import { submodelSchema } from './submodelSchema'; +import { colorSchema } from './colorSchema'; +import { productsSchema } from './products'; +import { lineSchema } from './lineSchema'; +import { operatorSchema } from './operatorSchema'; +import { structuralStateSchema } from './structuralStateSchema'; + +export const bioEntitySchema = z.object({ + id: z.number(), + stringType: z.string(), + name: z.string(), + elementId: z.string(), + model: z.number(), + references: z.array(referenceSchema), + z: z.number(), + notes: z.string(), + symbol: z.string().nullable(), + homodimer: z.number(), + nameX: z.number(), + nameY: z.number(), + nameWidth: z.number(), + nameHeight: z.number(), + nameVerticalAlign: z.string(), + nameHorizontalAlign: z.string(), + width: z.number(), + height: z.number(), + visibilityLevel: z.string(), + transparencyLevel: z.string(), + synonyms: z.array(z.string()), + formerSymbols: z.array(z.string()), + fullName: z.string().nullable(), + abbreviation: z.string().nullable(), + formula: z.string().nullable(), + glyph: glyphSchema.nullable(), + activity: z.boolean(), + structuralState: structuralStateSchema.nullable(), + hypothetical: z.boolean().nullable(), + boundaryCondition: z.boolean(), + constant: z.boolean(), + initialAmount: z.number().nullable(), + initialConcentration: z.number().nullable(), + charge: z.number().nullable(), + substanceUnits: z.string().nullable(), + onlySubstanceUnits: z.boolean(), + modificationResidues: z.optional(z.array(modificationResiduesSchema)), + complex: z.number().nullable(), + compartment: z.number().nullable(), + submodel: submodelSchema.nullable(), + x: z.number(), + y: z.number(), + lineWidth: z.number(), + fontColor: colorSchema, + fontSize: z.number(), + fillColor: colorSchema, + borderColor: colorSchema, + smiles: z.optional(z.string()), + inChI: z.optional(z.string().nullable()), + inChIKey: z.optional(z.string().nullable()), + thickness: z.optional(z.number()), + outerWidth: z.optional(z.number()), + innerWidth: z.optional(z.number()), + idReaction: z.optional(z.string()), + reversible: z.optional(z.boolean()), + mechanicalConfidenceScore: z.optional(z.boolean()), + lowerBound: z.optional(z.boolean()), + upperBound: z.optional(z.boolean()), + subsystem: z.optional(z.string()), + geneProteinReaction: z.optional(z.string()), + kinetics: z.optional(z.null()), + products: z.optional(z.array(productsSchema)), + reactants: z.optional(z.array(productsSchema)), + modifiers: z.optional(z.array(productsSchema)), + processCoordinates: z.optional(z.null()), + line: z.optional(lineSchema), + operators: z.optional(z.array(operatorSchema)), +}); diff --git a/src/models/colorSchema.ts b/src/models/colorSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..d40933695d9664644a048abb1888c8700afffa47 --- /dev/null +++ b/src/models/colorSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const colorSchema = z.object({ + alpha: z.number(), + rgb: z.number(), +}); diff --git a/src/models/fixtures/backgroundsFixture.ts b/src/models/fixtures/backgroundsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..e06e5c15e67fea0ee563e14f80792846f5ed998c --- /dev/null +++ b/src/models/fixtures/backgroundsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { mapBackground } from '../mapBackground'; + +export const backgroundsFixture = createFixture(z.array(mapBackground), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/fixtures/bioEntityContentsFixture.ts b/src/models/fixtures/bioEntityContentsFixture.ts index adc441d36ea3283a78f4e9134b1be4cbc8f0455d..8c9396548099eeecb25895e0acb2c3a89d564565 100644 --- a/src/models/fixtures/bioEntityContentsFixture.ts +++ b/src/models/fixtures/bioEntityContentsFixture.ts @@ -1,10 +1,29 @@ -import { ZOD_SEED } from '@/constants'; -import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { z } from 'zod'; // eslint-disable-next-line import/no-extraneous-dependencies -import { createFixture } from 'zod-fixture'; +import { createFixture, Fixture } from 'zod-fixture'; +import { ZOD_SEED } from '@/constants'; +import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; +import { modelIdGenerator } from '@/models/generators/modelIdGenerator'; +import { bioEntityResponseSchema } from '../bioEntityResponseSchema'; -export const bioEntityContentsFixture = createFixture(z.array(bioEntityContentSchema), { +export const bioEntityResponseFixture = createFixture(bioEntityResponseSchema, { seed: ZOD_SEED, - array: { min: 2, max: 2 }, }); + +const bioEntityFixtureGenerator = new Fixture({ seed: ZOD_SEED }).extend([modelIdGenerator]); + +export const bioEntityContentFixture = bioEntityFixtureGenerator.fromSchema( + bioEntityContentSchema, + { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, + }, +); + +export const bioEntitiesContentFixture = bioEntityFixtureGenerator.fromSchema( + z.array(bioEntityContentSchema), + { + seed: ZOD_SEED, + array: { min: 10, max: 10 }, + }, +); diff --git a/src/models/fixtures/drugFixtures.ts b/src/models/fixtures/drugFixtures.ts index 44c60b79eabff4c457ecd14d1f1c5ec0bb398cf5..b7d74539f4873cfe2008de418001d973a7142cd8 100644 --- a/src/models/fixtures/drugFixtures.ts +++ b/src/models/fixtures/drugFixtures.ts @@ -8,3 +8,8 @@ export const drugsFixture = createFixture(z.array(drugSchema), { seed: ZOD_SEED, array: { min: 2, max: 2 }, }); + +export const drugFixture = createFixture(drugSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/fixtures/modelsFixture.ts b/src/models/fixtures/modelsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..2eb9109dbac56042aafff5dd0a25a49f61be2c64 --- /dev/null +++ b/src/models/fixtures/modelsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { mapModelSchema } from '@/models/modelSchema'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; + +export const modelsFixture = createFixture(z.array(mapModelSchema), { + seed: ZOD_SEED, + array: { min: 3, max: 3 }, +}); diff --git a/src/models/fixtures/overlaysFixture.ts b/src/models/fixtures/overlaysFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..c0a26efd4daccf2dbd062e7b37a67ef6e2d1033a --- /dev/null +++ b/src/models/fixtures/overlaysFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { mapOverlay } from '../mapOverlay'; + +export const overlaysFixture = createFixture(z.array(mapOverlay), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/fixtures/projectFixture.ts b/src/models/fixtures/projectFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..99e01bb36cb06a4dac15e20fbf116238a398a1f5 --- /dev/null +++ b/src/models/fixtures/projectFixture.ts @@ -0,0 +1,9 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { projectSchema } from '../project'; + +export const projectFixture = createFixture(projectSchema, { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/generators/modelIdGenerator.ts b/src/models/generators/modelIdGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea755e6fd41c797ac12b10fffef1e34ed5615345 --- /dev/null +++ b/src/models/generators/modelIdGenerator.ts @@ -0,0 +1,12 @@ +import { MODEL_IDS_MOCK } from '@/constants/mocks'; +import { ZodNumber } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { Generator } from 'zod-fixture'; + +// model in bioEntity model is the same as idObject in model model +export const modelIdGenerator = Generator({ + schema: ZodNumber, + // eslint-disable-next-line no-magic-numbers + filter: ({ context }) => context.path.at(-1) === 'model', + output: ({ transform }) => transform.utils.random.from(MODEL_IDS_MOCK), +}); diff --git a/src/models/glyphSchema.ts b/src/models/glyphSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed69e8141e58fbe4002bb5a614fd3e673329377c --- /dev/null +++ b/src/models/glyphSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const glyphSchema = z.object({ + file: z.number(), +}); diff --git a/src/models/lineSchema.ts b/src/models/lineSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..8db5ae33c1aabc5890cb62ed423e94a8c5b9c08b --- /dev/null +++ b/src/models/lineSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { colorSchema } from './colorSchema'; +import { segmentSchema } from './segmentSchema'; +import { arrowSchema } from './arrowSchema'; + +export const lineSchema = z.object({ + id: z.number(), + width: z.number(), + color: colorSchema, + z: z.number(), + segments: z.array(segmentSchema), + startArrow: arrowSchema, + endArrow: arrowSchema, + lineType: z.string(), +}); diff --git a/src/models/mapModel.ts b/src/models/mapModel.ts deleted file mode 100644 index e50dd6c458e988c46be55d2f054d9982369d8478..0000000000000000000000000000000000000000 --- a/src/models/mapModel.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { z } from 'zod'; - -export const mapModel = z.object({ - idObject: z.number(), - width: z.number(), - height: z.number(), - defaultCenterX: z.number().nullable(), - defaultCenterY: z.number().nullable(), - description: z.string(), - name: z.string(), - defaultZoomLevel: z.number().nullable(), - tileSize: z.number(), - references: z.array(z.unknown()), - authors: z.array(z.unknown()), - creationDate: z.unknown(), - modificationDates: z.array(z.unknown()), - minZoom: z.number(), - maxZoom: z.number(), -}); diff --git a/src/models/mocks/modelsMock.ts b/src/models/mocks/modelsMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e4aa9a0a2d900c05d0b41edf786d0ca2895d2be2 --- /dev/null +++ b/src/models/mocks/modelsMock.ts @@ -0,0 +1,405 @@ +import { MapModel } from '@/types/models'; + +export const MODELS_MOCK: MapModel[] = [ + { + idObject: 5053, + width: 26779.25, + height: 13503.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Core PD map', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, + { + idObject: 5052, + width: 3511.09375, + height: 1312.125, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Histamine signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 6, + }, + { + idObject: 5054, + width: 1652.75, + height: 1171.9429798877356, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'PRKN substrates', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5055, + width: 2473.8078571428596, + height: 1143.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Ubiquitin-proteasome system', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 6, + }, + { + idObject: 5056, + width: 1975.0, + height: 1950.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'MTOR AMPK signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5057, + width: 21838.0, + height: 10376.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'PI3K AKT signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 9, + }, + { + idObject: 5058, + width: 5170.0, + height: 1535.1097689075634, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Axonal remodeling and CDK5 signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5059, + width: 4556.0, + height: 2852.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Cell death', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5060, + width: 2500.0, + height: 1238.25, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Actin filament organization', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 6, + }, + { + idObject: 5061, + width: 1289.0, + height: 1572.2941176470588, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Wnt signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5062, + width: 1220.0, + height: 1395.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Autophagy', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5063, + width: 9215.0, + height: 3880.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Mitochondrial and ROS metabolism', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 8, + }, + { + idObject: 5064, + width: 9102.0, + height: 4544.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Iron metabolism', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 8, + }, + { + idObject: 5065, + width: 1639.0, + height: 1814.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'FOXO3 activity', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5066, + width: 2823.0, + height: 1695.5, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Inflammation signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 6, + }, + { + idObject: 5067, + width: 1980.0, + height: 1740.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'LRRK2 activity', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 5, + }, + { + idObject: 5068, + width: 10312.75, + height: 4172.15625, + defaultCenterX: null, + defaultCenterY: null, + description: + 'For information on content, functionalities and referencing the Parkinson\'s disease map, click <a href="http://pdmap.uni.lu" target="_blank">here</a>\n\n.', + name: 'Neuroinflammation', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 8, + }, + { + idObject: 5069, + width: 4368.5, + height: 1644.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'SHH signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5070, + width: 5092.0, + height: 2947.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Glycolysis', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5071, + width: 5497.5, + height: 3699.25, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Scrapbook', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5072, + width: 11529.0, + height: 6911.0, + defaultCenterX: null, + defaultCenterY: null, + description: + 'General location of compartments:\n-Upper left: cortex\n-Middle left: cerebellum\n-Bottom left: PNS\n-Center: subpallium\n-Middle center: hypothalamus\n-Bottom center: liver and pancreas\n-Upper right: Pallium\n-Lower right: hippocampus\nColor compartment/complexes description:\n-green: they are migratory systems instead of compartments.\n-blue: location might be not appropriated.\n-red: it is not the real location. It was used to do not overcross many other parts of the map.\n \nOther remarks:\n1. State transitions were used to represent both state transition and migration.\n2. Empty compartments were placed to better visualized the brain anatomy.\n3. Unknown influence indicates coexpression.\n4. If specific brain developing time was available, it was included as a layer.\n5. If same reactions took place in additional compartments, names of those other compartments were included as a layer.', + name: 'NR2F1 signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 8, + }, + { + idObject: 5073, + width: 8081.0, + height: 5096.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'SRR signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, + { + idObject: 5074, + width: 4498.0, + height: 2653.0, + defaultCenterX: null, + defaultCenterY: null, + description: '', + name: 'Nicotine signaling', + defaultZoomLevel: null, + tileSize: 256, + references: [], + authors: [], + creationDate: null, + modificationDates: [], + minZoom: 2, + maxZoom: 7, + }, +]; diff --git a/src/models/modelSchema.ts b/src/models/modelSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a2e05485a93705d362914bcf8ac9c2bb67161e8 --- /dev/null +++ b/src/models/modelSchema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { authorSchema } from './authorSchema'; +import { referenceSchema } from './referenceSchema'; + +export const mapModelSchema = z.object({ + /** name of the map */ + name: z.string(), + description: z.string(), + /** map id */ + idObject: z.number(), + /** map width */ + width: z.number(), + /** map height */ + height: z.number(), + /** size of the png tile used to visualize in frontend */ + tileSize: z.number(), + /** default x center used in frontend visualization */ + defaultCenterX: z.number().nullable(), + /** default y center used in frontend visualization */ + defaultCenterY: z.number().nullable(), + /** default zoom level used in frontend visualization */ + defaultZoomLevel: z.number().nullable(), + /** minimum zoom level availbale for the map */ + minZoom: z.number(), + /** maximum zoom level available for the map */ + maxZoom: z.number(), + authors: z.array(authorSchema), + references: z.array(referenceSchema), + creationDate: z.string().nullable(), + modificationDates: z.array(z.string()), +}); diff --git a/src/models/modificationResiduesSchema.ts b/src/models/modificationResiduesSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..884ebe4b5c2f5c5fe59eec89a2c115123b8adb73 --- /dev/null +++ b/src/models/modificationResiduesSchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { positionSchema } from './positionSchema'; +import { colorSchema } from './colorSchema'; + +export const modificationResiduesSchema = z.object({ + id: z.number(), + idModificationResidue: z.string(), + name: z.string(), + position: positionSchema, + z: z.number(), + borderColor: colorSchema, + state: z.union([z.string(), z.number()]).nullable(), + size: z.number(), + elementId: z.string(), +}); diff --git a/src/models/operatorSchema.ts b/src/models/operatorSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..20f6305e34862db3cda4e2e4e059e8079f8873de --- /dev/null +++ b/src/models/operatorSchema.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { lineSchema } from './lineSchema'; + +export const operatorSchema = z.object({ + id: z.number(), + line: lineSchema, + inputs: z.array(z.object({ id: z.number() })), + outputs: z.undefined(), + operatorText: z.string(), + reactantOperator: z.boolean(), + productOperator: z.boolean(), + modifierOperator: z.boolean(), +}); diff --git a/src/models/overviewImageLink.ts b/src/models/overviewImageLink.ts new file mode 100644 index 0000000000000000000000000000000000000000..7c31c710645441466fa82e65a036c9065c84d511 --- /dev/null +++ b/src/models/overviewImageLink.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +import { positionSchema } from './positionSchema'; + +export const overviewImageLink = z.union([ + z.object({ + idObject: z.number(), + polygon: z.array(positionSchema), + imageLinkId: z.number(), + type: z.string(), + }), + z.object({ + idObject: z.number(), + polygon: z.array(positionSchema), + zoomLevel: z.number(), + modelPoint: positionSchema, + modelLinkId: z.number(), + type: z.string(), + }), +]); diff --git a/src/models/overviewImageView.ts b/src/models/overviewImageView.ts index 82587a893d3f6014e7a656b5104bc5d26c492721..1c1da19a31a2309c3cf1127e4a9f0de54baf1930 100644 --- a/src/models/overviewImageView.ts +++ b/src/models/overviewImageView.ts @@ -1,26 +1,10 @@ import { z } from 'zod'; +import { overviewImageLink } from './overviewImageLink'; export const overviewImageView = z.object({ idObject: z.number(), filename: z.string(), width: z.number(), height: z.number(), - links: z.array( - z.union([ - z.object({ - idObject: z.number(), - polygon: z.array(z.object({ x: z.number(), y: z.number() })), - imageLinkId: z.number(), - type: z.string(), - }), - z.object({ - idObject: z.number(), - polygon: z.array(z.object({ x: z.number(), y: z.number() })), - zoomLevel: z.number(), - modelPoint: z.object({ x: z.number(), y: z.number() }), - modelLinkId: z.number(), - type: z.string(), - }), - ]), - ), + links: z.array(overviewImageLink), }); diff --git a/src/models/positionSchema.ts b/src/models/positionSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf91481d25b701d09dd996071db84c2df328fe27 --- /dev/null +++ b/src/models/positionSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const positionSchema = z.object({ + x: z.number(), + y: z.number(), +}); diff --git a/src/models/products.ts b/src/models/products.ts new file mode 100644 index 0000000000000000000000000000000000000000..4807c4862dbe9be1abe5be98890bcbc5066b3509 --- /dev/null +++ b/src/models/products.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const productsSchema = z.object({ + aliasId: z.number(), + stoichiometry: z.number().nullable(), + type: z.optional(z.string()), +}); diff --git a/src/models/referenceSchema.ts b/src/models/referenceSchema.ts index 30c2bd65da5d6107397d3e72280865ce2305b258..30a31e287cfdc79348e9655b635a417a0729a34b 100644 --- a/src/models/referenceSchema.ts +++ b/src/models/referenceSchema.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { articleSchema } from './articleSchema'; export const referenceSchema = z.object({ - link: z.string(), + link: z.string().nullable(), article: articleSchema.optional(), type: z.string(), resource: z.string(), diff --git a/src/models/segmentSchema.ts b/src/models/segmentSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..34c8155dd1c26bfe33f33bb452707d51ecbacdb6 --- /dev/null +++ b/src/models/segmentSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const segmentSchema = z.object({ + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), +}); diff --git a/src/models/structuralStateSchema.ts b/src/models/structuralStateSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ef0f149b4d4d0b3fa4e4158296ad6d8f789d949 --- /dev/null +++ b/src/models/structuralStateSchema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; +import { positionSchema } from './positionSchema'; +import { colorSchema } from './colorSchema'; + +export const structuralStateSchema = z.object({ + value: z.string(), + position: positionSchema, + z: z.number(), + width: z.number(), + height: z.number(), + fontSize: z.number(), + size: z.number(), + center: positionSchema, + borderColor: colorSchema, + elementId: z.string(), +}); diff --git a/src/models/submodelSchema.ts b/src/models/submodelSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..634522b96339a8e3ca4e6e7a2805ebe7505770b0 --- /dev/null +++ b/src/models/submodelSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod'; + +export const submodelSchema = z.object({ + mapId: z.number(), + type: z.string(), +}); diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts index e1337f97c6f7f73f141d5739313c1868ab357f30..d23bffc005c9cb4587b70e60f574aee2720198af 100644 --- a/src/redux/apiPath.test.ts +++ b/src/redux/apiPath.test.ts @@ -16,7 +16,7 @@ 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`, + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000`, ); }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 28512d6af51f765811f99f18f60e7697f10ebc76..776993edc272561e3cdead6f3d8b6fc2f3e58706 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -2,18 +2,19 @@ import { PROJECT_ID } from '@/constants'; export const apiPath = { getBioEntityContentsStringWithQuery: (searchQuery: string): string => - `projects/${PROJECT_ID}/models/*/bioEntities:search?query=${searchQuery}`, + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000`, getDrugsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, getMirnasStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/miRnas:search?query=${searchQuery}`, + getModelsString: (): string => `projects/${PROJECT_ID}/models/`, getChemicalsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`, - getAllModelsByProjectIdQuery: (projectId: string): string => `projects/${projectId}/models/*/`, getAllOverlaysByProjectIdQuery: ( projectId: string, { publicOverlay }: { publicOverlay: boolean }, ): string => `projects/${projectId}/overlays/?publicOverlay=${String(publicOverlay)}`, getAllBackgroundsByProjectIdQuery: (projectId: string): string => `projects/${projectId}/backgrounds/`, + getProjectById: (projectId: string): string => `projects/${projectId}`, }; diff --git a/src/redux/backgrounds/backgrounds.reducers.test.ts b/src/redux/backgrounds/backgrounds.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b99161ae9290ee475248566ffe020cc56dc0561 --- /dev/null +++ b/src/redux/backgrounds/backgrounds.reducers.test.ts @@ -0,0 +1,80 @@ +import { PROJECT_ID } from '@/constants'; +import { backgroundsFixture } from '@/models/fixtures/backgroundsFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '../apiPath'; +import backgroundsReducer from './backgrounds.slice'; +import { getAllBackgroundsByProjectId } from './backgrounds.thunks'; +import { BackgroundsState } from './backgrounds.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: BackgroundsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('backgrounds reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<BackgroundsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('backgrounds', backgroundsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(backgroundsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getAllBackgroundsByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) + .reply(HttpStatusCode.Ok, backgroundsFixture); + + const { type } = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); + const { data, loading, error } = store.getState().backgrounds; + + expect(type).toBe('backgrounds/getAllBackgroundsByProjectId/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(backgroundsFixture); + }); + + it('should update store after failed getAllBackgroundsByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) + .reply(HttpStatusCode.NotFound, []); + + const { type } = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); + const { data, loading, error } = store.getState().backgrounds; + + expect(type).toBe('backgrounds/getAllBackgroundsByProjectId/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getAllBackgroundsByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) + .reply(HttpStatusCode.Ok, backgroundsFixture); + + const actionPromise = store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); + + const { data, loading } = store.getState().backgrounds; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + actionPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = + store.getState().backgrounds; + + expect(dataPromiseFulfilled).toEqual(backgroundsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/backgrounds/backgrounds.reducers.ts b/src/redux/backgrounds/backgrounds.reducers.ts index 6c099f17070cd19fff023efdbfaa8e57ca521c0e..2f0868b4a10d853b4c4d74e5ec913e42b05df9b9 100644 --- a/src/redux/backgrounds/backgrounds.reducers.ts +++ b/src/redux/backgrounds/backgrounds.reducers.ts @@ -5,8 +5,15 @@ import { BackgroundsState } from './backgrounds.types'; export const getAllBackgroundsByProjectIdReducer = ( builder: ActionReducerMapBuilder<BackgroundsState>, ): void => { + builder.addCase(getAllBackgroundsByProjectId.pending, state => { + state.loading = 'pending'; + }); builder.addCase(getAllBackgroundsByProjectId.fulfilled, (state, action) => { state.data = action.payload || []; state.loading = 'succeeded'; }); + builder.addCase(getAllBackgroundsByProjectId.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); }; diff --git a/src/redux/backgrounds/backgrounds.thunks.ts b/src/redux/backgrounds/backgrounds.thunks.ts index ee0b860f5584dc5865b34fc549f8d7a6fb885024..3741a1c8ef88c80078adf1462bdeb39807f92e65 100644 --- a/src/redux/backgrounds/backgrounds.thunks.ts +++ b/src/redux/backgrounds/backgrounds.thunks.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { apiPath } from '../apiPath'; export const getAllBackgroundsByProjectId = createAsyncThunk( - 'models/getAllBackgroundsByProjectId', + 'backgrounds/getAllBackgroundsByProjectId', async (projectId: string): Promise<MapBackground[]> => { const response = await axiosInstance.get<MapBackground[]>( apiPath.getAllBackgroundsByProjectIdQuery(projectId), diff --git a/src/redux/bioEntityContents/bioEntityContents.reducers.test.ts b/src/redux/bioEntity/bioEntity.reducers.test.ts similarity index 51% rename from src/redux/bioEntityContents/bioEntityContents.reducers.test.ts rename to src/redux/bioEntity/bioEntity.reducers.test.ts index 12831d5c7b0c2bb05d54f2ca863fa9fd655ea1f0..ed3afdbda94f2b9a5ed7641b292337e7a6ce57af 100644 --- a/src/redux/bioEntityContents/bioEntityContents.reducers.test.ts +++ b/src/redux/bioEntity/bioEntity.reducers.test.ts @@ -1,16 +1,16 @@ -import { bioEntityContentsFixture } from '@/models/fixtures/bioEntityContentsFixture'; -import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; import { HttpStatusCode } from 'axios'; import { apiPath } from '@/redux/apiPath'; -import { getBioEntityContents } from './bioEntityContents.thunks'; -import bioEntityContentsReducer from './bioEntityContents.slice'; -import { BioEntityContentsState } from './bioEntityContents.types'; +import { getBioEntity } from './bioEntity.thunks'; +import bioEntityContentsReducer from './bioEntity.slice'; +import { BioEntityContentsState } from './bioEntity.types'; -const mockedAxiosClient = mockNetworkResponse(); +const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'park7'; const INITIAL_STATE: BioEntityContentsState = { @@ -19,10 +19,10 @@ const INITIAL_STATE: BioEntityContentsState = { error: { name: '', message: '' }, }; -describe('bioEntityContents reducer', () => { +describe('bioEntity reducer', () => { let store = {} as ToolkitStoreWithSingleSlice<BioEntityContentsState>; beforeEach(() => { - store = createStoreInstanceUsingSliceReducer('bioEntityContents', bioEntityContentsReducer); + store = createStoreInstanceUsingSliceReducer('bioEntity', bioEntityContentsReducer); }); it('should match initial state', () => { @@ -30,27 +30,27 @@ describe('bioEntityContents reducer', () => { expect(bioEntityContentsReducer(undefined, action)).toEqual(INITIAL_STATE); }); - it('should update store after succesfull getBioEntityContents query', async () => { + it('should update store after succesfull getBioEntity query', async () => { mockedAxiosClient .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.Ok, bioEntityContentsFixture); + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const { type } = await store.dispatch(getBioEntityContents(SEARCH_QUERY)); - const { data, loading, error } = store.getState().bioEntityContents; + const { type } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { data, loading, error } = store.getState().bioEntity; expect(type).toBe('project/getBioEntityContents/fulfilled'); expect(loading).toEqual('succeeded'); expect(error).toEqual({ message: '', name: '' }); - expect(data).toEqual(bioEntityContentsFixture); + expect(data).toEqual(bioEntityResponseFixture.content); }); - it('should update store after failed getBioEntityContents query', async () => { + it('should update store after failed getBioEntity query', async () => { mockedAxiosClient .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.NotFound, bioEntityContentsFixture); + .reply(HttpStatusCode.NotFound, bioEntityResponseFixture); - const { type } = await store.dispatch(getBioEntityContents(SEARCH_QUERY)); - const { data, loading, error } = store.getState().bioEntityContents; + const { type } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + const { data, loading, error } = store.getState().bioEntity; expect(type).toBe('project/getBioEntityContents/rejected'); expect(loading).toEqual('failed'); @@ -58,22 +58,21 @@ describe('bioEntityContents reducer', () => { expect(data).toEqual([]); }); - it('should update store on loading getBioEntityContents query', async () => { + it('should update store on loading getBioEntity query', async () => { mockedAxiosClient .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.Ok, bioEntityContentsFixture); + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const bioEntityContentsPromise = store.dispatch(getBioEntityContents(SEARCH_QUERY)); + const bioEntityContentsPromise = store.dispatch(getBioEntity(SEARCH_QUERY)); - const { data, loading } = store.getState().bioEntityContents; + const { data, loading } = store.getState().bioEntity; expect(data).toEqual([]); expect(loading).toEqual('pending'); bioEntityContentsPromise.then(() => { - const { data: dataPromiseFulfilled, loading: promiseFulfilled } = - store.getState().bioEntityContents; + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().bioEntity; - expect(dataPromiseFulfilled).toEqual(bioEntityContentsFixture); + expect(dataPromiseFulfilled).toEqual(bioEntityResponseFixture.content); expect(promiseFulfilled).toEqual('succeeded'); }); }); diff --git a/src/redux/bioEntityContents/bioEntityContents.reducers.ts b/src/redux/bioEntity/bioEntity.reducers.ts similarity index 54% rename from src/redux/bioEntityContents/bioEntityContents.reducers.ts rename to src/redux/bioEntity/bioEntity.reducers.ts index d8806feb7989dbfb6cf7a0d2876711c774feec4d..48ce02f4ce25e3b734ae97c905b767a03edbb101 100644 --- a/src/redux/bioEntityContents/bioEntityContents.reducers.ts +++ b/src/redux/bioEntity/bioEntity.reducers.ts @@ -1,18 +1,18 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { BioEntityContentsState } from './bioEntityContents.types'; -import { getBioEntityContents } from './bioEntityContents.thunks'; +import { BioEntityContentsState } from './bioEntity.types'; +import { getBioEntity } from './bioEntity.thunks'; export const getBioEntityContentsReducer = ( builder: ActionReducerMapBuilder<BioEntityContentsState>, ): void => { - builder.addCase(getBioEntityContents.pending, state => { + builder.addCase(getBioEntity.pending, state => { state.loading = 'pending'; }); - builder.addCase(getBioEntityContents.fulfilled, (state, action) => { + builder.addCase(getBioEntity.fulfilled, (state, action) => { state.data = action.payload; state.loading = 'succeeded'; }); - builder.addCase(getBioEntityContents.rejected, state => { + builder.addCase(getBioEntity.rejected, state => { state.loading = 'failed'; // TODO: error management to be discussed in the team }); diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..43c3002e30980c9f6c09076cb632b6d54fa74bb3 --- /dev/null +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -0,0 +1,33 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); + +export const loadingBioEntityStatusSelector = createSelector( + bioEntitySelector, + state => state.loading, +); + +export const numberOfBioEntitiesSelector = createSelector(bioEntitySelector, state => + state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, +); + +export const numberOfBioEntitiesPerModelSelector = createSelector(rootSelector, state => { + const { + models, + bioEntity: { data: bioEntities }, + } = state; + + const numberOfBioEntitiesPerModel = (models.data || []).map(model => { + const bioEntitiesInGivenModel = (bioEntities || []).filter( + entity => model.idObject === entity.bioEntity.model, + ); + + return { modelName: model.name, numberOfEntities: bioEntitiesInGivenModel.length }; + }); + + return numberOfBioEntitiesPerModel.filter( + model => model.numberOfEntities !== SIZE_OF_EMPTY_ARRAY, + ); +}); diff --git a/src/redux/bioEntityContents/bioEntityContents.slice.ts b/src/redux/bioEntity/bioEntity.slice.ts similarity index 70% rename from src/redux/bioEntityContents/bioEntityContents.slice.ts rename to src/redux/bioEntity/bioEntity.slice.ts index 97e3b73cbb92836c41263f9cc4744bf9f756799f..1400797ae523cac389458436f9dfd5280aba24b4 100644 --- a/src/redux/bioEntityContents/bioEntityContents.slice.ts +++ b/src/redux/bioEntity/bioEntity.slice.ts @@ -1,6 +1,6 @@ import { createSlice } from '@reduxjs/toolkit'; -import { BioEntityContentsState } from '@/redux/bioEntityContents/bioEntityContents.types'; -import { getBioEntityContentsReducer } from './bioEntityContents.reducers'; +import { BioEntityContentsState } from '@/redux/bioEntity/bioEntity.types'; +import { getBioEntityContentsReducer } from './bioEntity.reducers'; const initialState: BioEntityContentsState = { data: [], diff --git a/src/redux/bioEntityContents/bioEntityContents.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts similarity index 61% rename from src/redux/bioEntityContents/bioEntityContents.thunks.test.ts rename to src/redux/bioEntity/bioEntity.thunks.test.ts index 540e29ce5181233a2ce2667a19fcfcdb3e6e7135..b5b51d289aa49fa406b619e3e1fa1924a5afd9f1 100644 --- a/src/redux/bioEntityContents/bioEntityContents.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -1,16 +1,16 @@ -import { bioEntityContentsFixture } from '@/models/fixtures/bioEntityContentsFixture'; -import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; import { HttpStatusCode } from 'axios'; import { apiPath } from '@/redux/apiPath'; -import { getBioEntityContents } from './bioEntityContents.thunks'; -import contentsReducer from './bioEntityContents.slice'; -import { BioEntityContentsState } from './bioEntityContents.types'; +import { getBioEntity } from './bioEntity.thunks'; +import contentsReducer from './bioEntity.slice'; +import { BioEntityContentsState } from './bioEntity.types'; -const mockedAxiosClient = mockNetworkResponse(); +const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'park7'; describe('bioEntityContents thunks', () => { @@ -22,17 +22,17 @@ describe('bioEntityContents thunks', () => { it('should return data when data response from API is valid', async () => { mockedAxiosClient .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.Ok, bioEntityContentsFixture); + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const { payload } = await store.dispatch(getBioEntityContents(SEARCH_QUERY)); - expect(payload).toEqual(bioEntityContentsFixture); + const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + expect(payload).toEqual(bioEntityResponseFixture.content); }); it('should return undefined when data response from API is not valid ', async () => { mockedAxiosClient .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); - const { payload } = await store.dispatch(getBioEntityContents(SEARCH_QUERY)); + const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); expect(payload).toEqual(undefined); }); }); diff --git a/src/redux/bioEntityContents/bioEntityContents.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts similarity index 50% rename from src/redux/bioEntityContents/bioEntityContents.thunks.ts rename to src/redux/bioEntity/bioEntity.thunks.ts index 6ccda7e246a6dd21568856018632752f1d9390bd..c175948124fa5fd2e193f5e0cca003d9743fad44 100644 --- a/src/redux/bioEntityContents/bioEntityContents.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -1,20 +1,19 @@ -import { z } from 'zod'; import { createAsyncThunk } from '@reduxjs/toolkit'; -import { axiosInstance } from '@/services/api/utils/axiosInstance'; -import { BioEntityContent } from '@/types/models'; -import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { BioEntityContent, BioEntityResponse } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { apiPath } from '@/redux/apiPath'; +import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; -export const getBioEntityContents = createAsyncThunk( +export const getBioEntity = createAsyncThunk( 'project/getBioEntityContents', async (searchQuery: string): Promise<BioEntityContent[] | undefined> => { - const response = await axiosInstance.get<BioEntityContent[]>( + const response = await axiosInstanceNewAPI.get<BioEntityResponse>( apiPath.getBioEntityContentsStringWithQuery(searchQuery), ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(bioEntityContentSchema)); + const isDataValid = validateDataUsingZodSchema(response.data, bioEntityResponseSchema); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data.content : undefined; }, ); diff --git a/src/redux/bioEntityContents/bioEntityContents.types.ts b/src/redux/bioEntity/bioEntity.types.ts similarity index 100% rename from src/redux/bioEntityContents/bioEntityContents.types.ts rename to src/redux/bioEntity/bioEntity.types.ts diff --git a/src/redux/chemicals/chemicals.selectors.ts b/src/redux/chemicals/chemicals.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fef7a3275e97b1a501223003e06e4cfe05f3f23 --- /dev/null +++ b/src/redux/chemicals/chemicals.selectors.ts @@ -0,0 +1,14 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +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, +); + +export const numberOfChemicalsSelector = createSelector(chemicalsSelector, state => + state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, +); diff --git a/src/redux/drawer/drawer.reducers.test.ts b/src/redux/drawer/drawer.reducers.test.ts index 7701a180c2bc0c148b050e96c0ffc2bc523f1bcd..ff8f02c239dbf2cc5a72c41ae3bd1001f856af12 100644 --- a/src/redux/drawer/drawer.reducers.test.ts +++ b/src/redux/drawer/drawer.reducers.test.ts @@ -1,12 +1,32 @@ -import { AnyAction } from '@reduxjs/toolkit'; import * as toolkitRaw from '@reduxjs/toolkit'; +import { AnyAction } from '@reduxjs/toolkit'; import type { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; -import drawerReducer, { openDrawer, closeDrawer } from './drawer.slice'; +import { drugFixture } from '@/models/fixtures/drugFixtures'; +import drawerReducer, { + closeDrawer, + displayChemicalsList, + displayDrugsList, + displayEntityDetails, + displayGroupedSearchResults, + openDrawer, + openSearchDrawer, +} from './drawer.slice'; import type { DrawerState } from './drawer.types'; const INITIAL_STATE: DrawerState = { - open: false, + isOpen: false, drawerName: 'none', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + }, +}; + +const STEP = { + FIRST: 1, + SECOND: 2, + THIRD: 3, }; type SliceReducerType = ToolkitStore< @@ -36,25 +56,90 @@ describe('drawer reducer', () => { expect(drawerReducer(undefined, action)).toEqual(INITIAL_STATE); }); - it('should update the store when you click a project info button on the nav bar', async () => { + it('should update the store after openDrawer action', async () => { const { type } = await store.dispatch(openDrawer('project-info')); - const { open, drawerName } = store.getState().drawer; + const { isOpen, drawerName } = store.getState().drawer; expect(type).toBe('drawer/openDrawer'); - expect(open).toBe(true); + expect(isOpen).toBe(true); expect(drawerName).toEqual('project-info'); }); - it('should update the store when you click the close button on the drawer', async () => { + it('should update the store after openSearchDrawer action', async () => { + const { type } = await store.dispatch(openSearchDrawer()); + const { + isOpen, + drawerName, + searchDrawerState: { currentStep }, + } = store.getState().drawer; + + expect(type).toBe('drawer/openSearchDrawer'); + expect(isOpen).toBe(true); + expect(drawerName).toEqual('search'); + expect(currentStep).toEqual(STEP.FIRST); + }); + + it('should update the store after closeDrawerReducer action', async () => { const { type } = await store.dispatch(closeDrawer()); - const { open, drawerName } = store.getState().drawer; + const { + isOpen, + drawerName, + searchDrawerState: { currentStep, selectedValue, stepType }, + } = store.getState().drawer; + expect(isOpen).toBe(false); + expect(drawerName).toBe('none'); + expect(currentStep).toBe(STEP.FIRST); + expect(selectedValue).toEqual(undefined); + expect(stepType).toEqual('none'); expect(type).toBe('drawer/closeDrawer'); - expect(open).toBe(false); - expect(drawerName).toEqual('none'); }); - it.skip('should update the store when you type in the search', async () => { - // TODO + it('should update the store after displayDrugsList action', async () => { + const { type } = await store.dispatch(displayDrugsList()); + const { + drawerName, + searchDrawerState: { currentStep, stepType }, + } = store.getState().drawer; + + expect(type).toBe('drawer/displayDrugsList'); + expect(drawerName).toBe('search'); + expect(currentStep).toBe(STEP.SECOND); + expect(stepType).toEqual('drugs'); + }); + + it('should update the store after displayChemicalsList action', async () => { + const { type } = await store.dispatch(displayChemicalsList()); + const { + drawerName, + searchDrawerState: { currentStep, stepType }, + } = store.getState().drawer; + + expect(type).toBe('drawer/displayChemicalsList'); + expect(drawerName).toBe('search'); + expect(currentStep).toBe(STEP.SECOND); + expect(stepType).toEqual('chemicals'); + }); + + it('should update the store after displayGroupedSearchResults action', async () => { + const { type } = await store.dispatch(displayGroupedSearchResults()); + const { + searchDrawerState: { currentStep, stepType }, + } = store.getState().drawer; + + expect(type).toBe('drawer/displayGroupedSearchResults'); + expect(currentStep).toBe(STEP.FIRST); + expect(stepType).toEqual('none'); + }); + + it('should update the store after displayEntityDetails action', async () => { + const { type } = await store.dispatch(displayEntityDetails(drugFixture)); + const { + searchDrawerState: { currentStep, selectedValue }, + } = store.getState().drawer; + + expect(type).toBe('drawer/displayEntityDetails'); + expect(currentStep).toBe(STEP.THIRD); + expect(selectedValue).toEqual(drugFixture); }); }); diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 47b8ef6dd7ff78df197ba96bd753bb80d99b186b..e68271b783561b56761808589cd2785eb126548f 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -1,12 +1,54 @@ -import { PayloadAction } from '@reduxjs/toolkit'; -import { DrawerState } from '@/redux/drawer/drawer.types'; -import { PathName } from '@/types/pathName'; +import { STEP } from '@/constants/searchDrawer'; +import type { DrawerState } from '@/redux/drawer/drawer.types'; +import type { DrawerName } from '@/types/drawerName'; +import type { PayloadAction } from '@reduxjs/toolkit'; -export const openDrawerReducer = (state: DrawerState, action: PayloadAction<PathName>): void => { - state.open = true; +export const openDrawerReducer = (state: DrawerState, action: PayloadAction<DrawerName>): void => { + state.isOpen = true; state.drawerName = action.payload; }; +export const openSearchDrawerReducer = (state: DrawerState): void => { + state.isOpen = true; + state.drawerName = 'search'; + state.searchDrawerState.currentStep = STEP.FIRST; +}; + export const closeDrawerReducer = (state: DrawerState): void => { - state.open = false; + state.isOpen = false; + state.drawerName = 'none'; + state.searchDrawerState.currentStep = STEP.FIRST; + state.searchDrawerState.selectedValue = undefined; + state.searchDrawerState.stepType = 'none'; +}; + +export const displayDrugsListReducer = (state: DrawerState): void => { + state.drawerName = 'search'; + state.searchDrawerState.currentStep = STEP.SECOND; + state.searchDrawerState.stepType = 'drugs'; +}; + +export const displayChemicalsListReducer = (state: DrawerState): void => { + state.drawerName = 'search'; + state.searchDrawerState.currentStep = STEP.SECOND; + state.searchDrawerState.stepType = 'chemicals'; +}; + +export const displayMirnaListReducer = (state: DrawerState): void => { + state.drawerName = 'search'; + state.searchDrawerState.currentStep = STEP.SECOND; + state.searchDrawerState.stepType = 'mirna'; +}; + +export const displayGroupedSearchResultsReducer = (state: DrawerState): void => { + state.searchDrawerState.currentStep = STEP.FIRST; + state.searchDrawerState.stepType = 'none'; +}; + +export const displayEntityDetailsReducer = ( + state: DrawerState, + action: PayloadAction<DrawerState['searchDrawerState']['selectedValue']>, +): void => { + state.searchDrawerState.currentStep = STEP.THIRD; + state.searchDrawerState.selectedValue = action.payload; }; diff --git a/src/redux/drawer/drawer.selectors.ts b/src/redux/drawer/drawer.selectors.ts index a49b98fffb4585ab3afef535d62b364377f20a6b..6c8a5e3adf0a2669edb07e363c882e135e964aca 100644 --- a/src/redux/drawer/drawer.selectors.ts +++ b/src/redux/drawer/drawer.selectors.ts @@ -1,4 +1,58 @@ -import { createSelector } from '@reduxjs/toolkit'; import { rootSelector } from '@/redux/root/root.selectors'; +import { assertNever } from '@/utils/assertNever'; +import { createSelector } from '@reduxjs/toolkit'; + +export const drawerSelector = createSelector(rootSelector, state => state.drawer); + +export const isDrawerOpenSelector = createSelector(drawerSelector, state => state.isOpen); + +export const searchDrawerStateSelector = createSelector( + drawerSelector, + state => state.searchDrawerState, +); + +export const currentStepDrawerStateSelector = createSelector( + searchDrawerStateSelector, + state => state.currentStep, +); + +export const selectedValueDrawerSelector = createSelector( + searchDrawerStateSelector, + state => state.selectedValue, +); + +export const stepTypeDrawerSelector = createSelector( + searchDrawerStateSelector, + state => state.stepType, +); + +export const resultListSelector = createSelector(rootSelector, state => { + const selectedType = state.drawer.searchDrawerState.stepType; -export const drawerDataSelector = createSelector(rootSelector, state => state.drawer); + switch (selectedType) { + case 'drugs': + return (state.drugs.data || []).map(drug => ({ + id: drug.id, + name: drug.name, + data: drug, + })); + case 'chemicals': + return (state.chemicals.data || []).map(chemical => ({ + id: chemical.id.id, + name: chemical.name, + data: chemical, + })); + case 'bioEntity': + return undefined; + case 'mirna': + return (state.mirnas.data || []).map(mirna => ({ + id: mirna.id, + name: mirna.name, + data: mirna, + })); + case 'none': + return undefined; + default: + return assertNever(selectedType); + } +}); diff --git a/src/redux/drawer/drawer.slice.ts b/src/redux/drawer/drawer.slice.ts index 4c71fda1792e323ce63c52292f06bf13cb1f044f..3deb088ddb094612b0e6086781c88b26026d4f31 100644 --- a/src/redux/drawer/drawer.slice.ts +++ b/src/redux/drawer/drawer.slice.ts @@ -1,10 +1,24 @@ -import { createSlice } from '@reduxjs/toolkit'; import { DrawerState } from '@/redux/drawer/drawer.types'; -import { openDrawerReducer, closeDrawerReducer } from './drawer.reducers'; +import { createSlice } from '@reduxjs/toolkit'; +import { + closeDrawerReducer, + displayChemicalsListReducer, + displayDrugsListReducer, + displayEntityDetailsReducer, + displayGroupedSearchResultsReducer, + displayMirnaListReducer, + openDrawerReducer, + openSearchDrawerReducer, +} from './drawer.reducers'; const initialState: DrawerState = { - open: false, + isOpen: false, drawerName: 'none', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + }, }; const drawerSlice = createSlice({ @@ -12,10 +26,25 @@ const drawerSlice = createSlice({ initialState, reducers: { openDrawer: openDrawerReducer, + openSearchDrawer: openSearchDrawerReducer, closeDrawer: closeDrawerReducer, + displayDrugsList: displayDrugsListReducer, + displayChemicalsList: displayChemicalsListReducer, + displayMirnaList: displayMirnaListReducer, + displayGroupedSearchResults: displayGroupedSearchResultsReducer, + displayEntityDetails: displayEntityDetailsReducer, }, }); -export const { openDrawer, closeDrawer } = drawerSlice.actions; +export const { + openDrawer, + openSearchDrawer, + closeDrawer, + displayDrugsList, + displayChemicalsList, + displayMirnaList, + displayGroupedSearchResults, + displayEntityDetails, +} = drawerSlice.actions; export default drawerSlice.reducer; diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index a0ad70b8b7e1cf4f7555eaf15ffc265dd9b19206..e39ff163a7d9ac9eb764d1228e4cc58e50e4e422 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -1,4 +1,14 @@ +import type { DrawerName } from '@/types/drawerName'; +import { BioEntityContent, Chemical, Drug, Mirna } from '@/types/models'; + +export type SearchDrawerState = { + currentStep: number; + stepType: 'bioEntity' | 'drugs' | 'mirna' | 'chemicals' | 'none'; + selectedValue: BioEntityContent | Drug | Mirna | Chemical | undefined; +}; + export type DrawerState = { - open: boolean; - drawerName: 'none' | 'search' | 'project-info' | 'plugins' | 'export' | 'legend'; + isOpen: boolean; + drawerName: DrawerName; + searchDrawerState: SearchDrawerState; }; diff --git a/src/redux/drawer/drawerFixture.ts b/src/redux/drawer/drawerFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..46c1896ac9808ffe7a802cc9db9c746ce95c1529 --- /dev/null +++ b/src/redux/drawer/drawerFixture.ts @@ -0,0 +1,31 @@ +import { DrawerState } from './drawer.types'; + +export const initialStateFixture: DrawerState = { + isOpen: false, + drawerName: 'none', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + }, +}; + +export const drawerSearchStepOneFixture: DrawerState = { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 1, + stepType: 'none', + selectedValue: undefined, + }, +}; + +export const drawerSearchDrugsStepTwoFixture: DrawerState = { + isOpen: true, + drawerName: 'search', + searchDrawerState: { + currentStep: 2, + stepType: 'drugs', + selectedValue: undefined, + }, +}; diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a1d3eacacbf0fee4cd7093f4c0255f4c910af51 --- /dev/null +++ b/src/redux/drugs/drugs.selectors.ts @@ -0,0 +1,10 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +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); +export const numberOfDrugsSelector = createSelector(drugsSelector, state => + state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, +); diff --git a/src/redux/mirnas/mirnas.selectors.ts b/src/redux/mirnas/mirnas.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..51ac4dea078d807985a399bede164e6782e7ae9b --- /dev/null +++ b/src/redux/mirnas/mirnas.selectors.ts @@ -0,0 +1,10 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +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); +export const numberOfMirnasSelector = createSelector(mirnasSelector, state => + state.data ? state.data.length : SIZE_OF_EMPTY_ARRAY, +); diff --git a/src/redux/models/models.reducers.test.ts b/src/redux/models/models.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1677afdfd86b83f6a1ea7834cbc15ceb0d71018e --- /dev/null +++ b/src/redux/models/models.reducers.test.ts @@ -0,0 +1,72 @@ +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import modelsReducer from './models.slice'; +import { getModels } from './models.thunks'; +import { ModelsState } from './models.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: ModelsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('models reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('models', modelsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(modelsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const { type } = await store.dispatch(getModels()); + const { data, loading, error } = store.getState().models; + + expect(type).toBe('project/getModels/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(modelsFixture); + }); + + it('should update store after failed getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.NotFound, []); + + const { type } = await store.dispatch(getModels()); + const { data, loading, error } = store.getState().models; + + expect(type).toBe('project/getModels/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const modelsPromise = store.dispatch(getModels()); + + const { data, loading } = store.getState().models; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + modelsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().models; + + expect(dataPromiseFulfilled).toEqual(modelsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/models/models.reducers.ts b/src/redux/models/models.reducers.ts index 7c8f18498df26e565b70f326626ad24c75809d36..4b9f7fc67c95ba1f80df7f907cbc4bb8fc1a3d28 100644 --- a/src/redux/models/models.reducers.ts +++ b/src/redux/models/models.reducers.ts @@ -1,12 +1,17 @@ -import { getAllModelsByProjectId } from '@/redux/models/models.thunks'; import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getModels } from './models.thunks'; import { ModelsState } from './models.types'; -export const getAllModelsByProjectIdReducer = ( - builder: ActionReducerMapBuilder<ModelsState>, -): void => { - builder.addCase(getAllModelsByProjectId.fulfilled, (state, action) => { +export const getModelsReducer = (builder: ActionReducerMapBuilder<ModelsState>): void => { + builder.addCase(getModels.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getModels.fulfilled, (state, action) => { state.data = action.payload || []; state.loading = 'succeeded'; }); + builder.addCase(getModels.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); }; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index ec3bbe2488d921e89eeaaf69df39df69f271e9b3..1ae57c7e2e962dc33485300be74d02a61e888d91 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -7,7 +7,7 @@ export const modelsSelector = createSelector(rootSelector, state => state.models export const modelsDataSelector = createSelector(modelsSelector, models => models.data || []); export const currentModelSelector = createSelector( - modelsSelector, + modelsDataSelector, mapDataSelector, (models, mapData) => models.find(model => model.idObject === mapData.modelId), ); diff --git a/src/redux/models/models.slice.ts b/src/redux/models/models.slice.ts index f47b5aa8da79bc969d3ce1e098005e9537c686f2..5c969f3e0e1d6eb2b0e5183d3a5bc7b454248f60 100644 --- a/src/redux/models/models.slice.ts +++ b/src/redux/models/models.slice.ts @@ -1,6 +1,6 @@ +import { ModelsState } from '@/redux/models/models.types'; import { createSlice } from '@reduxjs/toolkit'; -import { getAllModelsByProjectIdReducer } from './models.reducers'; -import { ModelsState } from './models.types'; +import { getModelsReducer } from './models.reducers'; const initialState: ModelsState = { data: [], @@ -8,12 +8,12 @@ const initialState: ModelsState = { error: { name: '', message: '' }, }; -const modelsSlice = createSlice({ +export const modelsSlice = createSlice({ name: 'models', initialState, reducers: {}, extraReducers: builder => { - getAllModelsByProjectIdReducer(builder); + getModelsReducer(builder); }, }); diff --git a/src/redux/models/models.thunks.test.ts b/src/redux/models/models.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d85a15e274e0d0ee6352353d25e504de8097259 --- /dev/null +++ b/src/redux/models/models.thunks.test.ts @@ -0,0 +1,36 @@ +import { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ModelsState } from '@/redux/models/models.types'; +import { apiPath } from '@/redux/apiPath'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import modelsReducer from './models.slice'; +import { getModels } from './models.thunks'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('models thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('models', modelsReducer); + }); + describe('getModels', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const { payload } = await store.dispatch(getModels()); + expect(payload).toEqual(modelsFixture); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getModelsString()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getModels()); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/models/models.thunks.ts b/src/redux/models/models.thunks.ts index c7cb548e8e353865becc3ed343095836d3fa9ddc..5880ddcd4cd8f2494790f321e05fac0ab0c25021 100644 --- a/src/redux/models/models.thunks.ts +++ b/src/redux/models/models.thunks.ts @@ -1,20 +1,18 @@ -import { mapModel } from '@/models/mapModel'; +import { mapModelSchema } from '@/models/modelSchema'; +import { apiPath } from '@/redux/apiPath'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { MapModel } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; -import { apiPath } from '../apiPath'; -export const getAllModelsByProjectId = createAsyncThunk( - 'models/getAllModelsByProjectId', - async (projectId: string): Promise<MapModel[]> => { - const response = await axiosInstance.get<MapModel[]>( - apiPath.getAllModelsByProjectIdQuery(projectId), - ); +export const getModels = createAsyncThunk( + 'project/getModels', + async (): Promise<MapModel[] | undefined> => { + const response = await axiosInstance.get<MapModel[]>(apiPath.getModelsString()); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapModel)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapModelSchema)); - return isDataValid ? response.data : []; + return isDataValid ? response.data : undefined; }, ); diff --git a/src/redux/models/models.types.ts b/src/redux/models/models.types.ts index 06bd18926c75f5fc88c8f55cde1e92cd5c102609..6d27b9dc198ef7ac5a36b5cb355d141ecf3604a2 100644 --- a/src/redux/models/models.types.ts +++ b/src/redux/models/models.types.ts @@ -1,4 +1,4 @@ import { FetchDataState } from '@/types/fetchDataState'; import { MapModel } from '@/types/models'; -export type ModelsState = FetchDataState<MapModel[] | []>; +export type ModelsState = FetchDataState<MapModel[], []>; diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d0116134dfce094433f2fd9a9948ee98541cd1eb --- /dev/null +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -0,0 +1,79 @@ +import { PROJECT_ID } from '@/constants'; +import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '../apiPath'; +import overlaysReducer from './overlays.slice'; +import { getAllPublicOverlaysByProjectId } from './overlays.thunks'; +import { OverlaysState } from './overlays.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: OverlaysState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('overlays reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<OverlaysState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('overlays', overlaysReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(overlaysReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getAllPublicOverlaysByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const { type } = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); + const { data, loading, error } = store.getState().overlays; + + expect(type).toBe('overlays/getAllPublicOverlaysByProjectId/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(overlaysFixture); + }); + + it('should update store after failed getAllPublicOverlaysByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) + .reply(HttpStatusCode.NotFound, []); + + const { type } = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); + const { data, loading, error } = store.getState().overlays; + + expect(type).toBe('overlays/getAllPublicOverlaysByProjectId/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getAllPublicOverlaysByProjectId query', async () => { + mockedAxiosClient + .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const actionPromise = store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); + + const { data, loading } = store.getState().overlays; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + actionPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().overlays; + + expect(dataPromiseFulfilled).toEqual(overlaysFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/overlays/overlays.reducers.ts b/src/redux/overlays/overlays.reducers.ts index 09863b89a1a6f81fa67e66216a2a5afb21c69449..99e493ea4f4f7b6d9288b0c28cfb9234057e9bc9 100644 --- a/src/redux/overlays/overlays.reducers.ts +++ b/src/redux/overlays/overlays.reducers.ts @@ -5,8 +5,15 @@ import { OverlaysState } from './overlays.types'; export const getAllPublicOverlaysByProjectIdReducer = ( builder: ActionReducerMapBuilder<OverlaysState>, ): void => { + builder.addCase(getAllPublicOverlaysByProjectId.pending, state => { + state.loading = 'pending'; + }); builder.addCase(getAllPublicOverlaysByProjectId.fulfilled, (state, action) => { state.data = action.payload || []; state.loading = 'succeeded'; }); + builder.addCase(getAllPublicOverlaysByProjectId.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); }; diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 3dc3c70e8be55ef5502dece731bb090a7758d07d..6a01933378559ff68685808752a45c4cc11ce9aa 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { apiPath } from '../apiPath'; export const getAllPublicOverlaysByProjectId = createAsyncThunk( - 'models/getAllPublicOverlaysByProjectId', + 'overlays/getAllPublicOverlaysByProjectId', async (projectId: string): Promise<MapOverlay[]> => { const response = await axiosInstance.get<MapOverlay[]>( apiPath.getAllOverlaysByProjectIdQuery(projectId, { publicOverlay: true }), diff --git a/src/redux/project/project.reducers.test.ts b/src/redux/project/project.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..28b9ef70063d829473534c60f51abe85143da8f9 --- /dev/null +++ b/src/redux/project/project.reducers.test.ts @@ -0,0 +1,77 @@ +import { PROJECT_ID } from '@/constants'; +import { projectFixture } from '@/models/fixtures/projectFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '../apiPath'; +import projectReducer from './project.slice'; +import { getProjectById } from './project.thunks'; +import { ProjectState } from './project.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: ProjectState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('project reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ProjectState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('project', projectReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(projectReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getProjectById query', async () => { + mockedAxiosClient + .onGet(apiPath.getProjectById(PROJECT_ID)) + .reply(HttpStatusCode.Ok, projectFixture); + + const { type } = await store.dispatch(getProjectById(PROJECT_ID)); + const { data, loading, error } = store.getState().project; + + expect(type).toBe('project/getProjectById/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(projectFixture); + }); + + it('should update store after failed getProjectById query', async () => { + mockedAxiosClient.onGet(apiPath.getProjectById(PROJECT_ID)).reply(HttpStatusCode.NotFound, []); + + const { type } = await store.dispatch(getProjectById(PROJECT_ID)); + const { data, loading, error } = store.getState().project; + + expect(type).toBe('project/getProjectById/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(undefined); + }); + + it('should update store on loading getProjectById query', async () => { + mockedAxiosClient + .onGet(apiPath.getProjectById(PROJECT_ID)) + .reply(HttpStatusCode.Ok, projectFixture); + + const actionPromise = store.dispatch(getProjectById(PROJECT_ID)); + + const { data, loading } = store.getState().project; + expect(data).toEqual(undefined); + expect(loading).toEqual('pending'); + + actionPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().project; + + expect(dataPromiseFulfilled).toEqual(projectFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/project/project.reducers.ts b/src/redux/project/project.reducers.ts index aee885ae235dace1b0345e3ee4f6a7d42ad8b3e8..435d55e8abbb5a895a21a0a4bba01e793333e36c 100644 --- a/src/redux/project/project.reducers.ts +++ b/src/redux/project/project.reducers.ts @@ -1,10 +1,17 @@ -import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { ProjectState } from '@/redux/project/project.types'; import { getProjectById } from '@/redux/project/project.thunks'; +import { ProjectState } from '@/redux/project/project.types'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; export const getProjectByIdReducer = (builder: ActionReducerMapBuilder<ProjectState>): void => { + builder.addCase(getProjectById.pending, state => { + state.loading = 'pending'; + }); builder.addCase(getProjectById.fulfilled, (state, action) => { - state.data = action.payload; + state.data = action.payload || undefined; state.loading = 'succeeded'; }); + builder.addCase(getProjectById.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); }; diff --git a/src/redux/project/project.thunks.ts b/src/redux/project/project.thunks.ts index d99f5d55ca335e5549354ac7d5e649489e1683f5..f3d9fbe26e2fc0d226a86be7b8762b39a938b777 100644 --- a/src/redux/project/project.thunks.ts +++ b/src/redux/project/project.thunks.ts @@ -3,11 +3,12 @@ import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { Project } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { apiPath } from '../apiPath'; export const getProjectById = createAsyncThunk( 'project/getProjectById', async (id: string): Promise<Project | undefined> => { - const response = await axiosInstance.get<Project>(`projects/${id}`); + const response = await axiosInstance.get<Project>(apiPath.getProjectById(id)); const isDataValid = validateDataUsingZodSchema(response.data, projectSchema); diff --git a/src/redux/search/search.reducers.test.ts b/src/redux/search/search.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6c6784a6b6d64d022d3e351ebcca5a76f9d7975 --- /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.skip('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 4f6f747d357f19c9a48c43041c484c4052c21600..21e30af3a9bc50deca0e7800746589bb792ef7fd 100644 --- a/src/redux/search/search.reducers.ts +++ b/src/redux/search/search.reducers.ts @@ -1,7 +1,19 @@ // 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, action) => { + state.searchValue = action.meta.arg; + 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..360bfa689ed19e83f6474176a4d02fe7c4512384 100644 --- a/src/redux/search/search.slice.ts +++ b/src/redux/search/search.slice.ts @@ -1,23 +1,19 @@ -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: '', - searchResult: { - 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..88dfc4f4dcc04fea7c30fd46241d27e72b8d478e --- /dev/null +++ b/src/redux/search/search.thunks.ts @@ -0,0 +1,17 @@ +import { getBioEntity } from '@/redux/bioEntity/bioEntity.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(getBioEntity(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..45380f011abc19d556077abbbc62021f6c1fa016 100644 --- a/src/redux/search/search.types.ts +++ b/src/redux/search/search.types.ts @@ -1,9 +1,6 @@ -export interface SearchResult { - content: string; - drugs: string; -} +import { Loading } from '@/types/loadingState'; export interface SearchState { searchValue: string; - searchResult: SearchResult; + loading: Loading; } diff --git a/src/redux/store.ts b/src/redux/store.ts index 556abf07c075702ce0656f8fe3aaabe80ff42fa6..0c59b4e1559b94a670f200f78ea4bee197498aa8 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,27 +1,36 @@ +import backgroundsReducer from '@/redux/backgrounds/backgrounds.slice'; +import bioEntityReducer from '@/redux/bioEntity/bioEntity.slice'; +import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; import drugsReducer from '@/redux/drugs/drugs.slice'; import mapReducer from '@/redux/map/map.slice'; +import mirnasReducer from '@/redux/mirnas/mirnas.slice'; +import modelsReducer from '@/redux/models/models.slice'; +import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; import searchReducer from '@/redux/search/search.slice'; import { configureStore } from '@reduxjs/toolkit'; -import backgroundsReducer from './backgrounds/backgrounds.slice'; -import modelsReducer from './models/models.slice'; -import overlaysReducer from './overlays/overlays.slice'; + +export const reducers = { + search: searchReducer, + project: projectReducer, + drugs: drugsReducer, + mirnas: mirnasReducer, + chemicals: chemicalsReducer, + bioEntity: bioEntityReducer, + drawer: drawerReducer, + map: mapReducer, + backgrounds: backgroundsReducer, + overlays: overlaysReducer, + models: modelsReducer, +}; export const store = configureStore({ - reducer: { - search: searchReducer, - project: projectReducer, - drugs: drugsReducer, - drawer: drawerReducer, - map: mapReducer, - backgrounds: backgroundsReducer, - overlays: overlaysReducer, - models: modelsReducer, - }, + reducer: reducers, devTools: true, }); +export type StoreType = typeof store; // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType<typeof store.getState>; // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} diff --git a/src/services/api/utils/axiosInstance.ts b/src/services/api/utils/axiosInstance.ts index 5912be0111ef30130d07af064b423be058b389ec..aaa17d287f1f0d3023f1d178e7ac54adf57ea9a8 100644 --- a/src/services/api/utils/axiosInstance.ts +++ b/src/services/api/utils/axiosInstance.ts @@ -1,6 +1,10 @@ -import { BASE_API_URL } from '@/constants'; +import { BASE_API_URL, BASE_NEW_API_URL } from '@/constants'; import axios from 'axios'; export const axiosInstance = axios.create({ baseURL: BASE_API_URL, }); + +export const axiosInstanceNewAPI = axios.create({ + baseURL: BASE_NEW_API_URL, +}); diff --git a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.component.tsx b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.component.tsx index 32942ae9a996c6f83c3402b9f904a4d27e1351a3..d932d2b580323d5eb5902438deb9ba435ddc247f 100644 --- a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.component.tsx +++ b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.component.tsx @@ -1,14 +1,35 @@ -import { Icon } from '@/shared/Icon'; import { AccordionItemButton as AIB } from 'react-accessible-accordion'; import './AccordionItemButton.style.css'; +import { Variant } from './AccordionItemButton.types'; +import { getIcon } from './AccordionItemButton.utils'; -interface AccordionItemButtonProps { +type AccordionItemButtonProps = { children: React.ReactNode; -} + variant?: Variant; + onClick?: () => void; + disabled?: boolean; +}; -export const AccordionItemButton = ({ children }: AccordionItemButtonProps): JSX.Element => ( - <AIB className="accordion-button flex flex-row flex-nowrap justify-between"> - {children} - <Icon name="chevron-down" className="arrow-button h-6 w-6 fill-font-500" /> - </AIB> -); +export const AccordionItemButton = ({ + children, + variant = 'expandable', + onClick, + disabled, +}: AccordionItemButtonProps): JSX.Element => { + const ButtonIcon = getIcon(variant); + + return ( + <AIB className="accordion-button flex flex-row flex-nowrap justify-between"> + <button + onClick={onClick} + disabled={disabled} + className="flex w-full flex-row flex-nowrap justify-between text-sm" + type="button" + data-testid="accordion-item-button" + > + {children} + {ButtonIcon} + </button> + </AIB> + ); +}; diff --git a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.style.css b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.style.css index ee300f20ba887d33a867a0524f9683a95d85d4cf..9da947bbf0e48c0a5c994b6b9bcd542923d29c5f 100644 --- a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.style.css +++ b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.style.css @@ -1,3 +1,3 @@ -.accordion-button[aria-expanded='true'] > .arrow-button { +.accordion-button[aria-expanded='true'] .arrow-button { @apply rotate-180; } diff --git a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.types.ts b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae2054d23759807ce59d282febcc2c5a091ef045 --- /dev/null +++ b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.types.ts @@ -0,0 +1 @@ +export type Variant = 'expandable' | 'non-expandable'; diff --git a/src/shared/Accordion/AccordionItemButton/AccordionItemButton.utils.tsx b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.utils.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2d597a92b01655e0b73d72516d30717caa3c75c1 --- /dev/null +++ b/src/shared/Accordion/AccordionItemButton/AccordionItemButton.utils.tsx @@ -0,0 +1,11 @@ +import { Icon } from '@/shared/Icon'; +import { Variant } from './AccordionItemButton.types'; + +export const getIcon = (variant: Variant): JSX.Element => { + const variantsIcons: Record<Variant, JSX.Element> = { + expandable: <Icon name="chevron-down" className="arrow-button h-6 w-6 fill-font-500" />, + 'non-expandable': <Icon name="chevron-right" className="h-6 w-6 fill-font-500" />, + }; + + return variantsIcons[variant]; +}; diff --git a/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.test.tsx b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..38cee7f2d5a8ab8f299890452130e9e86522f6fc --- /dev/null +++ b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.test.tsx @@ -0,0 +1,71 @@ +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { drawerSearchStepOneFixture } from '@/redux/drawer/drawerFixture'; +import { DrawerHeadingBackwardButton } from './DrawerHeadingBackwardButton.component'; + +const backwardFunction = jest.fn(); + +const renderComponent = ( + title: string, + value: string, + initialStoreState: InitialStoreState = {}, +): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <DrawerHeadingBackwardButton + title={title} + value={value} + backwardFunction={backwardFunction} + /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('DrawerHeadingBackwardButton - component', () => { + beforeEach(() => { + backwardFunction.mockReset(); + }); + + it('should render passed values', () => { + renderComponent('Title', 'value'); + + expect(screen.getByRole('back-button')).toBeInTheDocument(); + expect(screen.getByText('Title:')).toBeInTheDocument(); + expect(screen.getByText('value')).toBeInTheDocument(); + expect(screen.getByRole('close-drawer-button')).toBeInTheDocument(); + }); + + it('should call backward function on back button click', () => { + renderComponent('Title', 'value'); + + const backButton = screen.getByRole('back-button'); + backButton.click(); + + expect(backwardFunction).toBeCalled(); + }); + + it('should call class drawer on close button click', () => { + const { store } = renderComponent('Title', 'value', { + drawer: { + ...drawerSearchStepOneFixture, + }, + }); + expect(store.getState().drawer.isOpen).toBe(true); + + const closeButton = screen.getByRole('close-drawer-button'); + closeButton.click(); + + expect(store.getState().drawer.isOpen).toBe(false); + }); +}); diff --git a/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.tsx b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4e39e76ac513958f91f90b29693bc88eb1f50d9e --- /dev/null +++ b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.component.tsx @@ -0,0 +1,51 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { closeDrawer } from '@/redux/drawer/drawer.slice'; +import { IconButton } from '@/shared/IconButton'; +import { BACK_BUTTON_ROLE, CLOSE_BUTTON_ROLE } from './DrawerHeadingBackwardButton.constants'; + +export interface DrawerHeadingBackwardButtonProps { + title: string; + value: string; + backwardFunction: () => void; +} + +export const DrawerHeadingBackwardButton = ({ + backwardFunction, + title, + value, +}: DrawerHeadingBackwardButtonProps): JSX.Element => { + const dispatch = useAppDispatch(); + + const handleCloseDrawer = (): void => { + dispatch(closeDrawer()); + }; + + const onBackwardClick = (): void => { + backwardFunction(); + }; + + return ( + <div className="flex items-center justify-between border-b border-b-divide pl-4 pr-5"> + <div className="flex flex-row flex-nowrap items-center"> + <IconButton + className="h-6 w-6 bg-white-pearl" + icon="chevron-left" + classNameIcon="fill-font-500 h-6 w-6" + onClick={onBackwardClick} + role={BACK_BUTTON_ROLE} + /> + <div className="ml-2 py-8 text-xl"> + <span className="font-normal">{title}: </span> + <span className="font-semibold">{value}</span> + </div> + </div> + <IconButton + className="bg-white-pearl" + classNameIcon="fill-font-500" + icon="close" + role={CLOSE_BUTTON_ROLE} + onClick={handleCloseDrawer} + /> + </div> + ); +}; diff --git a/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.constants.tsx b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.constants.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8422870530be28c4877b289d32e73754160af761 --- /dev/null +++ b/src/shared/DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.constants.tsx @@ -0,0 +1,2 @@ +export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; +export const BACK_BUTTON_ROLE = 'back-button'; diff --git a/src/shared/DrawerHeadingBackwardButton/index.ts b/src/shared/DrawerHeadingBackwardButton/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3fb46743bc8c6aa582eb014c30eef2d0eb3ef88 --- /dev/null +++ b/src/shared/DrawerHeadingBackwardButton/index.ts @@ -0,0 +1 @@ +export { DrawerHeadingBackwardButton } from './DrawerHeadingBackwardButton.component'; diff --git a/src/shared/Icon/Icon.component.tsx b/src/shared/Icon/Icon.component.tsx index b23a3ad43cb93631acb252e238ae2531626c3ff3..b683ed15a8d2d574411618f5f9b8486c41b8319f 100644 --- a/src/shared/Icon/Icon.component.tsx +++ b/src/shared/Icon/Icon.component.tsx @@ -12,6 +12,7 @@ import { PageIcon } from '@/shared/Icon/Icons/PageIcon'; import { PluginIcon } from '@/shared/Icon/Icons/PluginIcon'; import { PlusIcon } from '@/shared/Icon/Icons/PlusIcon'; import { CloseIcon } from '@/shared/Icon/Icons/CloseIcon'; +import { Pin } from '@/shared/Icon/Icons/Pin'; import type { IconTypes } from '@/types/iconTypes'; @@ -25,6 +26,7 @@ const icons = { 'chevron-left': ChevronLeftIcon, 'chevron-up': ChevronUpIcon, 'chevron-down': ChevronDownIcon, + pin: Pin, plus: PlusIcon, arrow: ArrowIcon, dots: DotsIcon, diff --git a/src/shared/Icon/Icons/Pin.tsx b/src/shared/Icon/Icons/Pin.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aa365e8415e48cff5c98a9a7f42908616ea4cb78 --- /dev/null +++ b/src/shared/Icon/Icons/Pin.tsx @@ -0,0 +1,17 @@ +interface PinOrangeProps { + className: string; +} + +export const Pin = ({ className }: PinOrangeProps): JSX.Element => ( + <svg + width="18" + height="24" + viewBox="0 0 18 24" + fill="none" + xmlns="http://www.w3.org/2000/svg" + className={className} + > + <path d="M9 0C4.575 0 0 3.375 0 9C0 14.325 8.1 22.65 8.475 23.025C8.625 23.175 8.775 23.25 9 23.25C9.225 23.25 9.375 23.175 9.525 23.025C9.9 22.65 18 14.4 18 9C18 3.375 13.425 0 9 0ZM9 12C7.35 12 6 10.65 6 9C6 7.35 7.35 6 9 6C10.65 6 12 7.35 12 9C12 10.65 10.65 12 9 12Z" /> + <circle cx="9.0002" cy="8.99922" r="4.8" /> + </svg> +); diff --git a/src/types/drawerName.ts b/src/types/drawerName.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ee1478dcee864ab2339be99906cb6f583d84cf1 --- /dev/null +++ b/src/types/drawerName.ts @@ -0,0 +1 @@ +export type DrawerName = 'none' | 'search' | 'project-info' | 'plugins' | 'export' | 'legend'; diff --git a/src/types/iconTypes.ts b/src/types/iconTypes.ts index 9b14f66b477ef940782e3c94b8d1079b8683af89..c37714cb64db3ae771784cd0978c5858bfdf5536 100644 --- a/src/types/iconTypes.ts +++ b/src/types/iconTypes.ts @@ -12,4 +12,5 @@ export type IconTypes = | 'legend' | 'page' | 'plugin' - | 'close'; + | 'close' + | 'pin'; diff --git a/src/types/models.ts b/src/types/models.ts index 6b2907550af03e0d2cb53ae474d28f40c7c08124..29327e2046f0dcf27075d5bebd904f9d7e8b5167 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1,24 +1,28 @@ import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; +import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; +import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; -import { mapModel } from '@/models/mapModel'; +import { mapBackground } from '@/models/mapBackground'; import { mapOverlay } from '@/models/mapOverlay'; import { mirnaSchema } from '@/models/mirnaSchema'; +import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/project'; import { z } from 'zod'; -import { mapBackground } from '../models/mapBackground'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; -export type MapModel = z.infer<typeof mapModel>; +export type MapModel = z.infer<typeof mapModelSchema>; export type MapOverlay = z.infer<typeof mapOverlay>; export type MapBackground = z.infer<typeof mapBackground>; export type Organism = z.infer<typeof organism>; export type Disease = z.infer<typeof disease>; export type Drug = z.infer<typeof drugSchema>; export type Mirna = z.infer<typeof mirnaSchema>; +export type BioEntity = z.infer<typeof bioEntitySchema>; export type BioEntityContent = z.infer<typeof bioEntityContentSchema>; +export type BioEntityResponse = z.infer<typeof bioEntityResponseSchema>; export type Chemical = z.infer<typeof chemicalSchema>; diff --git a/src/types/pathName.ts b/src/types/pathName.ts deleted file mode 100644 index 2b61453e4b4469bf51cc26f440f5b49985c302b6..0000000000000000000000000000000000000000 --- a/src/types/pathName.ts +++ /dev/null @@ -1 +0,0 @@ -export type PathName = 'none' | 'search' | 'project-info' | 'plugins' | 'export' | 'legend'; diff --git a/src/utils/assertNever.ts b/src/utils/assertNever.ts new file mode 100644 index 0000000000000000000000000000000000000000..7638cb3093223b9eb244428c686e99af4db8334d --- /dev/null +++ b/src/utils/assertNever.ts @@ -0,0 +1,3 @@ +export const assertNever = (value: never): never => { + throw new Error(`Unexpected value ${value}`); +}; diff --git a/src/utils/mockNetworkResponse.ts b/src/utils/mockNetworkResponse.ts index 4f7bd1098b390c116d72c591feda2ecf14eab949..67077e0286c5b06b4c8c190f267db9170d8c1be7 100644 --- a/src/utils/mockNetworkResponse.ts +++ b/src/utils/mockNetworkResponse.ts @@ -1,8 +1,13 @@ // eslint-disable-next-line import/no-extraneous-dependencies import MockAdapter from 'axios-mock-adapter'; -import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { axiosInstance, axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; export const mockNetworkResponse = (): MockAdapter => { const mock = new MockAdapter(axiosInstance); return mock; }; + +export const mockNetworkNewAPIResponse = (): MockAdapter => { + const mock = new MockAdapter(axiosInstanceNewAPI); + return mock; +}; 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/src/utils/testing/getReduxWrapperWithStore.tsx b/src/utils/testing/getReduxWrapperWithStore.tsx new file mode 100644 index 0000000000000000000000000000000000000000..604f81c4c2ddec336dc0040bd1b57fa08e7f8f48 --- /dev/null +++ b/src/utils/testing/getReduxWrapperWithStore.tsx @@ -0,0 +1,29 @@ +import { RootState, StoreType, reducers } from '@/redux/store'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +interface WrapperProps { + children: React.ReactNode; +} + +export type InitialStoreState = Partial<RootState>; + +type GetReduxWrapperUsingSliceReducer = (initialState?: InitialStoreState) => { + Wrapper: ({ children }: WrapperProps) => JSX.Element; + store: StoreType; +}; + +export const getReduxWrapperWithStore: GetReduxWrapperUsingSliceReducer = ( + preloadedState: InitialStoreState = {}, +) => { + const testStore = configureStore({ + reducer: reducers, + preloadedState, + }); + + const Wrapper = ({ children }: WrapperProps): JSX.Element => ( + <Provider store={testStore}>{children}</Provider> + ); + + return { Wrapper, store: testStore }; +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index ce605cba16e8602fe0eccd1f17c8c26bdcc685d1..203d95ea697da1c18f58ee7b474cc1eaa31a228c 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -25,6 +25,8 @@ const config: Config = { cultured: '#f7f7f8', 'white-pearl': '#ffffff', divide: '#e1e0e6', + orange: '#f48c40', + purple: '#6400e3', }, height: { 'calc-drawer': 'calc(100% - 104px)', diff --git a/tsconfig.json b/tsconfig.json index 477f8b19ef51284dc1f21d0b456ee88c0d5419e6..40d57f4d84f6233a712a2c17e3928b50f336152a 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", "babel.config.js"