Skip to content
Snippets Groups Projects
Commit c292b40d authored by Adrian Orłów's avatar Adrian Orłów
Browse files

feat: add reaction drawer

parent 1fe5426b
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!65feat: Add reaction drawer (MIN-140)
Pipeline #82123 passed
Showing
with 274 additions and 36 deletions
......@@ -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>
);
};
import { SECOND } 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];
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
},
);
});
});
import { currentDrawerReactionSelector } from '@/redux/reactions/reactions.selector';
import { DrawerHeading } from '@/shared/DrawerHeading';
import { Icon } from '@/shared/Icon';
import { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { DEFAULT_REFERENCE_SOURCE } from './ReactionDrawer.constants';
import { getFilteredReferences } from './utils/getFilteredReferences';
import { getGroupedReferences } from './utils/getGroupedReferences';
export const ReactionDrawer = (): React.ReactNode => {
const reaction = useSelector(currentDrawerReactionSelector);
const referencesGrouped = useMemo(() => {
const referencesFiltered = getFilteredReferences(reaction);
return getGroupedReferences(referencesFiltered);
}, [reaction]);
if (!reaction) {
return null;
}
return (
<div className="h-full max-h-full">
<DrawerHeading
title={
<>
<span className="font-normal">Reaction:</span>&nbsp;{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(({ source, references }) => (
<>
<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>
))}
</>
))}
</div>
</div>
);
};
export const DEFAULT_REFERENCE_SOURCE = 'Annotated by curator';
import { Reference } from '@/types/models';
export type ReferenceFiltered = Omit<Reference, 'link'> & { link: string };
export type ReferenceGrouped = {
references: ReferenceFiltered[];
source: string;
}[];
export { ReactionDrawer } from './ReactionDrawer.component';
import { Reaction } from '@/types/models';
import { ReferenceFiltered } from '../ReactionDrawer.types';
export const getFilteredReferences = (reaction: Reaction | undefined): ReferenceFiltered[] =>
(reaction?.references || []).filter(
(ref): ref is ReferenceFiltered => ref.link !== null && ref.link !== undefined,
);
import { groupBy } from '@/utils/array/groupBy';
import { ReferenceFiltered, ReferenceGrouped } from '../ReactionDrawer.types';
export const getGroupedReferences = (references: ReferenceFiltered[]): ReferenceGrouped => {
const referencesGroupedObject = groupBy(references, ref => ref.annotatorClassName);
return Object.entries(referencesGroupedObject).map(([source, refs]) => ({
source,
references: refs,
}));
};
......@@ -43,6 +43,7 @@ describe('SearchDrawerWrapper - component', () => {
listOfBioEnitites: [],
selectedSearchElement: '',
},
reactionDrawerState: {},
},
});
......@@ -61,6 +62,7 @@ describe('SearchDrawerWrapper - component', () => {
listOfBioEnitites: [],
selectedSearchElement: '',
},
reactionDrawerState: {},
},
});
......
/* eslint-disable no-magic-numbers */
import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common';
import { FIRST, 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].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');
});
});
import { FIRST, 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];
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,
......
......@@ -3,3 +3,4 @@ export const ZERO = 0;
export const FIRST = 0;
export const ONE = 1;
export const SECOND = 1;
......@@ -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(),
......
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: {},
};
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 () => {
......
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;
};
......@@ -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,
);
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;
......@@ -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>;
......@@ -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,4 +49,5 @@ export const drawerSearchDrugsStepTwoFixture: DrawerState = {
listOfBioEnitites: [],
selectedSearchElement: '',
},
reactionDrawerState: {},
};
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment