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