From bd8ec0598b0a057721b9c38a4fdedbf05828d06c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mi=C5=82osz=20Grocholewski?= <m.grocholewski@atcomp.pl>
Date: Thu, 12 Dec 2024 08:50:56 +0100
Subject: [PATCH] bugfix(map): close context menu on click outside the menu

---
 .../ContextMenu/ContextMenu.component.tsx     | 95 ++++++++++---------
 .../OutsideClickWrapper.component.test.tsx    | 40 ++++++++
 .../OutsideClickWrapper.component.tsx         | 29 ++++++
 src/shared/OutsideClickWrapper/index.tsx      |  1 +
 4 files changed, 121 insertions(+), 44 deletions(-)
 create mode 100644 src/shared/OutsideClickWrapper/OutsideClickWrapper.component.test.tsx
 create mode 100644 src/shared/OutsideClickWrapper/OutsideClickWrapper.component.tsx
 create mode 100644 src/shared/OutsideClickWrapper/index.tsx

diff --git a/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx b/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx
index 973785e6..5e928260 100644
--- a/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx
+++ b/src/components/FunctionalArea/ContextMenu/ContextMenu.component.tsx
@@ -14,6 +14,7 @@ import { ClickCoordinates } from '@/services/pluginsManager/pluginContextMenu/pl
 import { currentModelSelector } from '@/redux/models/models.selectors';
 import { mapDataLastPositionSelector } from '@/redux/map/map.selectors';
 import { DEFAULT_ZOOM } from '@/constants/map';
