Add tab hotkey on table page (#457)

* wip

* wip

* - Added scopes on useHotkeys
- Use new EditableCellV2
- Implemented Recoil Scoped State with specific context
- Implemented soft focus position
- Factorized open/close editable cell
- Removed editable relation old components
- Broke down entity table into multiple components
- Added Recoil Scope by CellContext
- Added Recoil Scope by RowContext

* First working version

* Use a new EditableCellSoftFocusMode

* Fixed initialize soft focus

* Fixed enter mode

* Added TODO

* Fix

* Fixes

* Fix tests

* Fix lint

* Fixes

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Charles Bochet
2023-06-28 14:06:44 +02:00
committed by GitHub
parent a6b2fd75ba
commit aa612b5fc9
58 changed files with 958 additions and 332 deletions

View File

@ -1,11 +1,15 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useEditableCell } from './hooks/useCloseEditableCell';
import { useIsSoftFocusOnCurrentCell } from './hooks/useIsSoftFocusOnCurrentCell';
import { useSetSoftFocusOnCurrentCell } from './hooks/useSetSoftFocusOnCurrentCell';
import { isEditModeScopedState } from './states/isEditModeScopedState';
import { EditableCellDisplayMode } from './EditableCellDisplayMode';
import { EditableCellEditMode } from './EditableCellEditMode';
import { EditableCellSoftFocusMode } from './EditableCellSoftFocusMode';
export const CellBaseContainer = styled.div`
align-items: center;
@ -23,43 +27,48 @@ type OwnProps = {
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
isCreateMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCell({
editModeContent,
nonEditModeContent,
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
isEditMode = false,
onOutsideClick,
onInsideClick,
editModeContent,
nonEditModeContent,
}: OwnProps) {
const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const [isEditMode] = useRecoilScopedState(isEditModeScopedState);
const setSoftFocusOnCurrentCell = useSetSoftFocusOnCurrentCell();
const { closeEditableCell, openEditableCell } = useEditableCell();
// TODO: we might have silent problematic behavior because of the setTimeout in openEditableCell, investigate
// Maybe we could build a switchEditableCell to handle the case where we go from one cell to another.
// See https://github.com/twentyhq/twenty/issues/446
function handleOnClick() {
if (!isSomeInputInEditMode) {
onInsideClick?.();
setIsSomeInputInEditMode(true);
}
openEditableCell();
setSoftFocusOnCurrentCell();
}
function handleOnOutsideClick() {
closeEditableCell();
}
const hasSoftFocus = useIsSoftFocusOnCurrentCell();
return (
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<EditableCellEditMode
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={onOutsideClick}
onOutsideClick={handleOnOutsideClick}
>
{editModeContent}
</EditableCellEditMode>
) : hasSoftFocus ? (
<EditableCellSoftFocusMode>
{nonEditModeContent}
</EditableCellSoftFocusMode>
) : (
<EditableCellDisplayMode>{nonEditModeContent}</EditableCellDisplayMode>
)}

View File

@ -1,7 +1,12 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
export const EditableCellNormalModeOuterContainer = styled.div`
import { useIsSoftFocusOnCurrentCell } from './hooks/useIsSoftFocusOnCurrentCell';
type Props = {
softFocus: boolean;
};
export const EditableCellNormalModeOuterContainer = styled.div<Props>`
align-items: center;
display: flex;
height: 100%;
@ -11,17 +16,12 @@ export const EditableCellNormalModeOuterContainer = styled.div`
padding-right: ${({ theme }) => theme.spacing(1)};
width: 100%;
&:hover {
-moz-box-shadow: inset 0 0 0 1px
${({ theme }) => theme.font.color.extraLight};
-webkit-box-shadow: inset 0 0 0 1px
${({ theme }) => theme.font.color.extraLight};
background: ${({ theme }) => theme.background.transparent.secondary};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: inset 0 0 0 1px ${({ theme }) => theme.font.color.extraLight};
}
${(props) =>
props.softFocus
? `background: ${props.theme.background.transparent.secondary};
border-radius: ${props.theme.border.radius.md};
box-shadow: inset 0 0 0 1px ${props.theme.grayScale.gray30};`
: ''}
`;
export const EditableCellNormalModeInnerContainer = styled.div`
@ -32,13 +32,13 @@ export const EditableCellNormalModeInnerContainer = styled.div`
width: 100%;
`;
type OwnProps = {
children: ReactElement;
};
export function EditableCellDisplayMode({
children,
}: React.PropsWithChildren<unknown>) {
const hasSoftFocus = useIsSoftFocusOnCurrentCell();
export function EditableCellDisplayMode({ children }: OwnProps) {
return (
<EditableCellNormalModeOuterContainer>
<EditableCellNormalModeOuterContainer softFocus={hasSoftFocus}>
<EditableCellNormalModeInnerContainer>
{children}
</EditableCellNormalModeInnerContainer>

View File

@ -1,13 +1,12 @@
import { ReactElement, useMemo, useRef } from 'react';
import { ReactElement, useRef } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef';
import { useMoveSoftFocus } from '@/ui/tables/hooks/useMoveSoftFocus';
import { overlayBackground } from '@/ui/themes/effects';
import { debounce } from '@/utils/debounce';
import { useListenClickOutsideArrayOfRef } from '../../hooks/useListenClickOutsideArrayOfRef';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { useEditableCell } from './hooks/useCloseEditableCell';
export const EditableCellEditModeContainer = styled.div<OwnProps>`
align-items: center;
@ -32,67 +31,77 @@ type OwnProps = {
children: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
isEditMode?: boolean;
onOutsideClick?: () => void;
onInsideClick?: () => void;
};
export function EditableCellEditMode({
editModeHorizontalAlign,
editModeVerticalPosition,
children,
isEditMode,
onOutsideClick,
}: OwnProps) {
const wrapperRef = useRef(null);
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
const debouncedSetIsSomeInputInEditMode = useMemo(() => {
return debounce(setIsSomeInputInEditMode, 20);
}, [setIsSomeInputInEditMode]);
const { closeEditableCell } = useEditableCell();
const { moveRight, moveLeft, moveDown } = useMoveSoftFocus();
useListenClickOutsideArrayOfRef([wrapperRef], () => {
if (isEditMode) {
debouncedSetIsSomeInputInEditMode(false);
onOutsideClick?.();
}
onOutsideClick?.();
});
useHotkeys(
'esc',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
);
useHotkeys(
'enter',
() => {
if (isEditMode) {
onOutsideClick?.();
debouncedSetIsSomeInputInEditMode(false);
}
closeEditableCell();
moveDown();
},
{
preventDefault: true,
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[isEditMode, onOutsideClick, debouncedSetIsSomeInputInEditMode],
[closeEditableCell],
);
useHotkeys(
'esc',
() => {
closeEditableCell();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[closeEditableCell],
);
useHotkeys(
'tab',
() => {
closeEditableCell();
moveRight();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[closeEditableCell, moveRight],
);
useHotkeys(
'shift+tab',
() => {
closeEditableCell();
moveLeft();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[closeEditableCell, moveRight],
);
return (

View File

@ -0,0 +1,57 @@
import React from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useRecoilState } from 'recoil';
import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState';
import { isNonTextWritingKey } from '@/utils/hotkeys/isNonTextWritingKey';
import { useEditableCell } from './hooks/useCloseEditableCell';
import { EditableCellDisplayMode } from './EditableCellDisplayMode';
export function EditableCellSoftFocusMode({
children,
}: React.PropsWithChildren<unknown>) {
const { closeEditableCell, openEditableCell } = useEditableCell();
const [captureHotkeyTypeInFocus] = useRecoilState(
captureHotkeyTypeInFocusState,
);
useHotkeys(
'enter',
() => {
openEditableCell();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
[closeEditableCell],
);
useHotkeys(
'*',
(keyboardEvent) => {
const isWritingText =
!isNonTextWritingKey(keyboardEvent.key) &&
!keyboardEvent.ctrlKey &&
!keyboardEvent.metaKey;
if (!isWritingText) {
return;
}
if (captureHotkeyTypeInFocus) {
return;
}
openEditableCell();
},
{
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: false,
},
);
return <EditableCellDisplayMode>{children}</EditableCellDisplayMode>;
}

View File

@ -1,71 +0,0 @@
import { ReactElement } from 'react';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
import { isSomeInputInEditModeState } from '../../tables/states/isSomeInputInEditModeState';
import { isEditModeScopedState } from './states/isEditModeScopedState';
import { EditableCellDisplayMode } from './EditableCellDisplayMode';
import { EditableCellEditMode } from './EditableCellEditMode';
export const CellBaseContainer = styled.div`
align-items: center;
box-sizing: border-box;
cursor: pointer;
display: flex;
height: 32px;
position: relative;
user-select: none;
width: 100%;
`;
type OwnProps = {
editModeContent: ReactElement;
nonEditModeContent: ReactElement;
editModeHorizontalAlign?: 'left' | 'right';
editModeVerticalPosition?: 'over' | 'below';
};
export function EditableCellV2({
editModeHorizontalAlign = 'left',
editModeVerticalPosition = 'over',
editModeContent,
nonEditModeContent,
}: OwnProps) {
const [isEditMode, setIsEditMode] = useRecoilScopedState(
isEditModeScopedState,
);
const [isSomeInputInEditMode, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
function handleOnClick() {
if (!isSomeInputInEditMode) {
setIsSomeInputInEditMode(true);
setIsEditMode(true);
}
}
function handleOnOutsideClick() {
setIsEditMode(false);
}
return (
<CellBaseContainer onClick={handleOnClick}>
{isEditMode ? (
<EditableCellEditMode
editModeHorizontalAlign={editModeHorizontalAlign}
editModeVerticalPosition={editModeVerticalPosition}
isEditMode={isEditMode}
onOutsideClick={handleOnOutsideClick}
>
{editModeContent}
</EditableCellEditMode>
) : (
<EditableCellDisplayMode>{nonEditModeContent}</EditableCellDisplayMode>
)}
</CellBaseContainer>
);
}

View File

@ -1,19 +1,43 @@
import { useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useRecoilCallback } from 'recoil';
import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { isSomeInputInEditModeState } from '@/ui/tables/states/isSomeInputInEditModeState';
import { isEditModeScopedState } from '../states/isEditModeScopedState';
export function useCloseEditableCell() {
const [, setIsSomeInputInEditMode] = useRecoilState(
isSomeInputInEditModeState,
);
export function useEditableCell() {
const [, setIsEditMode] = useRecoilScopedState(isEditModeScopedState);
return useCallback(() => {
setIsSomeInputInEditMode(false);
setIsEditMode(false);
}, [setIsEditMode, setIsSomeInputInEditMode]);
const closeEditableCell = useRecoilCallback(
({ set }) =>
async () => {
setIsEditMode(false);
await new Promise((resolve) => setTimeout(resolve, 20));
set(isSomeInputInEditModeState, false);
},
[setIsEditMode],
);
const openEditableCell = useRecoilCallback(
({ snapshot, set }) =>
() => {
const isSomeInputInEditMode = snapshot
.getLoadable(isSomeInputInEditModeState)
.valueOrThrow();
if (!isSomeInputInEditMode) {
set(isSomeInputInEditModeState, true);
setIsEditMode(true);
}
},
[setIsEditMode],
);
return {
closeEditableCell,
openEditableCell,
};
}

View File

@ -0,0 +1,36 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { CellContext } from '@/ui/tables/states/CellContext';
import { currentColumnNumberScopedState } from '@/ui/tables/states/currentColumnNumberScopedState';
import { currentRowNumberScopedState } from '@/ui/tables/states/currentRowNumberScopedState';
import { isSoftFocusOnCellFamilyState } from '@/ui/tables/states/isSoftFocusOnCellFamilyState';
import { RowContext } from '@/ui/tables/states/RowContext';
import { TablePosition } from '@/ui/tables/types/TablePosition';
export function useIsSoftFocusOnCurrentCell() {
const [currentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
const [currentColumnNumber] = useRecoilScopedState(
currentColumnNumberScopedState,
CellContext,
);
const currentTablePosition: TablePosition = useMemo(
() => ({
column: currentColumnNumber,
row: currentRowNumber,
}),
[currentColumnNumber, currentRowNumber],
);
const isSoftFocusOnCell = useRecoilValue(
isSoftFocusOnCellFamilyState(currentTablePosition),
);
return isSoftFocusOnCell;
}

View File

@ -0,0 +1,34 @@
import { useCallback, useMemo } from 'react';
import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState';
import { useSetSoftFocusPosition } from '@/ui/tables/hooks/useSetSoftFocusPosition';
import { CellContext } from '@/ui/tables/states/CellContext';
import { currentColumnNumberScopedState } from '@/ui/tables/states/currentColumnNumberScopedState';
import { currentRowNumberScopedState } from '@/ui/tables/states/currentRowNumberScopedState';
import { RowContext } from '@/ui/tables/states/RowContext';
import { TablePosition } from '@/ui/tables/types/TablePosition';
export function useSetSoftFocusOnCurrentCell() {
const setSoftFocusPosition = useSetSoftFocusPosition();
const [currentRowNumber] = useRecoilScopedState(
currentRowNumberScopedState,
RowContext,
);
const [currentColumnNumber] = useRecoilScopedState(
currentColumnNumberScopedState,
CellContext,
);
const currentTablePosition: TablePosition = useMemo(
() => ({
column: currentColumnNumber,
row: currentRowNumber,
}),
[currentColumnNumber, currentRowNumber],
);
return useCallback(() => {
setSoftFocusPosition(currentTablePosition);
}, [setSoftFocusPosition, currentTablePosition]);
}

View File

@ -50,7 +50,6 @@ function EditableChip({
}: EditableChipProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
const handleRightEndContentClick = (
event: React.MouseEvent<HTMLDivElement>,
@ -60,9 +59,6 @@ function EditableChip({
return (
<EditableCell
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
isEditMode={isEditMode}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput

View File

@ -38,7 +38,6 @@ export function EditableDate({
editModeHorizontalAlign,
}: EditableDateProps) {
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
type DivProps = React.HTMLProps<HTMLDivElement>;
@ -60,9 +59,6 @@ export function EditableDate({
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledContainer>

View File

@ -1,4 +1,4 @@
import { ChangeEvent, ReactElement, useRef, useState } from 'react';
import { ChangeEvent, ReactElement, useRef } from 'react';
import styled from '@emotion/styled';
import { textInputStyle } from '@/ui/themes/effects';
@ -42,13 +42,9 @@ export function EditableDoubleText({
onChange,
}: OwnProps) {
const firstValueInputRef = useRef<HTMLInputElement>(null);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
onInsideClick={() => setIsEditMode(true)}
onOutsideClick={() => setIsEditMode(false)}
isEditMode={isEditMode}
editModeContent={
<StyledContainer>
<StyledEditInplaceInput

View File

@ -13,10 +13,6 @@ type OwnProps = {
changeHandler: (updated: string) => void;
};
type StyledEditModeProps = {
isEditMode: boolean;
};
const StyledRawLink = styled(RawLink)`
overflow: hidden;
@ -28,7 +24,7 @@ const StyledRawLink = styled(RawLink)`
`;
// TODO: refactor
const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
const StyledEditInplaceInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
@ -37,17 +33,12 @@ const StyledEditInplaceInput = styled.input<StyledEditModeProps>`
export function EditablePhone({ value, placeholder, changeHandler }: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(value);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeContent={
<StyledEditInplaceInput
autoFocus
isEditMode={isEditMode}
placeholder={placeholder || ''}
ref={inputRef}
value={inputValue}

View File

@ -12,12 +12,8 @@ type OwnProps = {
editModeHorizontalAlign?: 'left' | 'right';
};
type StyledEditModeProps = {
isEditMode: boolean;
};
// TODO: refactor
const StyledInplaceInput = styled.input<StyledEditModeProps>`
const StyledInplaceInput = styled.input`
margin: 0;
width: 100%;
${textInputStyle}
@ -38,17 +34,12 @@ export function EditableText({
}: OwnProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = useState(content);
const [isEditMode, setIsEditMode] = useState(false);
return (
<EditableCell
isEditMode={isEditMode}
onOutsideClick={() => setIsEditMode(false)}
onInsideClick={() => setIsEditMode(true)}
editModeHorizontalAlign={editModeHorizontalAlign}
editModeContent={
<StyledInplaceInput
isEditMode={isEditMode}
placeholder={placeholder || ''}
autoFocus
ref={inputRef}