From fbb8ffcdb72e8cb2e1888310779b54ea91d190b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeusz=20Miesi=C4=85c?= <tadeusz.miesiac@gmail.com> Date: Wed, 6 Dec 2023 15:25:46 +0100 Subject: [PATCH] feat(bioentity:submaplink): allow user to open submap by clicking on submaplink on map --- .../AnnotationItem.component.tsx | 18 ++ .../BioEntityDrawer/AnnotationItem/index.ts | 1 + .../AssociatedSubmap.component.test.tsx | 182 ++++++++++++++++++ .../AssociatedSubmap.component.tsx | 28 +++ .../BioEntityDrawer/AssociatedSubmap/index.ts | 1 + .../BioEntityDrawer.component.test.tsx | 27 +++ .../BioEntityDrawer.component.tsx | 39 ++-- src/constants/common.ts | 2 + src/hooks/useOpenSubmaps.ts | 43 +++++ src/redux/bioEntity/bioEntity.mock.ts | 20 ++ src/redux/bioEntity/bioEntity.selectors.ts | 18 +- 11 files changed, 351 insertions(+), 28 deletions(-) create mode 100644 src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/AnnotationItem.component.tsx create mode 100644 src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/index.ts create mode 100644 src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.test.tsx create mode 100644 src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.tsx create mode 100644 src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/index.ts create mode 100644 src/hooks/useOpenSubmaps.ts diff --git a/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/AnnotationItem.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/AnnotationItem.component.tsx new file mode 100644 index 00000000..0ea1a106 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/AnnotationItem.component.tsx @@ -0,0 +1,18 @@ +import { Icon } from '@/shared/Icon'; +import { Reference } from '@/types/models'; + +type AnnotationItemProps = Pick<Reference, 'link' | 'type' | 'resource'>; + +export const AnnotationItem = ({ link, type, resource }: AnnotationItemProps): JSX.Element => ( + <a className="pl-3 text-sm font-normal" href={link?.toString()} target="_blank"> + <div className="flex justify-between"> + <span> + Source:{' '} + <b className="font-semibold"> + {type} ({resource}) + </b> + </span> + <Icon name="arrow" className="h-6 w-6 fill-font-500" /> + </div> + </a> +); diff --git a/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/index.ts b/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/index.ts new file mode 100644 index 00000000..f52fd5f0 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/AnnotationItem/index.ts @@ -0,0 +1 @@ +export { AnnotationItem } from './AnnotationItem.component'; diff --git a/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.test.tsx new file mode 100644 index 00000000..0b3e93aa --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.test.tsx @@ -0,0 +1,182 @@ +import { StoreType } from '@/redux/store'; +import { + InitialStoreState, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { act, render, screen } from '@testing-library/react'; +import { + BIOENTITY_INITIAL_STATE_MOCK, + BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, +} from '@/redux/bioEntity/bioEntity.mock'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; +import { MODELS_INITIAL_STATE_MOCK } from '@/redux/models/models.mock'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { + initialMapDataFixture, + openedMapsInitialValueFixture, + openedMapsThreeSubmapsFixture, +} from '@/redux/map/map.fixtures'; +import { SIZE_OF_ARRAY_WITH_ONE_ELEMENT, ZERO } from '@/constants/common'; +import { AssociatedSubmap } from './AssociatedSubmap.component'; + +const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { + const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); + + return ( + render( + <Wrapper> + <AssociatedSubmap /> + </Wrapper>, + ), + { + store, + } + ); +}; + +const MAIN_MAP_ID = 5053; +const HISTAMINE_MAP_ID = 5052; + +describe('AssociatedSubmap - component', () => { + it('should not display component when can not find asociated map model', () => { + renderComponent({ + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: bioEntityContentFixture.bioEntity.id, + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + }, + }); + + expect(screen.queryByTestId('associated-submap')).not.toBeInTheDocument(); + }); + it('should render component when associated map model is found', () => { + renderComponent({ + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: bioEntityContentFixture.bioEntity.id, + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + data: MODELS_MOCK_SHORT, + }, + }); + + expect(screen.getByTestId('associated-submap')).toBeInTheDocument(); + }); + + describe('when map is already opened', () => { + it('should open submap and set it to active on open submap button click', async () => { + const { store } = renderComponent({ + map: { + data: initialMapDataFixture, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsInitialValueFixture, + }, + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: bioEntityContentFixture.bioEntity.id, + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + data: MODELS_MOCK_SHORT, + }, + }); + + const { + data: { modelId }, + openedMaps, + } = store.getState().map; + expect(modelId).toBe(ZERO); + expect(openedMaps).not.toContainEqual({ + modelId: HISTAMINE_MAP_ID, + modelName: 'Histamine signaling', + lastPosition: { x: 0, y: 0, z: 0 }, + }); + + const openSubmapButton = screen.getByRole('button', { name: 'Open submap' }); + await act(() => { + openSubmapButton.click(); + }); + + const { + data: { modelId: newModelId }, + openedMaps: newOpenedMaps, + } = store.getState().map; + + expect(newOpenedMaps).toContainEqual({ + modelId: HISTAMINE_MAP_ID, + modelName: 'Histamine signaling', + lastPosition: { x: 0, y: 0, z: 0 }, + }); + + expect(newModelId).toBe(HISTAMINE_MAP_ID); + }); + + it('should set map active on open submap button click', async () => { + const { store } = renderComponent({ + map: { + data: { + ...initialMapDataFixture, + modelId: MAIN_MAP_ID, + }, + loading: 'succeeded', + error: { name: '', message: '' }, + openedMaps: openedMapsThreeSubmapsFixture, + }, + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: bioEntityContentFixture.bioEntity.id, + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + data: MODELS_MOCK_SHORT, + }, + }); + + const openSubmapButton = screen.getByRole('button', { name: 'Open submap' }); + await act(() => { + openSubmapButton.click(); + }); + + const { + map: { + data: { modelId }, + openedMaps, + }, + } = store.getState(); + + const histamineMap = openedMaps.filter(map => map.modelName === 'Histamine signaling'); + + expect(histamineMap.length).toBe(SIZE_OF_ARRAY_WITH_ONE_ELEMENT); + expect(modelId).toBe(HISTAMINE_MAP_ID); + }); + }); +}); diff --git a/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.tsx new file mode 100644 index 00000000..43047b38 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/AssociatedSubmap.component.tsx @@ -0,0 +1,28 @@ +import { useOpenSubmap } from '@/hooks/useOpenSubmaps'; +import { searchedFromMapBioEntityElementRelatedSubmapSelector } from '@/redux/bioEntity/bioEntity.selectors'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { Button } from '@/shared/Button'; + +export const AssociatedSubmap = (): React.ReactNode => { + const relatedSubmap = useAppSelector(searchedFromMapBioEntityElementRelatedSubmapSelector); + const { openSubmap } = useOpenSubmap({ + modelId: relatedSubmap?.idObject, + modelName: relatedSubmap?.name, + }); + + if (!relatedSubmap) { + return null; + } + + return ( + <div + data-testid="associated-submap" + className="flex flex-row flex-nowrap items-center justify-between" + > + <p>Associated Submap: </p> + <Button className="max-h-8" variantStyles="ghost" onClick={openSubmap}> + Open submap + </Button> + </div> + ); +}; diff --git a/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/index.ts b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/index.ts new file mode 100644 index 00000000..9ab7b2f5 --- /dev/null +++ b/src/components/Map/Drawer/BioEntityDrawer/AssociatedSubmap/index.ts @@ -0,0 +1 @@ +export { AssociatedSubmap } from './AssociatedSubmap.component'; diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.test.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.test.tsx index 64308bbf..dbff8087 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.test.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.test.tsx @@ -11,6 +11,12 @@ import { bioEntityContentFixture, } from '@/models/fixtures/bioEntityContentsFixture'; import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { + BIOENTITY_INITIAL_STATE_MOCK, + BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, +} from '@/redux/bioEntity/bioEntity.mock'; +import { MODELS_MOCK_SHORT } from '@/models/mocks/modelsMock'; +import { MODELS_INITIAL_STATE_MOCK } from '@/redux/models/models.mock'; import { BioEntityDrawer } from './BioEntityDrawer.component'; const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { @@ -177,5 +183,26 @@ describe('BioEntityDrawer - component', () => { screen.getByText(bioEntity.references[0].resource, { exact: false }), ).toBeInTheDocument(); }); + + it('should display associated submaps if bio entity links to submap', () => { + renderComponent({ + bioEntity: { + ...BIOENTITY_INITIAL_STATE_MOCK, + data: BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: bioEntityContentFixture.bioEntity.id, + }, + }, + models: { + ...MODELS_INITIAL_STATE_MOCK, + data: MODELS_MOCK_SHORT, + }, + }); + + expect(screen.getByTestId('associated-submap')).toBeInTheDocument(); + }); }); }); diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 98b0c7bf..8bf424af 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx @@ -1,7 +1,9 @@ import { DrawerHeading } from '@/shared/DrawerHeading'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { searchedFromMapBioEntityElement } from '@/redux/bioEntity/bioEntity.selectors'; -import { Icon } from '@/shared/Icon'; +import { ZERO } from '@/constants/common'; +import { AnnotationItem } from './AnnotationItem'; +import { AssociatedSubmap } from './AssociatedSubmap'; export const BioEntityDrawer = (): React.ReactNode => { const bioEntityData = useAppSelector(searchedFromMapBioEntityElement); @@ -10,6 +12,8 @@ export const BioEntityDrawer = (): React.ReactNode => { return null; } + const isReferenceAvailable = bioEntityData.references.length > ZERO; + return ( <div className="h-full max-h-full" data-testid="bioentity-drawer"> <DrawerHeading @@ -29,27 +33,20 @@ export const BioEntityDrawer = (): React.ReactNode => { Full name: <b className="font-semibold">{bioEntityData.fullName}</b> </div> )} - <h3 className="font-semibold">Annotations:</h3> - {bioEntityData.references.map(reference => { - return ( - <a - className="pl-3 text-sm font-normal" - href={reference.link?.toString()} + <h3 className="font-semibold"> + Annotations:{' '} + {!isReferenceAvailable && <span className="font-normal">No annotations</span>} + </h3> + {isReferenceAvailable && + bioEntityData.references.map(reference => ( + <AnnotationItem key={reference.id} - target="_blank" - > - <div className="flex justify-between"> - <span> - Source:{' '} - <b className="font-semibold"> - {reference?.type} ({reference.resource}) - </b> - </span> - <Icon name="arrow" className="h-6 w-6 fill-font-500" /> - </div> - </a> - ); - })} + type={reference.type} + link={reference.link} + resource={reference.resource} + /> + ))} + <AssociatedSubmap /> </div> </div> ); diff --git a/src/constants/common.ts b/src/constants/common.ts index 973c26af..31d51626 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -1,4 +1,6 @@ export const SIZE_OF_EMPTY_ARRAY = 0; +export const SIZE_OF_ARRAY_WITH_ONE_ELEMENT = 1; + export const ZERO = 0; export const FIRST_ARRAY_ELEMENT = 0; diff --git a/src/hooks/useOpenSubmaps.ts b/src/hooks/useOpenSubmaps.ts new file mode 100644 index 00000000..a0a63304 --- /dev/null +++ b/src/hooks/useOpenSubmaps.ts @@ -0,0 +1,43 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { mapOpenedMapsSelector } from '@/redux/map/map.selectors'; +import { openMapAndSetActive, setActiveMap } from '@/redux/map/map.slice'; +import { modelsDataSelector } from '@/redux/models/models.selectors'; +import { useCallback } from 'react'; + +type UseOpenSubmapProps = { + modelId: number | undefined; + modelName: string | undefined; +}; + +type UseOpenSubmapReturnType = { + openSubmap: () => void; + isItPossibleToOpenMap: boolean; +}; + +export const useOpenSubmap = ({ + modelId, + modelName, +}: UseOpenSubmapProps): UseOpenSubmapReturnType => { + const openedMaps = useAppSelector(mapOpenedMapsSelector); + const models = useAppSelector(modelsDataSelector); + const dispatch = useAppDispatch(); + + const isMapAlreadyOpened = openedMaps.some(map => map.modelId === modelId); + const isMapExist = models.some(model => model.idObject === modelId); + const isItPossibleToOpenMap = modelId && modelName && isMapExist; + + const openSubmap = useCallback(() => { + if (!isItPossibleToOpenMap) { + return; + } + + if (isMapAlreadyOpened) { + dispatch(setActiveMap({ modelId })); + } else { + dispatch(openMapAndSetActive({ modelId, modelName })); + } + }, [dispatch, isItPossibleToOpenMap, isMapAlreadyOpened, modelId, modelName]); + + return { openSubmap, isItPossibleToOpenMap: Boolean(isItPossibleToOpenMap) }; +}; diff --git a/src/redux/bioEntity/bioEntity.mock.ts b/src/redux/bioEntity/bioEntity.mock.ts index c3dcdec0..7c86d068 100644 --- a/src/redux/bioEntity/bioEntity.mock.ts +++ b/src/redux/bioEntity/bioEntity.mock.ts @@ -1,4 +1,7 @@ import { DEFAULT_ERROR } from '@/constants/errors'; +import { BioEntity, BioEntityContent } from '@/types/models'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { MultiSearchData } from '@/types/fetchDataState'; import { BioEntityContentsState } from './bioEntity.types'; export const BIOENTITY_INITIAL_STATE_MOCK: BioEntityContentsState = { @@ -6,3 +9,20 @@ export const BIOENTITY_INITIAL_STATE_MOCK: BioEntityContentsState = { loading: 'idle', error: DEFAULT_ERROR, }; + +export const BIO_ENTITY_LINKING_TO_SUBMAP: BioEntity = { + ...bioEntityContentFixture.bioEntity, + submodel: { + mapId: 5052, + type: 'DONWSTREAM_TARGETS', + }, +}; + +export const BIO_ENTITY_LINKING_TO_SUBMAP_DATA_MOCK: MultiSearchData<BioEntityContent[]>[] = [ + { + data: [{ bioEntity: BIO_ENTITY_LINKING_TO_SUBMAP, perfect: false }], + searchQueryElement: '', + loading: 'succeeded', + error: DEFAULT_ERROR, + }, +]; diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index dfb1c82a..f0c3e426 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -1,7 +1,7 @@ import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { rootSelector } from '@/redux/root/root.selectors'; import { MultiSearchData } from '@/types/fetchDataState'; -import { BioEntity, BioEntityContent } from '@/types/models'; +import { BioEntity, BioEntityContent, MapModel } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; import { currentSearchedBioEntityId, @@ -23,12 +23,16 @@ export const bioEntitiesForSelectedSearchElement = createSelector( export const searchedFromMapBioEntityElement = createSelector( bioEntitiesForSelectedSearchElement, currentSearchedBioEntityId, - (bioEntitiesState, currentBioEntityId): BioEntity | undefined => { - return ( - bioEntitiesState && - bioEntitiesState.data?.find(({ bioEntity }) => bioEntity.id === currentBioEntityId)?.bioEntity - ); - }, + (bioEntitiesState, currentBioEntityId): BioEntity | undefined => + bioEntitiesState && + bioEntitiesState.data?.find(({ bioEntity }) => bioEntity.id === currentBioEntityId)?.bioEntity, +); + +export const searchedFromMapBioEntityElementRelatedSubmapSelector = createSelector( + searchedFromMapBioEntityElement, + modelsDataSelector, + (bioEntity, models): MapModel | undefined => + models.find(({ idObject }) => idObject === bioEntity?.submodel?.mapId), ); export const loadingBioEntityStatusSelector = createSelector( -- GitLab