diff --git a/jest.config.ts b/jest.config.ts index 610effde03b86034323304a5ca2801a353093307..9dcd2674d5676c0798a177e39027f7b9b78ce502 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -26,6 +26,7 @@ const config = { coverageReporters: ['html', 'text', 'text-summary', 'cobertura'], setupFilesAfterEnv: ['<rootDir>/setupTests.ts'], prettierPath: require.resolve('prettier-2'), + watchPlugins: ['jest-watch-typeahead/filename', 'jest-watch-typeahead/testname'], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/package-lock.json b/package-lock.json index 998d2ae142081f0f35efaa77210c3d0b01b85250..6dfd0fd89691449cc123b3ed5a1c8d33957f2561 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-junit": "^16.0.0", + "jest-watch-typeahead": "^2.2.2", "lint-staged": "^14.0.1", "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", @@ -8087,6 +8088,130 @@ "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/jest-watch-typeahead": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/jest-watch-typeahead/-/jest-watch-typeahead-2.2.2.tgz", + "integrity": "sha512-+QgOFW4o5Xlgd6jGS5X37i08tuuXNW8X0CV9WNFi+3n8ExCIP+E1melYhvYLjv5fE6D0yyzk74vsSO8I6GqtvQ==", + "dev": true, + "dependencies": { + "ansi-escapes": "^6.0.0", + "chalk": "^5.2.0", + "jest-regex-util": "^29.0.0", + "jest-watcher": "^29.0.0", + "slash": "^5.0.0", + "string-length": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "jest": "^27.0.0 || ^28.0.0 || ^29.0.0" + } + }, + "node_modules/jest-watch-typeahead/node_modules/ansi-escapes": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.0.tgz", + "integrity": "sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==", + "dev": true, + "dependencies": { + "type-fest": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/chalk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", + "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", + "dev": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/char-regex": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-2.0.1.tgz", + "integrity": "sha512-oSvEeo6ZUD7NepqAat3RqoucZ5SeqLJgOvVIwkafu6IP3V0pO38s/ypdVUmDDK6qIIHNlYHJAKX9E7R7HoKElw==", + "dev": true, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/jest-watch-typeahead/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/string-length": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-5.0.1.tgz", + "integrity": "sha512-9Ep08KAMUn0OadnVaBuRdE2l615CQ508kr0XMadjClfYpdCyvrbFp6Taebo8yyxokQ4viUd/xPPUA4FGgUa0ow==", + "dev": true, + "dependencies": { + "char-regex": "^2.0.0", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watch-typeahead/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/jest-watch-typeahead/node_modules/type-fest": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", + "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", + "dev": true, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jest-watcher": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", diff --git a/package.json b/package.json index 9cc8f727b910279491853415bf79fbf366e95031..a7feaab56721eecfb48ad854e60c0ac989dd447d 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "jest-junit": "^16.0.0", + "jest-watch-typeahead": "^2.2.2", "lint-staged": "^14.0.1", "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", diff --git a/pages/redux-api-poc.tsx b/pages/redux-api-poc.tsx deleted file mode 100644 index 86f369323bd601379d1765822b11e9fd54d178b1..0000000000000000000000000000000000000000 --- a/pages/redux-api-poc.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { getBioEntityContents } from '@/redux/bioEntityContents/bioEntityContents.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')); - }; - - return ( - <div> - {searchValue} - <button type="button" onClick={triggerSyncUpdate}> - sync update - </button> - </div> - ); -}; - -export default ReduxPage; diff --git a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx index 8b38fda6785369dffa380a4849599412e8387f09..c5bff4c3eade40bfea3f82240ff36741467ab2e9 100644 --- a/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx +++ b/src/components/FunctionalArea/NavBar/NavBar.component.test.tsx @@ -1,8 +1,24 @@ -import { RenderResult, screen } from '@testing-library/react'; -import { renderComponentWithProvider } from '@/utils/renderComponentWithProvider'; +import drawerReducer from '@/redux/drawer/drawer.slice'; +import type { DrawerState } from '@/redux/drawer/drawer.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { render, screen } from '@testing-library/react'; import { NavBar } from './NavBar.component'; -const renderComponent = (): RenderResult => renderComponentWithProvider(<NavBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<DrawerState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('drawer', drawerReducer); + + return ( + render( + <Wrapper> + <NavBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('NavBar - component', () => { it('Should contain navigation buttons and logos with powered by info', () => { diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx index f3db33ecd32d76c2d7318382b391de148348b23c..f5ac55b046d8c3ab4aac649601a79ca602e42c35 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.test.tsx @@ -1,15 +1,59 @@ -import { screen, render, RenderResult, fireEvent } from '@testing-library/react'; +import searchReducer from '@/redux/search/search.slice'; +import type { SearchState } from '@/redux/search/search.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { fireEvent, render, screen } from '@testing-library/react'; import { SearchBar } from './SearchBar.component'; -const renderComponent = (): RenderResult => render(<SearchBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<SearchState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('search', searchReducer); + + return ( + render( + <Wrapper> + <SearchBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('SearchBar - component', () => { it('should let user type text', () => { renderComponent(); - const input = screen.getByTestId('search-input'); + const input = screen.getByTestId<HTMLInputElement>('search-input'); fireEvent.change(input, { target: { value: 'test value' } }); - expect(screen.getByDisplayValue('test value')).toBeInTheDocument(); + expect(input.value).toBe('test value'); + }); + + it('should disable button when the user clicks the lens button', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + expect(input.value).toBe('park7'); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(button).toBeDisabled(); + }); + + it('should disable input when the user clicks the Enter', () => { + renderComponent(); + + const input = screen.getByTestId<HTMLInputElement>('search-input'); + fireEvent.change(input, { target: { value: 'park7' } }); + + expect(input.value).toBe('park7'); + + fireEvent.keyDown(input, { key: 'Enter', code: 'Enter', charCode: 13 }); + + expect(input).toBeDisabled(); }); }); diff --git a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx index 0c7cdd153ad0db61420f0d1efbe0a6d069ee396e..1932b5a2e240252e93ae09000b4fe94a5c5b4539 100644 --- a/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx +++ b/src/components/FunctionalArea/TopBar/SearchBar/SearchBar.component.tsx @@ -1,12 +1,32 @@ -import Image from 'next/image'; -import { ChangeEvent, useState } from 'react'; import lensIcon from '@/assets/vectors/icons/lens.svg'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { + isPendingSearchStatusSelector, + searchValueSelector, +} from '@/redux/search/search.selectors'; +import { getSearchData } from '@/redux/search/search.thunks'; +import Image from 'next/image'; +import { ChangeEvent, KeyboardEvent, useState } from 'react'; +import { useSelector } from 'react-redux'; + +const ENTER_KEY_CODE = 'Enter'; export const SearchBar = (): JSX.Element => { const [searchValue, setSearchValue] = useState<string>(''); + const dispatch = useAppDispatch(); + const isPendingSearchStatus = useSelector(isPendingSearchStatusSelector); + const prevSearchValue = useSelector(searchValueSelector); + + const isSameSearchValue = prevSearchValue === searchValue; - const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => { + const onSearchChange = (event: ChangeEvent<HTMLInputElement>): void => setSearchValue(event.target.value); + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + const onSearchClick = () => !isSameSearchValue && dispatch(getSearchData(searchValue)); + + const handleKeyPress = (event: KeyboardEvent<HTMLInputElement>): void => { + if (!isSameSearchValue && event.code === ENTER_KEY_CODE) dispatch(getSearchData(searchValue)); }; return ( @@ -16,16 +36,25 @@ export const SearchBar = (): JSX.Element => { name="search-input" aria-label="search-input" data-testid="search-input" + onKeyDown={handleKeyPress} onChange={onSearchChange} + disabled={isPendingSearchStatus} className="h-9 w-72 rounded-[64px] border border-transparent bg-cultured px-4 py-2.5 text-xs font-medium text-font-400 outline-none hover:border-greyscale-600 focus:border-greyscale-600" /> - <Image - src={lensIcon} - alt="lens icon" - height={16} - width={16} - className="absolute right-4 top-2.5" - /> + <button + disabled={isPendingSearchStatus} + type="button" + className="bg-transparent" + onClick={onSearchClick} + > + <Image + src={lensIcon} + alt="lens icon" + height={16} + width={16} + className="absolute right-4 top-2.5" + /> + </button> </div> ); }; diff --git a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx index a611ffd3fa009a1d98b6d663bd9af6dd9fb7a73a..6ff1d8495eaa23d3d947a52f707b6e03a3ba85ac 100644 --- a/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/TopBar.component.test.tsx @@ -1,7 +1,24 @@ -import { screen, render, RenderResult } from '@testing-library/react'; +import searchReducer from '@/redux/search/search.slice'; +import type { SearchState } from '@/redux/search/search.types'; +import { ToolkitStoreWithSingleSlice } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; +import { render, screen } from '@testing-library/react'; import { TopBar } from './TopBar.component'; -const renderComponent = (): RenderResult => render(<TopBar />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<SearchState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('search', searchReducer); + + return ( + render( + <Wrapper> + <TopBar /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('TopBar - component', () => { it('Should contain user avatar, search bar', () => { diff --git a/src/components/Map/Drawer/Drawer.component.test.tsx b/src/components/Map/Drawer/Drawer.component.test.tsx index 20426d94766970d75e16b3c4b22c1dd89f7626e2..a0663188edef546d4eb44d1fde6591bbcdbaa1f5 100644 --- a/src/components/Map/Drawer/Drawer.component.test.tsx +++ b/src/components/Map/Drawer/Drawer.component.test.tsx @@ -1,8 +1,24 @@ -import { screen, fireEvent, type RenderResult } from '@testing-library/react'; -import { renderComponentWithProvider } from '@/utils/renderComponentWithProvider'; +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 { Drawer } from './Drawer.component'; -const renderComponent = (): RenderResult => renderComponentWithProvider(<Drawer />); +const renderComponent = (): { store: ToolkitStoreWithSingleSlice<DrawerState> } => { + const { Wrapper, store } = getReduxWrapperUsingSliceReducer('drawer', drawerReducer); + + return ( + render( + <Wrapper> + <Drawer /> + </Wrapper>, + ), + { + store, + } + ); +}; describe('Drawer - component', () => { it('should render Drawer', () => { @@ -11,13 +27,42 @@ describe('Drawer - component', () => { expect(screen.getByRole('drawer')).toBeInTheDocument(); }); - it('should close Drawer', async () => { + it('should not display drawer when its not open', () => { renderComponent(); - const button = screen.getByRole('close-drawer-button'); + expect(screen.getByRole('drawer')).not.toHaveClass('translate-x-0'); + }); + + describe('search drawer ', () => { + it('should open drawer and display search drawer content', async () => { + const { store } = renderComponent(); - await fireEvent.click(button); + expect(screen.queryByTestId('search-drawer-content')).not.toBeInTheDocument(); - expect(screen.getByRole('drawer')).not.toHaveClass('translate-x-0'); + await act(() => { + store.dispatch(openDrawer('search')); + }); + + expect(screen.getByTestId('search-drawer-content')).toBeInTheDocument(); + }); + + it('should close drawer after pressing close button', async () => { + const { store } = renderComponent(); + + await act(() => { + store.dispatch(openDrawer('search')); + }); + + expect(screen.getByTestId('search-drawer-content')).toBeInTheDocument(); + + const button = screen.getByRole('close-drawer-button'); + + await act(() => { + fireEvent.click(button); + }); + + expect(screen.getByRole('drawer')).not.toHaveClass('translate-x-0'); + expect(screen.queryByTestId('search-drawer-content')).not.toBeInTheDocument(); + }); }); }); diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 47ee77aa25583fde85d11acdd9fe9ba64a0f3a8c..4e692262a8a87a4e45a2a079b6db8c3551b4b4af 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -1,24 +1,21 @@ +import dynamic from 'next/dynamic'; import { twMerge } from 'tailwind-merge'; -import { IconButton } from '@/shared/IconButton'; -import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { closeDrawer } from '@/redux/drawer/drawer.slice'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { drawerDataSelector } from '@/redux/drawer/drawer.selectors'; -import { - CLOSE_BUTTON_ROLE, - DRAWER_ROLE, - SOURCE_FROM_DRAWER, -} from '@/components/Map/Drawer/Drawer.constants'; +import { DRAWER_ROLE } from '@/components/Map/Drawer/Drawer.constants'; -export const Drawer = (): JSX.Element => { - const dispatch = useAppDispatch(); - const drawerData = useAppSelector(drawerDataSelector); - const { open } = drawerData; +const SearchDrawerContent = dynamic( + async () => + import('@/components/Map/Drawer/SearchDrawerContent').then( + module => module.SearchDrawerContent, + ), + { + ssr: false, + }, +); - const handleCloseDrawer = (): void => { - // eslint-disable-next-line prefer-template - dispatch(closeDrawer(SOURCE_FROM_DRAWER)); - }; +export const Drawer = (): JSX.Element => { + const { open, drawerName } = useAppSelector(drawerDataSelector); return ( <div @@ -28,19 +25,8 @@ export const Drawer = (): JSX.Element => { )} role={DRAWER_ROLE} > - <div className="flex items-center justify-between border-b border-b-divide px-6 py-8 text-xl"> - <div> - <span className="font-normal">Search: </span> - <span className="font-semibold">NADH</span> - </div> - <IconButton - className="bg-white-pearl" - classNameIcon="fill-font-500" - icon="close" - role={CLOSE_BUTTON_ROLE} - onClick={handleCloseDrawer} - /> - </div> + {open && drawerName === 'search' && <SearchDrawerContent />} + {/* other drawers comes here, should use dynamic import */} </div> ); }; diff --git a/src/components/Map/Drawer/Drawer.constants.ts b/src/components/Map/Drawer/Drawer.constants.ts index 3e3ce70f02bd81e9cd128cd52674a87a4e49589c..3439102bee9811f466edafd40afb7c8437b5582f 100644 --- a/src/components/Map/Drawer/Drawer.constants.ts +++ b/src/components/Map/Drawer/Drawer.constants.ts @@ -1,3 +1,2 @@ export const DRAWER_ROLE = 'drawer'; -export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; export const SOURCE_FROM_DRAWER = 'search'; diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ff006e6b0d2f9cc5ba046e81ec909ac018bf7995 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesAccordion.component.tsx @@ -0,0 +1,40 @@ +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/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..48c8a2ebb887deb97b97aafb3c96b1d84e7c95e8 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.test.tsx @@ -0,0 +1,16 @@ +import { render, RenderResult, screen } from '@testing-library/react'; +import { + BioEntitiesSubmapItem, + BioEntitiesSubmapItemProps, +} from './BioEntitiesSubmapItem.component'; + +const renderComponent = ({ mapName, numberOfEntities }: BioEntitiesSubmapItemProps): RenderResult => + render(<BioEntitiesSubmapItem mapName={mapName} numberOfEntities={numberOfEntities} />); + +describe('BioEntitiesSubmapItem - component', () => { + it('should display map name,number of elements, icon', () => { + renderComponent({ mapName: 'main map', numberOfEntities: 21 }); + + expect(screen.getByText('main map (21)')).toBeInTheDocument(); + }); +}); diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ee1caedf79bb47f9972697a170ef38cc0dbcc2d --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx @@ -0,0 +1,18 @@ +import { Icon } from '@/shared/Icon'; + +export interface BioEntitiesSubmapItemProps { + mapName: string; + numberOfEntities: string | number; +} + +export const BioEntitiesSubmapItem = ({ + mapName, + numberOfEntities, +}: BioEntitiesSubmapItemProps): JSX.Element => ( + <div className="flex flex-row flex-nowrap justify-between pl-6 [&:not(:last-of-type)]:pb-4"> + <p> + {mapName} ({numberOfEntities}) + </p> + <Icon name="arrow" className="h-6 w-6 fill-font-500" /> + </div> +); diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..2c5c4cc182f509dddde6f1c3f6cefb960206560d --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/BioEntitiesSubmapItem/index.ts @@ -0,0 +1 @@ +export { BioEntitiesSubmapItem } from './BioEntitiesSubmapItem.component'; diff --git a/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/index.ts b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..9a28e240da672200983586d5467aaec8d45dd4b1 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion/index.ts @@ -0,0 +1 @@ +export { BioEntitiesAccordion } from './BioEntitiesAccordion.component'; diff --git a/src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx b/src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ad1b350ce514b1077ec72147ee250400c3c1174f --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/SearchDrawerContent.component.tsx @@ -0,0 +1,35 @@ +import { BioEntitiesAccordion } from '@/components/Map/Drawer/SearchDrawerContent/BioEntitiesAccordion'; +import { closeDrawer } from '@/redux/drawer/drawer.slice'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { IconButton } from '@/shared/IconButton'; + +export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; + +export const SearchDrawerContent = (): JSX.Element => { + const dispatch = useAppDispatch(); + + const handleCloseDrawer = (): void => { + dispatch(closeDrawer()); + }; + + return ( + <div className="flex flex-col" data-testid="search-drawer-content"> + <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> + <span className="font-semibold">NADH</span> + </div> + <IconButton + className="bg-white-pearl" + classNameIcon="fill-font-500" + icon="close" + role={CLOSE_BUTTON_ROLE} + onClick={handleCloseDrawer} + /> + </div> + <div className="px-6"> + <BioEntitiesAccordion /> + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/SearchDrawerContent/index.ts b/src/components/Map/Drawer/SearchDrawerContent/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..72074d52d0f624c1d2bb20a782da720e40692531 --- /dev/null +++ b/src/components/Map/Drawer/SearchDrawerContent/index.ts @@ -0,0 +1 @@ +export { SearchDrawerContent } from './SearchDrawerContent.component'; 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/chemicalSchema.ts b/src/models/chemicalSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..eafa7c257b1ce8886772745f73f147043d0a9573 --- /dev/null +++ b/src/models/chemicalSchema.ts @@ -0,0 +1,15 @@ +import { idSchema } from '@/models/idSchema'; +import { z } from 'zod'; +import { referenceSchema } from './referenceSchema'; +import { targetSchema } from './targetSchema'; + +export const chemicalSchema = z.object({ + id: idSchema, + name: z.string(), + description: z.string(), + directEvidence: z.string().nullable(), + directEvidenceReferences: z.array(referenceSchema), + synonyms: z.array(z.string()), + references: z.array(referenceSchema), + targets: z.array(targetSchema), +}); diff --git a/src/models/fixtures/chemicalsFixture.ts b/src/models/fixtures/chemicalsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..88e2169170f74600809a1329280c1f9549527e20 --- /dev/null +++ b/src/models/fixtures/chemicalsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { chemicalSchema } from '@/models/chemicalSchema'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; + +export const chemicalsFixture = createFixture(z.array(chemicalSchema), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/idSchema.ts b/src/models/idSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ccc4d780bd3185097e86bdac53ec45abd1c848b --- /dev/null +++ b/src/models/idSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const idSchema = z.object({ + annotatorClassName: z.string(), + id: z.number(), + link: z.string(), + resource: z.string(), + type: z.string(), +}); diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts index ca36caaedbba792346899f05ee6bb45010727e3d..e1337f97c6f7f73f141d5739313c1868ab357f30 100644 --- a/src/redux/apiPath.test.ts +++ b/src/redux/apiPath.test.ts @@ -19,4 +19,10 @@ describe('api path', () => { `projects/${PROJECT_ID}/models/*/bioEntities:search?query=park7`, ); }); + + it('should return url string for bio entity content', () => { + expect(apiPath.getChemicalsStringWithQuery('Corticosterone')).toBe( + `projects/${PROJECT_ID}/chemicals:search?query=Corticosterone`, + ); + }); }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 6ed0c991bf09a8b386ea363bbfbfd32315b370fb..11ce6f11c1e59f6e8f6a4bf38037ac62e2e501a2 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -8,4 +8,6 @@ export const apiPath = { 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}`, }; diff --git a/src/redux/bioEntityContents/bioEntityContents.selectors.ts b/src/redux/bioEntityContents/bioEntityContents.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..80a88cb0bec55d0ebf43ba8dd85827b44b429121 --- /dev/null +++ b/src/redux/bioEntityContents/bioEntityContents.selectors.ts @@ -0,0 +1,12 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const bioEntityContentsSelector = createSelector( + rootSelector, + state => state.bioEntityContents, +); + +export const loadingBioEntityStatusSelector = createSelector( + bioEntityContentsSelector, + state => state.loading, +); diff --git a/src/redux/chemicals/chemicals.reducers.test.ts b/src/redux/chemicals/chemicals.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e74d817181ca064e3b36069fcb3ffc398dfce54a --- /dev/null +++ b/src/redux/chemicals/chemicals.reducers.test.ts @@ -0,0 +1,79 @@ +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import chemicalsReducer from './chemicals.slice'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +const mockedAxiosClient = mockNetworkResponse(); +const SEARCH_QUERY = 'Corticosterone'; + +const INITIAL_STATE: ChemicalsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('chemicals reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ChemicalsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('chemicals', chemicalsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(chemicalsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const { type } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const { data, loading, error } = store.getState().chemicals; + + expect(type).toBe('project/getChemicals/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(chemicalsFixture); + }); + + it('should update store after failed getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.NotFound, chemicalsFixture); + + const { type } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const { data, loading, error } = store.getState().chemicals; + + expect(type).toBe('project/getChemicals/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const chemicalsPromise = store.dispatch(getChemicals(SEARCH_QUERY)); + + const { data, loading } = store.getState().chemicals; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + chemicalsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().chemicals; + + expect(dataPromiseFulfilled).toEqual(chemicalsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/chemicals/chemicals.reducers.ts b/src/redux/chemicals/chemicals.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ca1b96f7034944c01b3743544220faa9e563090 --- /dev/null +++ b/src/redux/chemicals/chemicals.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +export const getChemicalsReducer = (builder: ActionReducerMapBuilder<ChemicalsState>): void => { + builder.addCase(getChemicals.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getChemicals.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getChemicals.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/chemicals/chemicals.selectors.ts b/src/redux/chemicals/chemicals.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6c7cb1ccdcc7bbacce65c6f539d5499ab66e8a0 --- /dev/null +++ b/src/redux/chemicals/chemicals.selectors.ts @@ -0,0 +1,9 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const chemicalsSelector = createSelector(rootSelector, state => state.chemicals); + +export const loadingChemicalsStatusSelector = createSelector( + chemicalsSelector, + state => state.loading, +); diff --git a/src/redux/chemicals/chemicals.slice.ts b/src/redux/chemicals/chemicals.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8dd8e593bfca43771ec553bac8face485b88a2d --- /dev/null +++ b/src/redux/chemicals/chemicals.slice.ts @@ -0,0 +1,20 @@ +import { ChemicalsState } from '@/redux/chemicals/chemicals.types'; +import { createSlice } from '@reduxjs/toolkit'; +import { getChemicalsReducer } from './chemicals.reducers'; + +const initialState: ChemicalsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const chemicalsSlice = createSlice({ + name: 'chemicals', + initialState, + reducers: {}, + extraReducers: builder => { + getChemicalsReducer(builder); + }, +}); + +export default chemicalsSlice.reducer; diff --git a/src/redux/chemicals/chemicals.thunks.test.ts b/src/redux/chemicals/chemicals.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..93945477c94dd25afcf561a04ac592ce57d12f2e --- /dev/null +++ b/src/redux/chemicals/chemicals.thunks.test.ts @@ -0,0 +1,39 @@ +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import chemicalsReducer from './chemicals.slice'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +const mockedAxiosClient = mockNetworkResponse(); +const SEARCH_QUERY = 'Corticosterone'; + +describe('chemicals thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ChemicalsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('chemicals', chemicalsReducer); + }); + describe('getChemiclas', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(payload).toEqual(chemicalsFixture); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/chemicals/chemicals.thunks.ts b/src/redux/chemicals/chemicals.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..df0f9dec0db3cf06752fb5bcb80568990112ea32 --- /dev/null +++ b/src/redux/chemicals/chemicals.thunks.ts @@ -0,0 +1,20 @@ +import { chemicalSchema } from '@/models/chemicalSchema'; +import { apiPath } from '@/redux/apiPath'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Chemical } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; + +export const getChemicals = createAsyncThunk( + 'project/getChemicals', + async (searchQuery: string): Promise<Chemical[] | undefined> => { + const response = await axiosInstance.get<Chemical[]>( + apiPath.getChemicalsStringWithQuery(searchQuery), + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(chemicalSchema)); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/chemicals/chemicals.types.ts b/src/redux/chemicals/chemicals.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..653e910ab23427b38d7a24d6c6caecc588d46af2 --- /dev/null +++ b/src/redux/chemicals/chemicals.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Chemical } from '@/types/models'; + +export type ChemicalsState = FetchDataState<Chemical[]>; diff --git a/src/redux/drawer/drawer.reducers.test.ts b/src/redux/drawer/drawer.reducers.test.ts index fb360a4f122bc49d1841f9b42a9388804e027f7b..7701a180c2bc0c148b050e96c0ffc2bc523f1bcd 100644 --- a/src/redux/drawer/drawer.reducers.test.ts +++ b/src/redux/drawer/drawer.reducers.test.ts @@ -6,7 +6,7 @@ import type { DrawerState } from './drawer.types'; const INITIAL_STATE: DrawerState = { open: false, - pathName: 'none', + drawerName: 'none', }; type SliceReducerType = ToolkitStore< @@ -38,20 +38,20 @@ describe('drawer reducer', () => { it('should update the store when you click a project info button on the nav bar', async () => { const { type } = await store.dispatch(openDrawer('project-info')); - const { open, pathName } = store.getState().drawer; + const { open, drawerName } = store.getState().drawer; expect(type).toBe('drawer/openDrawer'); expect(open).toBe(true); - expect(pathName).toEqual('project-info'); + expect(drawerName).toEqual('project-info'); }); it('should update the store when you click the close button on the drawer', async () => { - const { type } = await store.dispatch(closeDrawer('project-info')); - const { open, pathName } = store.getState().drawer; + const { type } = await store.dispatch(closeDrawer()); + const { open, drawerName } = store.getState().drawer; expect(type).toBe('drawer/closeDrawer'); expect(open).toBe(false); - expect(pathName).toEqual('project-info'); + expect(drawerName).toEqual('none'); }); it.skip('should update the store when you type in the search', async () => { diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 5cc9f02bc9f61db3de8fca8484a0ae58f3522e2f..47b8ef6dd7ff78df197ba96bd753bb80d99b186b 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -4,10 +4,9 @@ import { PathName } from '@/types/pathName'; export const openDrawerReducer = (state: DrawerState, action: PayloadAction<PathName>): void => { state.open = true; - state.pathName = action.payload; + state.drawerName = action.payload; }; -export const closeDrawerReducer = (state: DrawerState, action: PayloadAction<PathName>): void => { +export const closeDrawerReducer = (state: DrawerState): void => { state.open = false; - state.pathName = action.payload; }; diff --git a/src/redux/drawer/drawer.slice.ts b/src/redux/drawer/drawer.slice.ts index 42e426de011a870792b92c137a4b4bda9f10029c..4c71fda1792e323ce63c52292f06bf13cb1f044f 100644 --- a/src/redux/drawer/drawer.slice.ts +++ b/src/redux/drawer/drawer.slice.ts @@ -4,7 +4,7 @@ import { openDrawerReducer, closeDrawerReducer } from './drawer.reducers'; const initialState: DrawerState = { open: false, - pathName: 'none', + drawerName: 'none', }; const drawerSlice = createSlice({ diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index b299eaa1cac1b9d466dcff6e804ff5cab9472f3e..a0ad70b8b7e1cf4f7555eaf15ffc265dd9b19206 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -1,4 +1,4 @@ export type DrawerState = { open: boolean; - pathName: 'none' | 'search' | 'project-info' | 'plugins' | 'export' | 'legend'; + drawerName: 'none' | 'search' | 'project-info' | 'plugins' | 'export' | 'legend'; }; diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..b67a8cb086dfed2ffdebe53f6d446865358ee459 --- /dev/null +++ b/src/redux/drugs/drugs.selectors.ts @@ -0,0 +1,6 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const drugsSelector = createSelector(rootSelector, state => state.drugs); + +export const loadingDrugsStatusSelector = createSelector(drugsSelector, state => state.loading); diff --git a/src/redux/mirnas/mirnas.selectors.ts b/src/redux/mirnas/mirnas.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..5344f0370c031a7a21d9e9b673d7a7d95f1d1208 --- /dev/null +++ b/src/redux/mirnas/mirnas.selectors.ts @@ -0,0 +1,6 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const mirnasSelector = createSelector(rootSelector, state => state.mirnas); + +export const loadingMirnasStatusSelector = createSelector(mirnasSelector, state => state.loading); diff --git a/src/redux/search/search.reducers.ts b/src/redux/search/search.reducers.ts index 4f6f747d357f19c9a48c43041c484c4052c21600..28226ca9200f8dfef935eae8ff1f6b45fb6f3337 100644 --- a/src/redux/search/search.reducers.ts +++ b/src/redux/search/search.reducers.ts @@ -1,7 +1,18 @@ // updating state +import { getSearchData } from '@/redux/search/search.thunks'; import { SearchState } from '@/redux/search/search.types'; -import { PayloadAction } from '@reduxjs/toolkit'; +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -export const setSearchValueReducer = (state: SearchState, action: PayloadAction<string>): void => { - state.searchValue = action.payload; +export const getSearchDataReducer = (builder: ActionReducerMapBuilder<SearchState>): void => { + builder.addCase(getSearchData.pending, (state, action) => { + state.searchValue = action.meta.arg; + state.loading = 'pending'; + }); + builder.addCase(getSearchData.fulfilled, state => { + state.loading = 'succeeded'; + }); + builder.addCase(getSearchData.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); }; diff --git a/src/redux/search/search.selectors.ts b/src/redux/search/search.selectors.ts index c845eecd0c4220dc245e95335f575ece55ecb804..143488fe3fa882c94f86691cec3479d4590791b6 100644 --- a/src/redux/search/search.selectors.ts +++ b/src/redux/search/search.selectors.ts @@ -1,4 +1,15 @@ -import type { RootState } from '@/redux/store'; +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; -// THIS IS EXAMPLE, it's not memoised!!!! Check redux-tookit docs. -export const selectSearchValue = (state: RootState): string => state.search.searchValue; +const PENDING_STATUS = 'pending'; + +export const searchSelector = createSelector(rootSelector, state => state.search); + +export const searchValueSelector = createSelector(searchSelector, state => state.searchValue); + +export const loadingSearchStatusSelector = createSelector(searchSelector, state => state.loading); + +export const isPendingSearchStatusSelector = createSelector( + loadingSearchStatusSelector, + state => state === PENDING_STATUS, +); diff --git a/src/redux/search/search.slice.ts b/src/redux/search/search.slice.ts index 92357f8c943fea34aae15b7978266df4c89ae760..73930e3374bc72c333bc89ec8be066d1e961c241 100644 --- a/src/redux/search/search.slice.ts +++ b/src/redux/search/search.slice.ts @@ -1,6 +1,6 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { getSearchDataReducer } from '@/redux/search/search.reducers'; import { SearchState } from '@/redux/search/search.types'; -import { setSearchValueReducer } from '@/redux/search/search.reducers'; +import { createSlice } from '@reduxjs/toolkit'; const initialState: SearchState = { searchValue: '', @@ -8,16 +8,16 @@ const initialState: SearchState = { content: '', drugs: '', }, + loading: 'idle', }; export const searchSlice = createSlice({ name: 'search', initialState, - reducers: { - setSearchValue: setSearchValueReducer, + reducers: {}, + extraReducers(builder) { + getSearchDataReducer(builder); }, }); -export const { setSearchValue } = searchSlice.actions; - export default searchSlice.reducer; diff --git a/src/redux/search/search.thunks.ts b/src/redux/search/search.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..2724826c65aa793de889176cc3fe75764509ec1c --- /dev/null +++ b/src/redux/search/search.thunks.ts @@ -0,0 +1,17 @@ +import { getBioEntityContents } from '@/redux/bioEntityContents/bioEntityContents.thunks'; +import { getChemicals } from '@/redux/chemicals/chemicals.thunks'; +import { getDrugs } from '@/redux/drugs/drugs.thunks'; +import { getMirnas } from '@/redux/mirnas/mirnas.thunks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +export const getSearchData = createAsyncThunk( + 'project/getSearchData', + async (searchQuery: string, { dispatch }): Promise<void> => { + await Promise.all([ + dispatch(getDrugs(searchQuery)), + dispatch(getBioEntityContents(searchQuery)), + dispatch(getChemicals(searchQuery)), + dispatch(getMirnas(searchQuery)), + ]); + }, +); diff --git a/src/redux/search/search.types.ts b/src/redux/search/search.types.ts index 6b6316a881f83aed58588d03b75c2f0ca0d5d9a1..1faf1b72e2f18b7a340e425c0af6a611712e95d8 100644 --- a/src/redux/search/search.types.ts +++ b/src/redux/search/search.types.ts @@ -1,3 +1,5 @@ +import { Loading } from '@/types/loadingState'; + export interface SearchResult { content: string; drugs: string; @@ -6,4 +8,5 @@ export interface SearchResult { export interface SearchState { searchValue: string; searchResult: SearchResult; + loading: Loading; } diff --git a/src/redux/store.ts b/src/redux/store.ts index 3928339b17b467628a8b183bac4f461002617489..1999d33f1373323b288bed176473fca135a77067 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,15 +1,21 @@ -import { configureStore } from '@reduxjs/toolkit'; -import searchReducer from '@/redux/search/search.slice'; -import projectSlice from '@/redux/project/project.slice'; -import drugsReducer from '@/redux/drugs/drugs.slice'; +import bioEntityContentsReducer from '@/redux/bioEntityContents/bioEntityContents.slice'; +import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; import modelsReducer from '@/redux/models/models.slice'; +import drugsReducer from '@/redux/drugs/drugs.slice'; +import mirnasReducer from '@/redux/mirnas/mirnas.slice'; +import projectSlice from '@/redux/project/project.slice'; +import searchReducer from '@/redux/search/search.slice'; +import { configureStore } from '@reduxjs/toolkit'; export const store = configureStore({ reducer: { search: searchReducer, project: projectSlice, drugs: drugsReducer, + mirnas: mirnasReducer, + chemicals: chemicalsReducer, + bioEntityContents: bioEntityContentsReducer, drawer: drawerReducer, models: modelsReducer, }, diff --git a/src/shared/Accordion/Accordion.component.tsx b/src/shared/Accordion/Accordion.component.tsx index f0de2a3e2d506006d8211936ca45c70ad0b11f1d..c02c53d6f1df5e14382d2d7b7541928266328c1a 100644 --- a/src/shared/Accordion/Accordion.component.tsx +++ b/src/shared/Accordion/Accordion.component.tsx @@ -1,3 +1,4 @@ +import { twMerge } from 'tailwind-merge'; import { Accordion as Ac } from 'react-accessible-accordion'; import { DivAttributes } from 'react-accessible-accordion/dist/types/helpers/types'; @@ -9,6 +10,7 @@ type AccordionProps = Pick<DivAttributes, Exclude<keyof DivAttributes, 'onChange allowMultipleExpanded?: boolean; allowZeroExpanded?: boolean; onChange?(args: ID[]): void; + className?: string; }; export const Accordion = ({ @@ -17,6 +19,7 @@ export const Accordion = ({ allowMultipleExpanded, allowZeroExpanded, onChange, + className, ...rest }: AccordionProps): JSX.Element => ( <Ac @@ -24,6 +27,7 @@ export const Accordion = ({ allowMultipleExpanded={allowMultipleExpanded} allowZeroExpanded={allowZeroExpanded} onChange={onChange} + className={twMerge('text-base', className)} {...rest} > {children} diff --git a/src/shared/Accordion/AccordionItemPanel/AccordionItemPanel.component.tsx b/src/shared/Accordion/AccordionItemPanel/AccordionItemPanel.component.tsx index d0ef686da639ebe64570fac06c6eee25deb7af8d..439ba6d82d1b2f2b327393d3eebf5532cee3ff4c 100644 --- a/src/shared/Accordion/AccordionItemPanel/AccordionItemPanel.component.tsx +++ b/src/shared/Accordion/AccordionItemPanel/AccordionItemPanel.component.tsx @@ -1,9 +1,14 @@ import { AccordionItemPanel as AIP } from 'react-accessible-accordion'; +import { twMerge } from 'tailwind-merge'; interface AccordionItemPanelProps { + className?: string; children: React.ReactNode; } -export const AccordionItemPanel = ({ children }: AccordionItemPanelProps): JSX.Element => ( - <AIP className="pb-4">{children}</AIP> +export const AccordionItemPanel = ({ + className, + children, +}: AccordionItemPanelProps): JSX.Element => ( + <AIP className={twMerge('pb-4', className)}>{children}</AIP> ); diff --git a/src/types/models.ts b/src/types/models.ts index 81b516066594c8122700b2f190f06d26da388ad1..8902849349a999a58661d14d73d5734574f96130 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1,9 +1,10 @@ +import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; +import { chemicalSchema } from '@/models/chemicalSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; +import { mirnaSchema } from '@/models/mirnaSchema'; import { organism } from '@/models/organism'; import { projectSchema } from '@/models/project'; -import { mirnaSchema } from '@/models/mirnaSchema'; -import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { z } from 'zod'; import { modelSchema } from '@/models/modelSchema'; @@ -14,3 +15,4 @@ export type Drug = z.infer<typeof drugSchema>; export type Mirna = z.infer<typeof mirnaSchema>; export type BioEntityContent = z.infer<typeof bioEntityContentSchema>; export type Model = z.infer<typeof modelSchema>; +export type Chemical = z.infer<typeof chemicalSchema>; 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/getReduxWrapperUsingSliceReducer.tsx b/src/utils/testing/getReduxWrapperUsingSliceReducer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..302eb7845d76b84b50e1c5fb8c16ae7f885cfb44 --- /dev/null +++ b/src/utils/testing/getReduxWrapperUsingSliceReducer.tsx @@ -0,0 +1,31 @@ +import { Reducer } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '../createStoreInstanceUsingSliceReducer'; + +interface WrapperProps { + children: React.ReactNode; +} + +type GetReduxWrapperUsingSliceReducer = <StateType>( + name: string, + passedReducer: Reducer<StateType>, +) => { + Wrapper: ({ children }: WrapperProps) => JSX.Element; + store: ToolkitStoreWithSingleSlice<StateType>; +}; + +export const getReduxWrapperUsingSliceReducer: GetReduxWrapperUsingSliceReducer = ( + reducerName, + reducerInstance, +) => { + const store = createStoreInstanceUsingSliceReducer(reducerName, reducerInstance); + + const Wrapper = ({ children }: WrapperProps): JSX.Element => ( + <Provider store={store}>{children}</Provider> + ); + + return { Wrapper, store }; +}; diff --git a/tsconfig.json b/tsconfig.json index 338c730927fddced861320bac441ccaa9568de16..a44f42a56e52544084e6f465ad04da481689ef14 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -29,7 +29,6 @@ "**/*.tsx", ".next/types/**/*.ts", "pages", - "@types/images.d.ts", "jest.config.ts", "setupTests.ts" ],