+import { OutsideClickWrapper } from '@/shared/OutsideClickWrapper';
 
 export const ContextMenu = (): React.ReactNode => {
   const pluginContextMenu = PluginsContextMenu.menuItems;
@@ -29,15 +30,19 @@ export const ContextMenu = (): React.ReactNode => {
     return isUnitProtIdAvailable() ? unitProtId : 'no UnitProt ID available';
   };
 
+  const closeContextMenuFunction = (): void => {
+    dispatch(closeContextMenu());
+  };
+
   const handleOpenMolArtClick = (): void => {
     if (isUnitProtIdAvailable()) {
-      dispatch(closeContextMenu());
+      closeContextMenuFunction();
       dispatch(openMolArtModalById(unitProtId));
     }
   };
 
   const handleAddCommentClick = (): void => {
-    dispatch(closeContextMenu());
+    closeContextMenuFunction();
     dispatch(openAddCommentModal());
   };
 
@@ -47,7 +52,7 @@ export const ContextMenu = (): React.ReactNode => {
     callback: (coordinates: ClickCoordinates, element: BioEntity | NewReaction | undefined) => void,
   ) => {
     return () => {
-      dispatch(closeContextMenu());
+      closeContextMenuFunction();
       return callback(
         {
           modelId,
@@ -61,55 +66,57 @@ export const ContextMenu = (): React.ReactNode => {
   };
 
   return (
-    <div
-      className={twMerge(
-        'absolute z-10 rounded-lg border border-[#DBD9D9] bg-white p-4',
-        isOpen ? '' : 'hidden',
-      )}
-      style={{
-        left: `${coordinates[FIRST_ARRAY_ELEMENT]}px`,
-        top: `${coordinates[SECOND_ARRAY_ELEMENT]}px`,
-      }}
-      data-testid="context-modal"
-    >
-      <button
+    <OutsideClickWrapper onOutsideClick={closeContextMenuFunction}>
+      <div
         className={twMerge(
-          'w-full cursor-pointer text-left text-xs font-normal',
-          !isUnitProtIdAvailable() ? 'cursor-not-allowed text-greyscale-700' : '',
+          'absolute z-10 rounded-lg border border-[#DBD9D9] bg-white p-4',
+          isOpen ? '' : 'hidden',
         )}
-        onClick={handleOpenMolArtClick}
-        type="button"
-        data-testid="open-molart"
-      >
-        Open MolArt ({getUnitProtId()})
-      </button>
-      <hr />
-      <button
-        className={twMerge('w-full cursor-pointer text-left text-xs font-normal')}
-        onClick={handleAddCommentClick}
-        type="button"
-        data-testid="add-comment"
+        style={{
+          left: `${coordinates[FIRST_ARRAY_ELEMENT]}px`,
+          top: `${coordinates[SECOND_ARRAY_ELEMENT]}px`,
+        }}
+        data-testid="context-modal"
       >
-        Add comment
-      </button>
-      {pluginContextMenu.length && <hr />}
-
-      {pluginContextMenu.map(contextMenuEntry => (
         <button
-          key={contextMenuEntry.id}
-          id={contextMenuEntry.id}
           className={twMerge(
-            'cursor-pointer text-xs font-normal',
-            contextMenuEntry.style,
-            !contextMenuEntry.enabled ? 'cursor-not-allowed text-greyscale-700' : '',
+            'w-full cursor-pointer text-left text-xs font-normal',
+            !isUnitProtIdAvailable() ? 'cursor-not-allowed text-greyscale-700' : '',
           )}
-          onClick={handleCallback(contextMenuEntry.callback)}
+          onClick={handleOpenMolArtClick}
           type="button"
-          data-testid={contextMenuEntry.id}
+          data-testid="open-molart"
         >
-          {contextMenuEntry.name}
+          Open MolArt ({getUnitProtId()})
         </button>
-      ))}
-    </div>
+        <hr />
+        <button
+          className={twMerge('w-full cursor-pointer text-left text-xs font-normal')}
+          onClick={handleAddCommentClick}
+          type="button"
+          data-testid="add-comment"
+        >
+          Add comment
+        </button>
+        {pluginContextMenu.length && <hr />}
+
+        {pluginContextMenu.map(contextMenuEntry => (
+          <button
+            key={contextMenuEntry.id}
+            id={contextMenuEntry.id}
+            className={twMerge(
+              'cursor-pointer text-xs font-normal',
+              contextMenuEntry.style,
+              !contextMenuEntry.enabled ? 'cursor-not-allowed text-greyscale-700' : '',
+            )}
+            onClick={handleCallback(contextMenuEntry.callback)}
+            type="button"
+            data-testid={contextMenuEntry.id}
+          >
+            {contextMenuEntry.name}
+          </button>
+        ))}
+      </div>
+    </OutsideClickWrapper>
   );
 };
diff --git a/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.test.tsx b/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.test.tsx
new file mode 100644
index 00000000..9070b6bb
--- /dev/null
+++ b/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.test.tsx
@@ -0,0 +1,40 @@
+/* eslint-disable no-magic-numbers */
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react';
+import { OutsideClickWrapper } from '.';
+
+describe('OutsideClickWrapper', () => {
+  it('should call onOutsideClick when click outside the component', () => {
+    const handleOutsideClick = jest.fn();
+    const { getByText } = render(
+      <OutsideClickWrapper onOutsideClick={handleOutsideClick}>
+        <div>Inner element</div>
+      </OutsideClickWrapper>,
+    );
+
+    const innerElement = getByText('Inner element');
+
+    fireEvent.mouseDown(document.body);
+
+    expect(handleOutsideClick).toHaveBeenCalledTimes(1);
+
+    fireEvent.mouseDown(innerElement);
+
+    expect(handleOutsideClick).toHaveBeenCalledTimes(1);
+  });
+
+  it('should not call onOutsideClick when click inside the component', () => {
+    const handleOutsideClick = jest.fn();
+    const { getByText } = render(
+      <OutsideClickWrapper onOutsideClick={handleOutsideClick}>
+        <div>Inner element</div>
+      </OutsideClickWrapper>,
+    );
+
+    const innerElement = getByText('Inner element');
+
+    fireEvent.mouseDown(innerElement);
+
+    expect(handleOutsideClick).not.toHaveBeenCalled();
+  });
+});
diff --git a/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.tsx b/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.tsx
new file mode 100644
index 00000000..48c15e1f
--- /dev/null
+++ b/src/shared/OutsideClickWrapper/OutsideClickWrapper.component.tsx
@@ -0,0 +1,29 @@
+import React, { useEffect, useRef, ReactNode } from 'react';
+
+interface OutsideClickHandlerProps {
+  onOutsideClick: () => void;
+  children: ReactNode;
+}
+
+export const OutsideClickWrapper: React.FC<OutsideClickHandlerProps> = ({
+  onOutsideClick,
+  children,
+}) => {
+  const ref = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    const handleClickOutside = (event: MouseEvent): void => {
+      if (ref.current && !ref.current.contains(event.target as Node)) {
+        onOutsideClick();
+      }
+    };
+
+    document.addEventListener('mousedown', handleClickOutside);
+
+    return () => {
+      document.removeEventListener('mousedown', handleClickOutside);
+    };
+  }, [onOutsideClick]);
+
+  return <div ref={ref}>{children}</div>;
+};
diff --git a/src/shared/OutsideClickWrapper/index.tsx b/src/shared/OutsideClickWrapper/index.tsx
new file mode 100644
index 00000000..1535d4cf
--- /dev/null
+++ b/src/shared/OutsideClickWrapper/index.tsx
@@ -0,0 +1 @@
+export { OutsideClickWrapper } from './OutsideClickWrapper.component';
-- 
GitLab