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