diff --git a/front/.gitignore b/front/.gitignore index 4d29575de..cdb9d017d 100644 --- a/front/.gitignore +++ b/front/.gitignore @@ -7,6 +7,7 @@ # testing /coverage +storybook-static # production /build diff --git a/front/src/components/editable-cell/EditableCell.tsx b/front/src/components/editable-cell/EditableCell.tsx index 32afd5872..1f9e6dc79 100644 --- a/front/src/components/editable-cell/EditableCell.tsx +++ b/front/src/components/editable-cell/EditableCell.tsx @@ -1,9 +1,9 @@ -import { ReactElement, useRef } from 'react'; -import { useOutsideAlerter } from '../../hooks/useOutsideAlerter'; -import { useHotkeys } from 'react-hotkeys-hook'; +import { ReactElement } from 'react'; import { CellBaseContainer } from './CellBaseContainer'; -import { CellEditModeContainer } from './CellEditModeContainer'; import { CellNormalModeContainer } from './CellNormalModeContainer'; +import { useRecoilState } from 'recoil'; +import { isSomeInputInEditModeState } from '../../modules/ui/tables/states/isSomeInputInEditModeState'; +import { EditableCellEditMode } from './EditableCellEditMode'; type OwnProps = { editModeContent: ReactElement; @@ -25,58 +25,27 @@ export function EditableCell({ onOutsideClick, onInsideClick, }: OwnProps) { - const wrapperRef = useRef(null); - const editableContainerRef = useRef(null); - - useOutsideAlerter(wrapperRef, () => { - onOutsideClick?.(); - }); - - useHotkeys( - 'esc', - () => { - if (isEditMode) { - onOutsideClick?.(); - } - }, - { - preventDefault: true, - enableOnContentEditable: true, - enableOnFormTags: true, - }, - [isEditMode, onOutsideClick], + const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, ); - useHotkeys( - 'enter', - () => { - if (isEditMode) { - onOutsideClick?.(); - } - }, - { - preventDefault: true, - enableOnContentEditable: true, - enableOnFormTags: true, - }, - [isEditMode, onOutsideClick], - ); + function handleOnClick() { + if (!isSomeInputInEditMode) { + onInsideClick?.(); + setIsSomeInputInEditMode(true); + } + } return ( - { - onInsideClick && onInsideClick(); - }} - > + {isEditMode ? ( - - {editModeContent} - + isEditMode={isEditMode} + onOutsideClick={onOutsideClick} + /> ) : ( {nonEditModeContent} )} diff --git a/front/src/components/editable-cell/EditableCellEditMode.tsx b/front/src/components/editable-cell/EditableCellEditMode.tsx new file mode 100644 index 000000000..e1c3ae5c6 --- /dev/null +++ b/front/src/components/editable-cell/EditableCellEditMode.tsx @@ -0,0 +1,86 @@ +import { ReactElement, useMemo, useRef } from 'react'; +import { CellEditModeContainer } from './CellEditModeContainer'; +import { useRecoilState } from 'recoil'; +import { isSomeInputInEditModeState } from '../../modules/ui/tables/states/isSomeInputInEditModeState'; +import { useListenClickOutsideArrayOfRef } from '../../modules/ui/common/hooks/useListenClickOutsideArrayOfRef'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { debounce } from '../../modules/utils/debounce'; + +type OwnProps = { + editModeContent: ReactElement; + editModeHorizontalAlign?: 'left' | 'right'; + editModeVerticalPosition?: 'over' | 'below'; + isEditMode?: boolean; + onOutsideClick?: () => void; + onInsideClick?: () => void; +}; + +export function EditableCellEditMode({ + editModeHorizontalAlign, + editModeVerticalPosition, + editModeContent, + isEditMode, + onOutsideClick, +}: OwnProps) { + const wrapperRef = useRef(null); + + const [, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, + ); + + const debouncedSetIsSomeInputInEditMode = useMemo(() => { + return debounce(setIsSomeInputInEditMode, 20); + }, [setIsSomeInputInEditMode]); + + useListenClickOutsideArrayOfRef([wrapperRef], () => { + if (isEditMode) { + debouncedSetIsSomeInputInEditMode(false); + onOutsideClick?.(); + } + }); + + useHotkeys( + 'esc', + () => { + if (isEditMode) { + onOutsideClick?.(); + + debouncedSetIsSomeInputInEditMode(false); + } + }, + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode], + ); + + useHotkeys( + 'enter', + () => { + if (isEditMode) { + onOutsideClick?.(); + + debouncedSetIsSomeInputInEditMode(false); + } + }, + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode], + ); + + return ( + + {editModeContent} + + ); +} diff --git a/front/src/components/editable-cell/EditableCellMenu.tsx b/front/src/components/editable-cell/EditableCellMenu.tsx index d1c05b48c..0416ce152 100644 --- a/front/src/components/editable-cell/EditableCellMenu.tsx +++ b/front/src/components/editable-cell/EditableCellMenu.tsx @@ -1,9 +1,9 @@ -import { ReactElement, useRef } from 'react'; -import { useOutsideAlerter } from '../../hooks/useOutsideAlerter'; -import { useHotkeys } from 'react-hotkeys-hook'; +import { ReactElement } from 'react'; import { CellBaseContainer } from './CellBaseContainer'; import styled from '@emotion/styled'; -import { EditableCellMenuEditModeContainer } from './EditableCellMenuEditModeContainer'; +import { useRecoilState } from 'recoil'; +import { isSomeInputInEditModeState } from '../../modules/ui/tables/states/isSomeInputInEditModeState'; +import { EditableCellMenuEditMode } from './EditableCellMenuEditMode'; const EditableCellMenuNormalModeContainer = styled.div` display: flex; @@ -34,61 +34,31 @@ export function EditableCellMenu({ onOutsideClick, onInsideClick, }: OwnProps) { - const wrapperRef = useRef(null); - const editableContainerRef = useRef(null); - - useOutsideAlerter(wrapperRef, () => { - onOutsideClick?.(); - }); - - useHotkeys( - 'esc', - () => { - if (isEditMode) { - onOutsideClick?.(); - } - }, - { - preventDefault: true, - enableOnContentEditable: true, - enableOnFormTags: true, - }, - [isEditMode, onOutsideClick], + const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, ); - useHotkeys( - 'enter', - () => { - if (isEditMode) { - onOutsideClick?.(); - } - }, - { - preventDefault: true, - enableOnContentEditable: true, - enableOnFormTags: true, - }, - [isEditMode, onOutsideClick], - ); + function handleOnClick() { + if (!isSomeInputInEditMode) { + onInsideClick?.(); + setIsSomeInputInEditMode(true); + } + } return ( - { - onInsideClick && onInsideClick(); - }} - > + {nonEditModeContent} {isEditMode && ( - - {editModeContent} - + isEditMode={isEditMode} + onOutsideClick={onOutsideClick} + onInsideClick={onInsideClick} + /> )} ); diff --git a/front/src/components/editable-cell/EditableCellMenuEditMode.tsx b/front/src/components/editable-cell/EditableCellMenuEditMode.tsx new file mode 100644 index 000000000..0612fb0ac --- /dev/null +++ b/front/src/components/editable-cell/EditableCellMenuEditMode.tsx @@ -0,0 +1,85 @@ +import { ReactElement, useMemo, useRef } from 'react'; +import { useRecoilState } from 'recoil'; +import { isSomeInputInEditModeState } from '../../modules/ui/tables/states/isSomeInputInEditModeState'; +import { useListenClickOutsideArrayOfRef } from '../../modules/ui/common/hooks/useListenClickOutsideArrayOfRef'; +import { useHotkeys } from 'react-hotkeys-hook'; +import { debounce } from '../../modules/utils/debounce'; +import { EditableCellMenuEditModeContainer } from './EditableCellMenuEditModeContainer'; + +type OwnProps = { + editModeContent: ReactElement; + editModeHorizontalAlign?: 'left' | 'right'; + editModeVerticalPosition?: 'over' | 'below'; + isEditMode?: boolean; + onOutsideClick?: () => void; + onInsideClick?: () => void; +}; + +export function EditableCellMenuEditMode({ + editModeHorizontalAlign, + editModeVerticalPosition, + editModeContent, + isEditMode, + onOutsideClick, +}: OwnProps) { + const wrapperRef = useRef(null); + + const [, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, + ); + + const debouncedSetIsSomeInputInEditMode = useMemo(() => { + return debounce(setIsSomeInputInEditMode, 20); + }, [setIsSomeInputInEditMode]); + + useListenClickOutsideArrayOfRef([wrapperRef], () => { + if (isEditMode) { + debouncedSetIsSomeInputInEditMode(false); + onOutsideClick?.(); + } + }); + + useHotkeys( + 'esc', + () => { + if (isEditMode) { + onOutsideClick?.(); + + debouncedSetIsSomeInputInEditMode(false); + } + }, + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode], + ); + + useHotkeys( + 'enter', + () => { + if (isEditMode) { + onOutsideClick?.(); + + debouncedSetIsSomeInputInEditMode(false); + } + }, + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + [isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode], + ); + + return ( + + {editModeContent} + + ); +} diff --git a/front/src/components/editable-cell/EditableRelation.tsx b/front/src/components/editable-cell/EditableRelation.tsx index 3a35bfcd3..94bd45323 100644 --- a/front/src/components/editable-cell/EditableRelation.tsx +++ b/front/src/components/editable-cell/EditableRelation.tsx @@ -10,6 +10,8 @@ import { FaPlus } from 'react-icons/fa'; import { HoverableMenuItem } from './HoverableMenuItem'; import { EditableCellMenu } from './EditableCellMenu'; import { CellNormalModeContainer } from './CellNormalModeContainer'; +import { useRecoilState } from 'recoil'; +import { isSomeInputInEditModeState } from '../../modules/ui/tables/states/isSomeInputInEditModeState'; const StyledEditModeContainer = styled.div` width: 200px; @@ -112,6 +114,9 @@ function EditableRelation< onCreate, }: EditableRelationProps) { const [isEditMode, setIsEditMode] = useState(false); + const [, setIsSomeInputInEditMode] = useRecoilState( + isSomeInputInEditModeState, + ); // TODO: Tie this to a react context const [filterSearchResults, setSearchInput, setFilterSearch, searchInput] = @@ -130,7 +135,12 @@ function EditableRelation< function handleCreateNewRelationButtonClick() { onCreate?.(searchInput); + closeEditMode(); + } + + function closeEditMode() { setIsEditMode(false); + setIsSomeInputInEditMode(false); } return ( @@ -155,6 +165,7 @@ function EditableRelation< ) => { setFilterSearch(searchConfig); @@ -183,7 +194,7 @@ function EditableRelation< key={index} onClick={() => { onChange(result.value); - setIsEditMode(false); + closeEditMode(); }} > diff --git a/front/src/modules/ui/common/hooks/useListenClickOutsideArrayOfRef.ts b/front/src/modules/ui/common/hooks/useListenClickOutsideArrayOfRef.ts index b902bd5fe..a32178cc2 100644 --- a/front/src/modules/ui/common/hooks/useListenClickOutsideArrayOfRef.ts +++ b/front/src/modules/ui/common/hooks/useListenClickOutsideArrayOfRef.ts @@ -3,10 +3,10 @@ import { isDefined } from '../../../utils/type-guards/isDefined'; export function useListenClickOutsideArrayOfRef( arrayOfRef: Array>, - outsideClickCallback: (event?: MouseEvent) => void, + outsideClickCallback: (event?: MouseEvent | TouchEvent) => void, ) { useEffect(() => { - function handleClickOutside(event: any) { + function handleClickOutside(event: MouseEvent | TouchEvent) { const clickedOnAtLeastOneRef = arrayOfRef .filter((ref) => !!ref.current) .some((ref) => ref.current?.contains(event.target as Node)); @@ -21,13 +21,13 @@ export function useListenClickOutsideArrayOfRef( ); if (hasAtLeastOneRefDefined) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchstart', handleClickOutside); + document.addEventListener('mouseup', handleClickOutside); + document.addEventListener('touchend', handleClickOutside); } return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('touchstart', handleClickOutside); + document.removeEventListener('mouseup', handleClickOutside); + document.removeEventListener('touchend', handleClickOutside); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [arrayOfRef, outsideClickCallback]); diff --git a/front/src/modules/ui/tables/states/isSomeInputInEditModeState.ts b/front/src/modules/ui/tables/states/isSomeInputInEditModeState.ts new file mode 100644 index 000000000..6affc85cb --- /dev/null +++ b/front/src/modules/ui/tables/states/isSomeInputInEditModeState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const isSomeInputInEditModeState = atom({ + key: 'ui/table/is-in-edit-mode', + default: false, +}); diff --git a/front/src/pages/people/__stories__/People.inputs.stories.tsx b/front/src/pages/people/__stories__/People.inputs.stories.tsx new file mode 100644 index 000000000..b64429c28 --- /dev/null +++ b/front/src/pages/people/__stories__/People.inputs.stories.tsx @@ -0,0 +1,59 @@ +import { expect } from '@storybook/jest'; +import type { Meta } from '@storybook/react'; +import { userEvent, within } from '@storybook/testing-library'; + +import People from '../People'; +import { Story } from './People.stories'; +import { mocks, render } from './shared'; +import { mockedPeopleData } from '../../../testing/mock-data/people'; + +const meta: Meta = { + title: 'Pages/People', + component: People, +}; + +export default meta; + +export const ChangeEmail: Story = { + render, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const firstRowEmailCell = await canvas.findByText( + mockedPeopleData[0].email, + ); + + const secondRowEmailCell = await canvas.findByText( + mockedPeopleData[1].email, + ); + + expect( + canvas.queryByTestId('editable-cell-edit-mode-container'), + ).toBeNull(); + + await userEvent.click(firstRowEmailCell); + + expect( + canvas.queryByTestId('editable-cell-edit-mode-container'), + ).toBeInTheDocument(); + + await userEvent.click(secondRowEmailCell); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect( + canvas.queryByTestId('editable-cell-edit-mode-container'), + ).toBeNull(); + + await userEvent.click(secondRowEmailCell); + + await new Promise((resolve) => setTimeout(resolve, 25)); + + expect( + canvas.queryByTestId('editable-cell-edit-mode-container'), + ).toBeInTheDocument(); + }, + parameters: { + msw: mocks, + }, +}; diff --git a/front/src/testing/mock-data/people.ts b/front/src/testing/mock-data/people.ts index 4e9b591e5..b4f310e2e 100644 --- a/front/src/testing/mock-data/people.ts +++ b/front/src/testing/mock-data/people.ts @@ -1,6 +1,6 @@ import { GraphqlQueryPerson } from '../../interfaces/entities/person.interface'; -export const mockedPeopleData: Array = [ +export const mockedPeopleData = [ { id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b', __typename: 'Person', @@ -70,4 +70,4 @@ export const mockedPeopleData: Array = [ city: 'Paris', }, -]; +] satisfies Array;