diff --git a/src/components/Map/Drawer/Drawer.component.test.tsx b/src/components/Map/Drawer/Drawer.component.test.tsx index 185ccbae910b0813e2a240508f78fdaacde166e1..a0cbbdfc2e08e3a39b9b1cd658d5e895f0e811c3 100644 --- a/src/components/Map/Drawer/Drawer.component.test.tsx +++ b/src/components/Map/Drawer/Drawer.component.test.tsx @@ -1,11 +1,22 @@ -import { openSearchDrawerWithSelectedTab, openSubmapsDrawer } from '@/redux/drawer/drawer.slice'; +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { reactionsFixture } from '@/models/fixtures/reactionFixture'; +import { + openReactionDrawerById, + openSearchDrawerWithSelectedTab, + openSubmapsDrawer, +} from '@/redux/drawer/drawer.slice'; +import { getReactionsByIds } from '@/redux/reactions/reactions.thunks'; import { StoreType } from '@/redux/store'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { act, fireEvent, render, screen } from '@testing-library/react'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type {} from 'redux-thunk/extend-redux'; import { Drawer } from './Drawer.component'; -const renderComponent = (): { store: StoreType } => { - const { Wrapper, store } = getReduxWrapperWithStore(); +const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStore); return ( render( <Wrapper> @@ -77,4 +88,25 @@ describe('Drawer - component', () => { expect(screen.getByTestId('submap-drawer')).toBeInTheDocument(); }); }); + + describe('reaction drawer', () => { + it('should open drawer and display reaction', async () => { + const { id } = reactionsFixture[FIRST_ARRAY_ELEMENT]; + + const { store } = renderComponent({ + reactions: { + data: reactionsFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + }); + + expect(screen.queryByTestId('reaction-drawer')).not.toBeInTheDocument(); + + store.dispatch(getReactionsByIds([id])); + store.dispatch(openReactionDrawerById(id)); + + await waitFor(() => expect(screen.getByTestId('reaction-drawer')).toBeInTheDocument()); + }); + }); }); diff --git a/src/components/Map/Drawer/Drawer.component.tsx b/src/components/Map/Drawer/Drawer.component.tsx index 269281bd16f691fb84569c7ba489964751128432..abc4e3a2880001d5e5463ec393963f139b54b308 100644 --- a/src/components/Map/Drawer/Drawer.component.tsx +++ b/src/components/Map/Drawer/Drawer.component.tsx @@ -2,6 +2,7 @@ import { DRAWER_ROLE } from '@/components/Map/Drawer/Drawer.constants'; import { drawerSelector } from '@/redux/drawer/drawer.selectors'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { twMerge } from 'tailwind-merge'; +import { ReactionDrawer } from './ReactionDrawer'; import { SearchDrawerWrapper as SearchDrawerContent } from './SearchDrawerWrapper'; import { SubmapsDrawer } from './SubmapsDrawer'; @@ -11,13 +12,14 @@ export const Drawer = (): JSX.Element => { return ( <div className={twMerge( - 'absolute bottom-0 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', + 'absolute bottom-0 left-[88px] top-[104px] z-10 h-calc-drawer w-[432px] -translate-x-full transform border border-divide bg-white-pearl text-font-500 transition-all duration-500', isOpen && 'translate-x-0', )} role={DRAWER_ROLE} > {isOpen && drawerName === 'search' && <SearchDrawerContent />} {isOpen && drawerName === 'submaps' && <SubmapsDrawer />} + {isOpen && drawerName === 'reaction' && <ReactionDrawer />} </div> ); }; diff --git a/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.test.tsx b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1a635a0b5ac357f43962460455aa1c9683780c41 --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.test.tsx @@ -0,0 +1,116 @@ +import { SECOND_ARRAY_ELEMENT } from '@/constants/common'; +import { reactionsFixture } from '@/models/fixtures/reactionFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { ReactionDrawer } from './ReactionDrawer.component'; +import { DEFAULT_REFERENCE_SOURCE } from './ReactionDrawer.constants'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ReactionDrawer /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ReactionDrawer - component', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + describe("when there's NO matching reaction", () => { + beforeEach(() => + renderComponent({ + reactions: { + data: [], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: DRAWER_INITIAL_STATE, + }), + ); + + it('should not show drawer content', () => { + expect(screen.queryByText('Reaction:')).toBeNull(); + expect(screen.queryByText('Type:')).toBeNull(); + expect(screen.queryByText('Annotations:')).toBeNull(); + expect(screen.queryByText('Source:')).toBeNull(); + }); + }); + + describe('when there IS a matching reaction', () => { + const reaction = reactionsFixture[SECOND_ARRAY_ELEMENT]; + + const filteredReferences = reaction.references.filter( + ref => ref.link !== null && ref.link !== undefined, + ); + + const referencesTextHref: [string, string][] = filteredReferences.map(ref => [ + `${ref.type} (${ref.id})`, + ref.link as string, + ]); + + const referencesSources: string[] = filteredReferences.map( + ref => ref.annotatorClassName || DEFAULT_REFERENCE_SOURCE, + ); + + beforeEach(() => + renderComponent({ + reactions: { + data: reactionsFixture, + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + reactionDrawerState: { + reactionId: reaction.id, + }, + }, + }), + ); + + it('should show drawer header', () => { + expect(screen.getByText('Reaction:')).toBeInTheDocument(); + expect(screen.getByText(reaction.reactionId)).toBeInTheDocument(); + }); + + it('should show drawer reaction type', () => { + expect(screen.getByText('Type:')).toBeInTheDocument(); + expect(screen.getByText(reaction.type)).toBeInTheDocument(); + }); + + it('should show drawer reaction annotations title', () => { + expect(screen.getByText('Annotations:')).toBeInTheDocument(); + }); + + it.each(referencesSources)('should show drawer reaction source for source=%s', source => { + expect(screen.getByText(`Source: ${source}`, { exact: false })).toBeInTheDocument(); + }); + + it.each(referencesTextHref)( + 'should show drawer reaction reference with text=%s, href=%s', + (refText, href) => { + const linkReferenceSpan = screen.getByText(refText, { exact: false }); + const linkReferenceAnchor = linkReferenceSpan.closest('a'); + + expect(linkReferenceSpan).toBeInTheDocument(); + expect(linkReferenceAnchor).toBeInTheDocument(); + expect(linkReferenceAnchor?.href).toBe(`${href}/`); // component render adds trailing slash + }, + ); + }); +}); diff --git a/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.tsx b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9f6a8c4c3a130a2986a2a7cb15879fa93cb6cca9 --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.component.tsx @@ -0,0 +1,38 @@ +import { + currentDrawerReactionGroupedReferencesSelector, + currentDrawerReactionSelector, +} from '@/redux/reactions/reactions.selector'; +import { DrawerHeading } from '@/shared/DrawerHeading'; +import { useSelector } from 'react-redux'; +import { ReferenceGroup } from './ReferenceGroup'; + +export const ReactionDrawer = (): React.ReactNode => { + const reaction = useSelector(currentDrawerReactionSelector); + const referencesGrouped = useSelector(currentDrawerReactionGroupedReferencesSelector); + + if (!reaction) { + return null; + } + + return ( + <div className="h-full max-h-full" data-testid="reaction-drawer"> + <DrawerHeading + title={ + <> + <span className="font-normal">Reaction:</span> {reaction.reactionId} + </> + } + /> + <div className="flex flex-col gap-6 p-6"> + <div className="text-sm font-normal"> + Type: <b className="font-semibold">{reaction.type}</b> + </div> + <hr className="border-b border-b-divide" /> + <h3 className="font-semibold">Annotations:</h3> + {referencesGrouped.map(group => ( + <ReferenceGroup key={group.source} group={group} /> + ))} + </div> + </div> + ); +}; diff --git a/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.constants.ts b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ea80cbf1cd8944995ff783aa2fd0dd73a3d57c9 --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReactionDrawer.constants.ts @@ -0,0 +1 @@ +export const DEFAULT_REFERENCE_SOURCE = 'Annotated by curator'; diff --git a/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.test.tsx b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..838f1d50e65f88462e99d31713bcd202b4661dcf --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.test.tsx @@ -0,0 +1,111 @@ +import { StoreType } from '@/redux/store'; +import { ReferenceFiltered } from '@/types/reference'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { render, screen } from '@testing-library/react'; +import { Props, ReferenceGroup } from './ReferenceGroup.component'; + +const renderComponent = ( + props: Props, + initialStoreState: InitialStoreState = {}, +): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <ReferenceGroup {...props} /> + </Wrapper>, + ), + { + store, + } + ); +}; + +describe('ReactionDrawer - component', () => { + beforeEach(() => { + jest.resetAllMocks(); + jest.clearAllMocks(); + }); + + const singleReference = { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: '', + }; + + const cases: [string, ReferenceFiltered[]][] = [ + ['', [singleReference]], + [ + 'source1', + [ + { + ...singleReference, + annotatorClassName: 'source1', + id: 1, + }, + { + ...singleReference, + annotatorClassName: 'source1', + id: 2, + }, + ], + ], + [ + 'source2', + [ + { + ...singleReference, + annotatorClassName: 'source2', + id: 3, + }, + ], + ], + ]; + + it.each(cases)('should show reference group with source=%s', (source, references) => { + const referencesTextHref: [string, string][] = references.map(ref => [ + `${ref.type} (${ref.id})`, + ref.link as string, + ]); + + renderComponent({ + group: { + source, + references, + }, + }); + + referencesTextHref.forEach(([refText, href]) => { + const linkReferenceSpan = screen.getByText(refText, { exact: false }); + const linkReferenceAnchor = linkReferenceSpan.closest('a'); + + expect(linkReferenceSpan).toBeInTheDocument(); + expect(linkReferenceAnchor).toBeInTheDocument(); + expect(linkReferenceAnchor?.href).toBe(`${href}`); + }); + }); +}); diff --git a/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.tsx b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aab45512ff1b06cdea9eebca337c83f27082939e --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/ReferenceGroup.component.tsx @@ -0,0 +1,21 @@ +import { Icon } from '@/shared/Icon'; +import { ReferenceGroup as ReferenceGroupType } from '@/types/reference'; +import { DEFAULT_REFERENCE_SOURCE } from '../ReactionDrawer.constants'; + +export interface Props { + group: ReferenceGroupType; +} + +export const ReferenceGroup = ({ group: { source, references } }: Props): JSX.Element => ( + <> + <h3 className="font-semibold">Source: {source || DEFAULT_REFERENCE_SOURCE}</h3> + {references.map(({ id, link, type }) => ( + <a key={id} href={link} target="_blank"> + <div className="flex justify-between"> + <span>{`${type} (${id})`}</span> + <Icon name="arrow" className="h-6 w-6 fill-font-500" /> + </div> + </a> + ))} + </> +); diff --git a/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/index.ts b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ac05ab8eb96c1fdf86a0095257838d8de604991 --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/ReferenceGroup/index.ts @@ -0,0 +1 @@ +export { ReferenceGroup } from './ReferenceGroup.component'; diff --git a/src/components/Map/Drawer/ReactionDrawer/index.ts b/src/components/Map/Drawer/ReactionDrawer/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f440178e2344badc1c96e449196e59ac66497ea4 --- /dev/null +++ b/src/components/Map/Drawer/ReactionDrawer/index.ts @@ -0,0 +1 @@ +export { ReactionDrawer } from './ReactionDrawer.component'; diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx index 7a35bafb8e264528fba7a55a258271790172ae00..0d22badc45be8d6f9cd6cf4c68102330b6f399b6 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.test.tsx @@ -43,6 +43,7 @@ describe('SearchDrawerWrapper - component', () => { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }, }); @@ -61,6 +62,7 @@ describe('SearchDrawerWrapper - component', () => { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }, }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts index 701539ac758b99655a2537758fc04bca64f2f4c3..8c3eb1e6f95f2461b398610cf139e1096e50d81c 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts @@ -1,4 +1,4 @@ -import { FIRST, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { FIRST_ARRAY_ELEMENT, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS } from '@/models/mocks/elementSearchResultMock'; import { apiPath } from '@/redux/apiPath'; @@ -31,7 +31,7 @@ describe('handleAliasResults - util', () => { await waitFor(() => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[FIRST].type).toEqual('project/getMultiBioEntity/pending'); + expect(actions[FIRST_ARRAY_ELEMENT].type).toEqual('project/getMultiBioEntity/pending'); }); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts index 21c6524d2eb8656cf19a870edb07e15d06963d6e..b514095b3654a64d101e79df172d841b60abb01d 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts @@ -1,5 +1,5 @@ /* eslint-disable no-magic-numbers */ -import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { FIRST_ARRAY_ELEMENT, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { reactionsFixture } from '@/models/fixtures/reactionFixture'; import { @@ -43,15 +43,22 @@ describe('handleReactionResults - util', () => { expect(actions[1].type).toEqual('reactions/getByIds/fulfilled'); }); - it('should run setBioEntityContent to empty array as second action', () => { + it('should run openReactionDrawerById to empty array as second action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[2].type).toEqual('project/getMultiBioEntity/pending'); + expect(actions[2].type).toEqual('drawer/openReactionDrawerById'); + expect(actions[2].payload).toEqual(reactionsFixture[FIRST_ARRAY_ELEMENT].id); }); - it('should run getBioEntity as third action', () => { + it('should run setBioEntityContent to empty array as third action', () => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[3].type).toEqual('project/getBioEntityContents/pending'); + expect(actions[3].type).toEqual('project/getMultiBioEntity/pending'); + }); + + it('should run getBioEntity as fourth action', () => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[4].type).toEqual('project/getBioEntityContents/pending'); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts index 5c5ae1a20eada4b0349d1b2972059cdb04265d69..55c244ad04bd1a9cb6021085247101025742fa68 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -1,5 +1,6 @@ -import { FIRST, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { FIRST_ARRAY_ELEMENT, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; +import { openReactionDrawerById } from '@/redux/drawer/drawer.slice'; import { getReactionsByIds } from '@/redux/reactions/reactions.thunks'; import { AppDispatch } from '@/redux/store'; import { ElementSearchResult, Reaction } from '@/types/models'; @@ -15,12 +16,14 @@ export const handleReactionResults = return; } - const { products, reactants, modifiers } = payload[FIRST]; + const reaction = payload[FIRST_ARRAY_ELEMENT]; + const { products, reactants, modifiers } = reaction; const productsIds = products.map(p => p.aliasId); const reactantsIds = reactants.map(r => r.aliasId); const modifiersIds = modifiers.map(m => m.aliasId); const bioEntitiesIds = [...productsIds, ...reactantsIds, ...modifiersIds].map(identifier => String(identifier)); + dispatch(openReactionDrawerById(reaction.id)); await dispatch( getMultiBioEntity({ searchQueries: bioEntitiesIds, diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts index e60af27ce2a06b6db202af658868857d57b341a7..648457ecab4fb03ddd75a8b69d324377d81d9c39 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts @@ -1,4 +1,4 @@ -import { FIRST } from '@/constants/common'; +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; import { AppDispatch } from '@/redux/store'; import { ElementSearchResult } from '@/types/models'; import { handleAliasResults } from './handleAliasResults'; @@ -13,7 +13,7 @@ export const handleSearchResultAction = async ({ searchResults, dispatch, }: HandleSearchResultActionInput): Promise<void> => { - const closestSearchResult = searchResults[FIRST]; + const closestSearchResult = searchResults[FIRST_ARRAY_ELEMENT]; const { type } = closestSearchResult; const action = { ALIAS: handleAliasResults, diff --git a/src/constants/common.ts b/src/constants/common.ts index 1825686b98af79ba59146ff6c567b8a50170940e..973c26af876d5e91f9e0599ff1a21571d96579b5 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -1,5 +1,6 @@ export const SIZE_OF_EMPTY_ARRAY = 0; export const ZERO = 0; -export const FIRST = 0; +export const FIRST_ARRAY_ELEMENT = 0; export const ONE = 1; +export const SECOND_ARRAY_ELEMENT = 1; diff --git a/src/models/mocks/referencesMock.ts b/src/models/mocks/referencesMock.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce51b43e29b66b0e0266367a6c8772916b7e7a81 --- /dev/null +++ b/src/models/mocks/referencesMock.ts @@ -0,0 +1,178 @@ +import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { Reference } from '@/types/models'; + +export const REFERENCES_MOCK_ALL_VALID: Reference[] = [ + { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: '', + }, + { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/27299292', + article: { + title: + 'Transcription factor EB: from master coordinator of lysosomal pathways to candidate therapeutic target in degenerative storage diseases.', + authors: ['Sardiello M.'], + journal: 'Annals of the New York Academy of Sciences', + year: 2016, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/27299292', + pubmedId: '27299292', + citationCount: 66, + }, + type: 'PUBMED', + resource: '27299292', + id: 154974, + annotatorClassName: '', + }, + { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: 'source1', + }, + { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + article: { + title: + 'Transcription factor EB: from master coordinator of lysosomal pathways to candidate therapeutic target in degenerative storage diseases.', + authors: ['Sardiello M.'], + journal: 'Annals of the New York Academy of Sciences', + year: 2016, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/27299292', + pubmedId: '27299292', + citationCount: 66, + }, + type: 'PUBMED', + resource: '27299292', + id: 154974, + annotatorClassName: 'source2', + }, +]; + +export const REFERENCES_MOCK_ALL_INVALID: Reference[] = [ + { + link: null, + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: '', + }, + { + link: null, + article: { + title: + 'Transcription factor EB: from master coordinator of lysosomal pathways to candidate therapeutic target in degenerative storage diseases.', + authors: ['Sardiello M.'], + journal: 'Annals of the New York Academy of Sciences', + year: 2016, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/27299292', + pubmedId: '27299292', + citationCount: 66, + }, + type: 'PUBMED', + resource: '27299292', + id: 154974, + annotatorClassName: '', + }, + { + link: null, + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: 'source1', + }, + { + link: null, + article: { + title: + 'Transcription factor EB: from master coordinator of lysosomal pathways to candidate therapeutic target in degenerative storage diseases.', + authors: ['Sardiello M.'], + journal: 'Annals of the New York Academy of Sciences', + year: 2016, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/27299292', + pubmedId: '27299292', + citationCount: 66, + }, + type: 'PUBMED', + resource: '27299292', + id: 154974, + annotatorClassName: 'source2', + }, +]; + +export const SINGLE_VALID_REFERENCE = REFERENCES_MOCK_ALL_VALID[FIRST_ARRAY_ELEMENT]; diff --git a/src/models/referenceSchema.ts b/src/models/referenceSchema.ts index 30a31e287cfdc79348e9655b635a417a0729a34b..44a1e0c6d58c37cc92ef8ec2a0acd796007467fc 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().nullable(), + link: z.string().url().nullable(), article: articleSchema.optional(), type: z.string(), resource: z.string(), diff --git a/src/redux/drawer/drawer.constants.ts b/src/redux/drawer/drawer.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..68d612ac9444f668bcf19cbcbfde1efee4b1c430 --- /dev/null +++ b/src/redux/drawer/drawer.constants.ts @@ -0,0 +1,14 @@ +import { DrawerState } from './drawer.types'; + +export const DRAWER_INITIAL_STATE: DrawerState = { + isOpen: false, + drawerName: 'none', + searchDrawerState: { + currentStep: 0, + stepType: 'none', + selectedValue: undefined, + listOfBioEnitites: [], + selectedSearchElement: '', + }, + reactionDrawerState: {}, +}; diff --git a/src/redux/drawer/drawer.reducers.test.ts b/src/redux/drawer/drawer.reducers.test.ts index 5f5986f50ad3c58df43c6cbaccce397d94ac9ae5..8b3558312d380e4217a85f1eea0f6b633700b208 100644 --- a/src/redux/drawer/drawer.reducers.test.ts +++ b/src/redux/drawer/drawer.reducers.test.ts @@ -1,7 +1,8 @@ +import { drugFixture } from '@/models/fixtures/drugFixtures'; import * as toolkitRaw from '@reduxjs/toolkit'; import { AnyAction } from '@reduxjs/toolkit'; import type { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore'; -import { drugFixture } from '@/models/fixtures/drugFixtures'; +import { DRAWER_INITIAL_STATE } from './drawer.constants'; import drawerReducer, { closeDrawer, displayChemicalsList, @@ -14,18 +15,6 @@ import drawerReducer, { } from './drawer.slice'; import type { DrawerState } from './drawer.types'; -const INITIAL_STATE: DrawerState = { - isOpen: false, - drawerName: 'none', - searchDrawerState: { - currentStep: 0, - stepType: 'none', - selectedValue: undefined, - listOfBioEnitites: [], - selectedSearchElement: '', - }, -}; - const STEP = { FIRST: 1, SECOND: 2, @@ -56,7 +45,7 @@ describe('drawer reducer', () => { it('should match initial state', () => { const action = { type: 'unknown' }; - expect(drawerReducer(undefined, action)).toEqual(INITIAL_STATE); + expect(drawerReducer(undefined, action)).toEqual(DRAWER_INITIAL_STATE); }); it('should update the store after openDrawer action', async () => { diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index f0546c3176022fc9b90fe1150ce6ec5eab459fef..5aefc95bf503df4b2fd87ea9b7d480c9cd88296b 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -1,6 +1,7 @@ import { STEP } from '@/constants/searchDrawer'; import type { DrawerState, + OpenReactionDrawerByIdAction, OpenSearchDrawerWithSelectedTabReducerAction, } from '@/redux/drawer/drawer.types'; import type { DrawerName } from '@/types/drawerName'; @@ -74,3 +75,12 @@ export const displayEntityDetailsReducer = ( state.searchDrawerState.currentStep = STEP.THIRD; state.searchDrawerState.selectedValue = action.payload; }; + +export const openReactionDrawerByIdReducer = ( + state: DrawerState, + action: OpenReactionDrawerByIdAction, +): void => { + state.isOpen = true; + state.drawerName = 'reaction'; + state.reactionDrawerState.reactionId = action.payload; +}; diff --git a/src/redux/drawer/drawer.selectors.ts b/src/redux/drawer/drawer.selectors.ts index 66743062f679acf1ae46440fea034599ed46be0b..dc8cb74d8b6e248b999df35cc5f7c501d7dfe281 100644 --- a/src/redux/drawer/drawer.selectors.ts +++ b/src/redux/drawer/drawer.selectors.ts @@ -89,3 +89,13 @@ export const resultListSelector = createSelector( export const bioEnititiesResultListSelector = createSelector(rootSelector, state => { return state.drawer.searchDrawerState.listOfBioEnitites; }); + +export const reactionDrawerStateSelector = createSelector( + drawerSelector, + state => state.reactionDrawerState, +); + +export const currentDrawerReactionIdSelector = createSelector( + reactionDrawerStateSelector, + state => state?.reactionId, +); diff --git a/src/redux/drawer/drawer.slice.ts b/src/redux/drawer/drawer.slice.ts index 1ae3ace2709537f7a943ddb6f0f15b9b9a495a14..bf0c2f9fe6b42a3992e71fa0b42642bbc882d4d8 100644 --- a/src/redux/drawer/drawer.slice.ts +++ b/src/redux/drawer/drawer.slice.ts @@ -1,4 +1,3 @@ -import { DrawerState } from '@/redux/drawer/drawer.types'; import { createSlice } from '@reduxjs/toolkit'; import { closeDrawerReducer, @@ -9,25 +8,15 @@ import { displayGroupedSearchResultsReducer, displayMirnaListReducer, openDrawerReducer, + openReactionDrawerByIdReducer, openSearchDrawerWithSelectedTabReducer, openSubmapsDrawerReducer, } from './drawer.reducers'; - -const initialState: DrawerState = { - isOpen: false, - drawerName: 'none', - searchDrawerState: { - currentStep: 0, - stepType: 'none', - selectedValue: undefined, - listOfBioEnitites: [], - selectedSearchElement: '', - }, -}; +import { DRAWER_INITIAL_STATE } from './drawer.constants'; const drawerSlice = createSlice({ name: 'drawer', - initialState, + initialState: DRAWER_INITIAL_STATE, reducers: { openDrawer: openDrawerReducer, openSearchDrawerWithSelectedTab: openSearchDrawerWithSelectedTabReducer, @@ -39,6 +28,7 @@ const drawerSlice = createSlice({ displayBioEntitiesList: displayBioEntitiesListReducer, displayGroupedSearchResults: displayGroupedSearchResultsReducer, displayEntityDetails: displayEntityDetailsReducer, + openReactionDrawerById: openReactionDrawerByIdReducer, }, }); @@ -53,6 +43,7 @@ export const { displayBioEntitiesList, displayGroupedSearchResults, displayEntityDetails, + openReactionDrawerById, } = drawerSlice.actions; export default drawerSlice.reducer; diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index 5660d534bdea32d783957b4824d6e5b0a6ba78ca..3046389c05ccb350d506125d67284c507d62ce80 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -10,12 +10,20 @@ export type SearchDrawerState = { selectedSearchElement: string; }; +export type ReactionDrawerState = { + reactionId?: number; +}; + export type DrawerState = { isOpen: boolean; drawerName: DrawerName; searchDrawerState: SearchDrawerState; + reactionDrawerState: ReactionDrawerState; }; export type OpenSearchDrawerWithSelectedTabReducerPayload = string; export type OpenSearchDrawerWithSelectedTabReducerAction = PayloadAction<OpenSearchDrawerWithSelectedTabReducerPayload>; + +export type OpenReactionDrawerByIdPayload = number; +export type OpenReactionDrawerByIdAction = PayloadAction<OpenReactionDrawerByIdPayload>; diff --git a/src/redux/drawer/drawerFixture.ts b/src/redux/drawer/drawerFixture.ts index c6c1c62d511a90ce59ca3d41c4a20f57ff58bf7b..d76275c83846ed547c4c7b8b266fa9c7ee90555f 100644 --- a/src/redux/drawer/drawerFixture.ts +++ b/src/redux/drawer/drawerFixture.ts @@ -10,6 +10,7 @@ export const initialStateFixture: DrawerState = { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }; export const openedDrawerSubmapsFixture: DrawerState = { @@ -22,6 +23,7 @@ export const openedDrawerSubmapsFixture: DrawerState = { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }; export const drawerSearchStepOneFixture: DrawerState = { @@ -34,6 +36,7 @@ export const drawerSearchStepOneFixture: DrawerState = { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }; export const drawerSearchDrugsStepTwoFixture: DrawerState = { @@ -46,6 +49,7 @@ export const drawerSearchDrugsStepTwoFixture: DrawerState = { listOfBioEnitites: [], selectedSearchElement: '', }, + reactionDrawerState: {}, }; export const drawerSearchChemicalsStepTwoFixture: DrawerState = { diff --git a/src/redux/reactions/reactions.selector.ts b/src/redux/reactions/reactions.selector.ts index 1f907b39550b23aacbb831687631e8562eec6a50..8dbc69904a3a2737dbbf9159989b43a3c4f2c2e0 100644 --- a/src/redux/reactions/reactions.selector.ts +++ b/src/redux/reactions/reactions.selector.ts @@ -1,7 +1,10 @@ import { Reaction } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; +import { currentDrawerReactionIdSelector } from '../drawer/drawer.selectors'; import { currentModelIdSelector } from '../models/models.selectors'; import { rootSelector } from '../root/root.selectors'; +import { getReferencesWithoutEmptyLink } from './utils/getFilteredReferences'; +import { getReferencesGroupedBySource } from './utils/getGroupedReferences'; export const reactionsSelector = createSelector(rootSelector, state => state.reactions); @@ -17,3 +20,18 @@ export const allReactionsSelectorOfCurrentMap = createSelector( return reactions.filter(({ modelId }) => modelId === currentModelId); }, ); + +export const currentDrawerReactionSelector = createSelector( + reactionsDataSelector, + currentDrawerReactionIdSelector, + (reactions, currentDrawerReactionId) => + reactions.find(({ id }) => id === currentDrawerReactionId), +); + +export const currentDrawerReactionGroupedReferencesSelector = createSelector( + currentDrawerReactionSelector, + reaction => { + const referencesFiltered = getReferencesWithoutEmptyLink(reaction); + return getReferencesGroupedBySource(referencesFiltered); + }, +); diff --git a/src/redux/reactions/utils/getFilteredReferences.test.ts b/src/redux/reactions/utils/getFilteredReferences.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b02b2cc3dc556abcfc96ad22e59e6563751dfef7 --- /dev/null +++ b/src/redux/reactions/utils/getFilteredReferences.test.ts @@ -0,0 +1,34 @@ +import { + REFERENCES_MOCK_ALL_INVALID, + REFERENCES_MOCK_ALL_VALID, +} from '@/models/mocks/referencesMock'; +import { Reaction } from '@/types/models'; +import { ReferenceFiltered } from '@/types/reference'; +import { getReferencesWithoutEmptyLink } from './getFilteredReferences'; + +describe('getFilteredReferences - subUtil', () => { + const cases: [Pick<Reaction, 'references'>, ReferenceFiltered[]][] = [ + [ + { + references: REFERENCES_MOCK_ALL_VALID, + }, + REFERENCES_MOCK_ALL_VALID as ReferenceFiltered[], + ], + [ + { + references: REFERENCES_MOCK_ALL_INVALID, + }, + [], + ], + [ + { + references: [...REFERENCES_MOCK_ALL_VALID, ...REFERENCES_MOCK_ALL_INVALID], + }, + REFERENCES_MOCK_ALL_VALID as ReferenceFiltered[], + ], + ]; + + it.each(cases)('should return valid filtered references', (reaction, result) => { + expect(getReferencesWithoutEmptyLink(reaction)).toStrictEqual(result); + }); +}); diff --git a/src/redux/reactions/utils/getFilteredReferences.ts b/src/redux/reactions/utils/getFilteredReferences.ts new file mode 100644 index 0000000000000000000000000000000000000000..987eb1a0637a0b901dcb1a8ea13df76196190ef2 --- /dev/null +++ b/src/redux/reactions/utils/getFilteredReferences.ts @@ -0,0 +1,11 @@ +import { Reaction } from '@/types/models'; +import { ReferenceFiltered } from '@/types/reference'; + +type InputReaction = Pick<Reaction, 'references'>; + +export const getReferencesWithoutEmptyLink = ( + reaction: InputReaction | undefined, +): ReferenceFiltered[] => + (reaction?.references || []).filter( + (ref): ref is ReferenceFiltered => ref.link !== null && ref.link !== undefined, + ); diff --git a/src/redux/reactions/utils/getGroupedReferences.test.ts b/src/redux/reactions/utils/getGroupedReferences.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..65359741f252355813570b0072b15b268f3a28f8 --- /dev/null +++ b/src/redux/reactions/utils/getGroupedReferences.test.ts @@ -0,0 +1,89 @@ +import { ReferenceFiltered } from '@/types/reference'; +import { getReferencesGroupedBySource } from './getGroupedReferences'; + +describe('getGroupedReferences - util', () => { + const singleReference = { + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + article: { + title: + 'The nutrient-responsive transcription factor TFE3 promotes autophagy, lysosomal biogenesis, and clearance of cellular debris.', + authors: [ + 'Martina JA', + ' Diab HI', + ' Lishu L', + ' Jeong-A L', + ' Patange S', + ' Raben N', + ' Puertollano R.', + ], + journal: 'Science signaling', + year: 2014, + link: 'https://www.ncbi.nlm.nih.gov/pubmed/24448649', + pubmedId: '24448649', + citationCount: 321, + }, + type: 'PUBMED', + resource: '24448649', + id: 154973, + annotatorClassName: '', + }; + + const cases = [ + [[], []], + [ + [singleReference], + [ + { + source: '', + references: [singleReference], + }, + ], + ], + [ + [ + { + ...singleReference, + annotatorClassName: 'source1', + }, + { + ...singleReference, + annotatorClassName: 'source1', + }, + { + ...singleReference, + annotatorClassName: 'source2', + }, + ], + [ + { + source: 'source1', + references: [ + { + ...singleReference, + annotatorClassName: 'source1', + }, + { + ...singleReference, + annotatorClassName: 'source1', + }, + ], + }, + { + source: 'source2', + references: [ + { + ...singleReference, + annotatorClassName: 'source2', + }, + ], + }, + ], + ], + ]; + + it.each(cases)('should return correct grouped references', (references, referencesGrouped) => + expect(getReferencesGroupedBySource(references as ReferenceFiltered[])).toMatchObject( + referencesGrouped, + ), + ); +}); diff --git a/src/redux/reactions/utils/getGroupedReferences.ts b/src/redux/reactions/utils/getGroupedReferences.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca09f0a93e4357e4a594c055a7d27ce38f58718c --- /dev/null +++ b/src/redux/reactions/utils/getGroupedReferences.ts @@ -0,0 +1,11 @@ +import { ReferenceFiltered, ReferenceGrouped } from '@/types/reference'; +import { groupBy } from '@/utils/array/groupBy'; + +export const getReferencesGroupedBySource = (references: ReferenceFiltered[]): ReferenceGrouped => { + const referencesGroupedObject = groupBy(references, ref => ref.annotatorClassName); + + return Object.entries(referencesGroupedObject).map(([source, refs]) => ({ + source, + references: refs, + })); +}; diff --git a/src/shared/DrawerHeading/DrawerHeading.component.tsx b/src/shared/DrawerHeading/DrawerHeading.component.tsx index 650c76f3941101f182cd3e941e51328134a3da6d..17f616ad625f8a70e781cf037e3a2ddb38d2cd7e 100644 --- a/src/shared/DrawerHeading/DrawerHeading.component.tsx +++ b/src/shared/DrawerHeading/DrawerHeading.component.tsx @@ -1,10 +1,10 @@ +import { closeDrawer } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { IconButton } from '@/shared/IconButton'; -import { closeDrawer } from '@/redux/drawer/drawer.slice'; import { CLOSE_BUTTON_ROLE } from '../DrawerHeadingBackwardButton/DrawerHeadingBackwardButton.constants'; interface DrawerHeadingProps { - title: string; + title: string | React.ReactNode; } export const DrawerHeading = ({ title }: DrawerHeadingProps): JSX.Element => { diff --git a/src/types/drawerName.ts b/src/types/drawerName.ts index 3dcb4bdce5c8ce8fbf6c6129956eaa23d55ab0f4..2f7938506e57bacf7160c83abce8d4886aeba945 100644 --- a/src/types/drawerName.ts +++ b/src/types/drawerName.ts @@ -5,4 +5,5 @@ export type DrawerName = | 'plugins' | 'export' | 'legend' - | 'submaps'; + | 'submaps' + | 'reaction'; diff --git a/src/types/models.ts b/src/types/models.ts index 27b6246e82c88f5b95e699fb154a5d3292bf3633..5b99701639f8f1a0cc8c6a728e8343117bbba951 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -14,6 +14,7 @@ import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/project'; import { reactionSchema } from '@/models/reaction'; import { reactionLineSchema } from '@/models/reactionLineSchema'; +import { referenceSchema } from '@/models/referenceSchema'; import { targetSchema } from '@/models/targetSchema'; import { z } from 'zod'; @@ -32,6 +33,7 @@ export type BioEntityContent = z.infer<typeof bioEntityContentSchema>; export type BioEntityResponse = z.infer<typeof bioEntityResponseSchema>; export type Chemical = z.infer<typeof chemicalSchema>; export type Reaction = z.infer<typeof reactionSchema>; +export type Reference = z.infer<typeof referenceSchema>; export type ReactionLine = z.infer<typeof reactionLineSchema>; export type ElementSearchResult = z.infer<typeof elementSearchResult>; export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; diff --git a/src/types/reference.ts b/src/types/reference.ts new file mode 100644 index 0000000000000000000000000000000000000000..e7201433a7a6ea9d609b35a094c4a6e0b3eea309 --- /dev/null +++ b/src/types/reference.ts @@ -0,0 +1,10 @@ +import { Reference } from './models'; + +export type ReferenceFiltered = Omit<Reference, 'link'> & { link: string }; + +export type ReferenceGroup = { + references: ReferenceFiltered[]; + source: string; +}; + +export type ReferenceGrouped = ReferenceGroup[]; diff --git a/src/utils/array/groupBy.test.ts b/src/utils/array/groupBy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..32bbc32a61821ddccb5b74b2e00d5316bb9c2dfe --- /dev/null +++ b/src/utils/array/groupBy.test.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { groupBy } from './groupBy'; + +interface InputObject { + title: string; + value: number; +} + +describe('groupBy - util', () => { + const cases: [InputObject[], (value: InputObject) => string, { [key: string]: InputObject[] }][] = + [ + [ + [ + { + title: 'GROUP_1', + value: 1, + }, + { + title: 'GROUP_1', + value: 2, + }, + { + title: 'GROUP_1', + value: 3, + }, + { + title: 'GROUP_2', + value: 1, + }, + { + title: 'GROUP_3', + value: 1, + }, + ], + (obj): string => obj.title, + { + GROUP_1: [ + { title: 'GROUP_1', value: 1 }, + { title: 'GROUP_1', value: 2 }, + { title: 'GROUP_1', value: 3 }, + ], + GROUP_2: [{ title: 'GROUP_2', value: 1 }], + GROUP_3: [{ title: 'GROUP_3', value: 1 }], + }, + ], + [ + [ + { + title: '1', + value: 1, + }, + { + title: '1', + value: 2, + }, + { + title: '1', + value: 3, + }, + { + title: '2', + value: 1, + }, + { + title: '3', + value: 1, + }, + ], + (obj): string => obj.value.toString(), + { + '1': [ + { title: '1', value: 1 }, + { title: '2', value: 1 }, + { title: '3', value: 1 }, + ], + '2': [{ title: '1', value: 2 }], + '3': [{ title: '1', value: 3 }], + }, + ], + ]; + + it.each(cases)('should return valid data basing on predicate', (input, predicate, output) => { + expect(groupBy(input as any[], predicate)).toStrictEqual(output); + }); +}); diff --git a/src/utils/array/groupBy.ts b/src/utils/array/groupBy.ts new file mode 100644 index 0000000000000000000000000000000000000000..4fe4d030639d4a5b02233518efbab87db527e2af --- /dev/null +++ b/src/utils/array/groupBy.ts @@ -0,0 +1,12 @@ +/* prettier-ignore */ +export const groupBy = <T>( + array: T[], + predicate: (value: T, index: number, arr: T[]) => string, +): { [key: string]: T[] } => + array.reduce( + (acc, value, index, arr) => { + (acc[predicate(value, index, arr)] ||= []).push(value); + return acc; + }, + {} as { [key: string]: T[] }, + );