Lucas/t 352 i dont want another input cell to open when i click outside (#163)

* Added logic to handle global edit mode

* Added recoil global edit mode state into generic editable components

* Fix lint

* Added tests
This commit is contained in:
Lucas Bordeau
2023-05-31 16:33:11 +02:00
committed by GitHub
parent c61beb1066
commit 723ea462e8
10 changed files with 294 additions and 107 deletions

1
front/.gitignore vendored
View File

@ -7,6 +7,7 @@
# testing
/coverage
storybook-static
# production
/build

View File

@ -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 (
<CellBaseContainer
ref={wrapperRef}
onClick={() => {
onInsideClick && onInsideClick();
}}
>
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<CellEditModeContainer
ref={editableContainerRef}
<EditableCellEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</CellEditModeContainer>
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
/>
) : (
<CellNormalModeContainer>{nonEditModeContent}</CellNormalModeContainer>
)}

View File

@ -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 (
<CellEditModeContainer
data-testid="editable-cell-edit-mode-container"
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</CellEditModeContainer>
);
}

View File

@ -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 (
<CellBaseContainer
ref={wrapperRef}
onClick={() => {
onInsideClick && onInsideClick();
}}
>
<CellBaseContainer onClick={handleOnClick}>
<EditableCellMenuNormalModeContainer>
{nonEditModeContent}
</EditableCellMenuNormalModeContainer>
{isEditMode && (
<EditableCellMenuEditModeContainer
ref={editableContainerRef}
<EditableCellMenuEditMode
editModeContent={editModeContent}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</EditableCellMenuEditModeContainer>
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
onInsideClick={onInsideClick}
/>
)}
</CellBaseContainer>
);

View File

@ -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 (
<EditableCellMenuEditModeContainer
ref={wrapperRef}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
>
{editModeContent}
</EditableCellMenuEditModeContainer>
);
}

View File

@ -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<RelationType, ChipComponentPropsType>) {
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<
</StyledEditModeSelectedContainer>
<StyledEditModeSearchContainer>
<StyledEditModeSearchInput
autoFocus
placeholder={searchPlaceholder}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setFilterSearch(searchConfig);
@ -183,7 +194,7 @@ function EditableRelation<
key={index}
onClick={() => {
onChange(result.value);
setIsEditMode(false);
closeEditMode();
}}
>
<HoverableMenuItem>

View File

@ -3,10 +3,10 @@ import { isDefined } from '../../../utils/type-guards/isDefined';
export function useListenClickOutsideArrayOfRef<T extends HTMLElement>(
arrayOfRef: Array<React.RefObject<T>>,
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<T extends HTMLElement>(
);
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]);

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const isSomeInputInEditModeState = atom<boolean>({
key: 'ui/table/is-in-edit-mode',
default: false,
});

View File

@ -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<typeof People> = {
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,
},
};

View File

@ -1,6 +1,6 @@
import { GraphqlQueryPerson } from '../../interfaces/entities/person.interface';
export const mockedPeopleData: Array<GraphqlQueryPerson> = [
export const mockedPeopleData = [
{
id: '7dfbc3f7-6e5e-4128-957e-8d86808cdf6b',
__typename: 'Person',
@ -70,4 +70,4 @@ export const mockedPeopleData: Array<GraphqlQueryPerson> = [
city: 'Paris',
},
];
] satisfies Array<GraphqlQueryPerson>;