From aa612b5fc95ad40fe08bd36c50d928a594ebd697 Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 28 Jun 2023 14:06:44 +0200 Subject: [PATCH] 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 --- .../components/CompanyAccountOwnerCell.tsx | 4 +- .../components/CompanyAccountOwnerPicker.tsx | 6 +- .../states/captureHotkeyTypeInFocusState.ts | 6 + .../hotkeys/states/pendingHotkeysState.ts | 2 +- .../opportunities/components/NewButton.tsx | 2 +- .../components/NewCompanyBoardCard.tsx | 2 +- .../people/components/PeopleCompanyCell.tsx | 6 +- .../components/PeopleCompanyCreateCell.tsx | 2 +- .../people/components/PeopleCompanyPicker.tsx | 6 +- .../recoil-scope/components/RecoilScope.tsx | 24 +++ .../hooks/useRecoilScopedState.ts | 20 +++ .../hooks/useRecoilScopedValue.ts | 2 +- .../states}/RecoilScopeContext.ts | 0 .../hooks/useEntitySelectLogic.ts | 2 +- .../components/editable-cell/EditableCell.tsx | 49 +++--- .../editable-cell/EditableCellDisplayMode.tsx | 36 ++-- .../editable-cell/EditableCellEditMode.tsx | 95 ++++++----- .../EditableCellSoftFocusMode.tsx | 57 +++++++ .../editable-cell/EditableCellV2.tsx | 71 -------- .../hooks/useCloseEditableCell.ts | 46 +++-- .../hooks/useIsSoftFocusOnCurrentCell.ts | 36 ++++ .../hooks/useSetSoftFocusOnCurrentCell.ts | 34 ++++ .../editable-cell/types/EditableChip.tsx | 4 - .../editable-cell/types/EditableDate.tsx | 4 - .../types/EditableDoubleText.tsx | 6 +- .../editable-cell/types/EditablePhone.tsx | 11 +- .../editable-cell/types/EditableText.tsx | 11 +- .../ui/components/table/EntityTable.tsx | 59 +------ .../ui/components/table/EntityTableCell.tsx | 56 ++++++ .../ui/components/table/EntityTableRow.tsx | 63 +++++++ .../ui/components/table/HooksEntityTable.tsx | 19 +++ .../table/table-header/DropdownButton.tsx | 8 + .../__stories__/TableHeader.stories.tsx | 40 ++--- front/src/modules/ui/hooks/RecoilScope.tsx | 14 -- .../modules/ui/hooks/useRecoilScopedState.ts | 17 -- .../src/modules/ui/tables/constants/index.ts | 1 + .../tables/hooks/useInitializeEntityTable.ts | 42 +++++ .../tables/hooks/useMapKeyboardToSoftFocus.ts | 72 ++++++++ .../ui/tables/hooks/useMoveSoftFocus.ts | 161 ++++++++++++++++++ .../tables/hooks/useSetSoftFocusPosition.ts | 21 +++ .../modules/ui/tables/states/CellContext.ts | 3 + .../modules/ui/tables/states/RowContext.ts | 3 + .../states/currentColumnNumberScopedState.ts | 6 + .../states/currentRowNumberScopedState.ts | 6 + .../states/entityTableDimensionsState.ts | 11 ++ .../states/isSoftFocusOnCellFamilyState.ts | 8 + .../numberOfTableColumnsSelectorState.ts | 12 ++ .../states/numberOfTableRowsSelectorState.ts | 12 ++ .../tables/states/softFocusPositionState.ts | 11 ++ .../ui/tables/types/TableDimensions.ts | 4 + .../modules/ui/tables/types/TablePosition.ts | 4 + .../ui/tables/types/guards/isTablePosition.ts | 7 + .../utils/hotkeys/isNonTextWritingKey.ts | 57 +++++++ front/src/pages/companies/Companies.tsx | 5 + .../src/pages/companies/companies-columns.tsx | 5 +- front/src/pages/people/People.tsx | 6 + .../__stories__/People.inputs.stories.tsx | 6 +- front/src/pages/people/people-columns.tsx | 7 +- 58 files changed, 958 insertions(+), 332 deletions(-) create mode 100644 front/src/modules/hotkeys/states/captureHotkeyTypeInFocusState.ts create mode 100644 front/src/modules/recoil-scope/components/RecoilScope.tsx create mode 100644 front/src/modules/recoil-scope/hooks/useRecoilScopedState.ts rename front/src/modules/{ui => recoil-scope}/hooks/useRecoilScopedValue.ts (86%) rename front/src/modules/{ui/hooks => recoil-scope/states}/RecoilScopeContext.ts (100%) create mode 100644 front/src/modules/ui/components/editable-cell/EditableCellSoftFocusMode.tsx delete mode 100644 front/src/modules/ui/components/editable-cell/EditableCellV2.tsx create mode 100644 front/src/modules/ui/components/editable-cell/hooks/useIsSoftFocusOnCurrentCell.ts create mode 100644 front/src/modules/ui/components/editable-cell/hooks/useSetSoftFocusOnCurrentCell.ts create mode 100644 front/src/modules/ui/components/table/EntityTableCell.tsx create mode 100644 front/src/modules/ui/components/table/EntityTableRow.tsx create mode 100644 front/src/modules/ui/components/table/HooksEntityTable.tsx delete mode 100644 front/src/modules/ui/hooks/RecoilScope.tsx delete mode 100644 front/src/modules/ui/hooks/useRecoilScopedState.ts create mode 100644 front/src/modules/ui/tables/constants/index.ts create mode 100644 front/src/modules/ui/tables/hooks/useInitializeEntityTable.ts create mode 100644 front/src/modules/ui/tables/hooks/useMapKeyboardToSoftFocus.ts create mode 100644 front/src/modules/ui/tables/hooks/useMoveSoftFocus.ts create mode 100644 front/src/modules/ui/tables/hooks/useSetSoftFocusPosition.ts create mode 100644 front/src/modules/ui/tables/states/CellContext.ts create mode 100644 front/src/modules/ui/tables/states/RowContext.ts create mode 100644 front/src/modules/ui/tables/states/currentColumnNumberScopedState.ts create mode 100644 front/src/modules/ui/tables/states/currentRowNumberScopedState.ts create mode 100644 front/src/modules/ui/tables/states/entityTableDimensionsState.ts create mode 100644 front/src/modules/ui/tables/states/isSoftFocusOnCellFamilyState.ts create mode 100644 front/src/modules/ui/tables/states/numberOfTableColumnsSelectorState.ts create mode 100644 front/src/modules/ui/tables/states/numberOfTableRowsSelectorState.ts create mode 100644 front/src/modules/ui/tables/states/softFocusPositionState.ts create mode 100644 front/src/modules/ui/tables/types/TableDimensions.ts create mode 100644 front/src/modules/ui/tables/types/TablePosition.ts create mode 100644 front/src/modules/ui/tables/types/guards/isTablePosition.ts create mode 100644 front/src/modules/utils/hotkeys/isNonTextWritingKey.ts diff --git a/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx b/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx index f7026f75d..2dbf0b1ff 100644 --- a/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx +++ b/front/src/modules/companies/components/CompanyAccountOwnerCell.tsx @@ -1,5 +1,5 @@ import { PersonChip } from '@/people/components/PersonChip'; -import { EditableCellV2 } from '@/ui/components/editable-cell/EditableCellV2'; +import { EditableCell } from '@/ui/components/editable-cell/EditableCell'; import { Company, User } from '~/generated/graphql'; import { CompanyAccountOwnerPicker } from './CompanyAccountOwnerPicker'; @@ -12,7 +12,7 @@ export type OwnProps = { export function CompanyAccountOwnerCell({ company }: OwnProps) { return ( - } nonEditModeContent={ company.accountOwner?.displayName ? ( diff --git a/front/src/modules/companies/components/CompanyAccountOwnerPicker.tsx b/front/src/modules/companies/components/CompanyAccountOwnerPicker.tsx index 2def2eb66..270ab5b51 100644 --- a/front/src/modules/companies/components/CompanyAccountOwnerPicker.tsx +++ b/front/src/modules/companies/components/CompanyAccountOwnerPicker.tsx @@ -1,10 +1,10 @@ +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect'; import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery'; import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; import { EntityForSelect } from '@/relation-picker/types/EntityForSelect'; import { Entity } from '@/relation-picker/types/EntityTypeForSelect'; -import { useCloseEditableCell } from '@/ui/components/editable-cell/hooks/useCloseEditableCell'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; +import { useEditableCell } from '@/ui/components/editable-cell/hooks/useCloseEditableCell'; import { Company, User, @@ -28,7 +28,7 @@ export function CompanyAccountOwnerPicker({ company }: OwnProps) { ); const [updateCompany] = useUpdateCompanyMutation(); - const closeEditableCell = useCloseEditableCell(); + const { closeEditableCell } = useEditableCell(); const companies = useFilteredSearchEntityQuery({ queryHook: useSearchUserQuery, diff --git a/front/src/modules/hotkeys/states/captureHotkeyTypeInFocusState.ts b/front/src/modules/hotkeys/states/captureHotkeyTypeInFocusState.ts new file mode 100644 index 000000000..8d239b631 --- /dev/null +++ b/front/src/modules/hotkeys/states/captureHotkeyTypeInFocusState.ts @@ -0,0 +1,6 @@ +import { atom } from 'recoil'; + +export const captureHotkeyTypeInFocusState = atom({ + key: 'captureHotkeyTypeInFocusState', + default: false, +}); diff --git a/front/src/modules/hotkeys/states/pendingHotkeysState.ts b/front/src/modules/hotkeys/states/pendingHotkeysState.ts index 685e1753c..da724a5fd 100644 --- a/front/src/modules/hotkeys/states/pendingHotkeysState.ts +++ b/front/src/modules/hotkeys/states/pendingHotkeysState.ts @@ -1,6 +1,6 @@ import { atom } from 'recoil'; export const pendingHotkeyState = atom({ - key: 'command-menu/pendingHotkeyState', + key: 'pendingHotkeyState', default: null, }); diff --git a/front/src/modules/opportunities/components/NewButton.tsx b/front/src/modules/opportunities/components/NewButton.tsx index 77e6ed8a4..b69f96714 100644 --- a/front/src/modules/opportunities/components/NewButton.tsx +++ b/front/src/modules/opportunities/components/NewButton.tsx @@ -2,9 +2,9 @@ import { useCallback, useState } from 'react'; import { useRecoilState } from 'recoil'; import { v4 as uuidv4 } from 'uuid'; +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; import { Column } from '@/ui/components/board/Board'; import { NewButton as UINewButton } from '@/ui/components/board/NewButton'; -import { RecoilScope } from '@/ui/hooks/RecoilScope'; import { Company, PipelineProgressableType, diff --git a/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx b/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx index bf8cef2fc..a536dc195 100644 --- a/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx +++ b/front/src/modules/opportunities/components/NewCompanyBoardCard.tsx @@ -1,7 +1,7 @@ +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect'; import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery'; import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { getLogoUrlFromDomainName } from '@/utils/utils'; import { CommentableType, diff --git a/front/src/modules/people/components/PeopleCompanyCell.tsx b/front/src/modules/people/components/PeopleCompanyCell.tsx index 73687f771..6b34efba7 100644 --- a/front/src/modules/people/components/PeopleCompanyCell.tsx +++ b/front/src/modules/people/components/PeopleCompanyCell.tsx @@ -1,7 +1,7 @@ import CompanyChip from '@/companies/components/CompanyChip'; -import { EditableCellV2 } from '@/ui/components/editable-cell/EditableCellV2'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { EditableCell } from '@/ui/components/editable-cell/EditableCell'; import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { getLogoUrlFromDomainName } from '@/utils/utils'; import { Company, Person } from '~/generated/graphql'; @@ -18,7 +18,7 @@ export function PeopleCompanyCell({ people }: OwnProps) { const [isCreating] = useRecoilScopedState(isCreateModeScopedState); return ( - diff --git a/front/src/modules/people/components/PeopleCompanyCreateCell.tsx b/front/src/modules/people/components/PeopleCompanyCreateCell.tsx index ebc2dd3ea..c66863b0d 100644 --- a/front/src/modules/people/components/PeopleCompanyCreateCell.tsx +++ b/front/src/modules/people/components/PeopleCompanyCreateCell.tsx @@ -2,11 +2,11 @@ import { useRef, useState } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { v4 } from 'uuid'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState'; import { DoubleTextInput } from '@/ui/components/inputs/DoubleTextInput'; import { useListenClickOutsideArrayOfRef } from '@/ui/hooks/useListenClickOutsideArrayOfRef'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { logError } from '@/utils/logs/logError'; import { Person, diff --git a/front/src/modules/people/components/PeopleCompanyPicker.tsx b/front/src/modules/people/components/PeopleCompanyPicker.tsx index c46465424..aa91efead 100644 --- a/front/src/modules/people/components/PeopleCompanyPicker.tsx +++ b/front/src/modules/people/components/PeopleCompanyPicker.tsx @@ -1,9 +1,9 @@ +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { SingleEntitySelect } from '@/relation-picker/components/SingleEntitySelect'; import { useFilteredSearchEntityQuery } from '@/relation-picker/hooks/useFilteredSearchEntityQuery'; import { relationPickerSearchFilterScopedState } from '@/relation-picker/states/relationPickerSearchFilterScopedState'; -import { useCloseEditableCell } from '@/ui/components/editable-cell/hooks/useCloseEditableCell'; +import { useEditableCell } from '@/ui/components/editable-cell/hooks/useCloseEditableCell'; import { isCreateModeScopedState } from '@/ui/components/editable-cell/states/isCreateModeScopedState'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; import { getLogoUrlFromDomainName } from '@/utils/utils'; import { CommentableType, @@ -25,7 +25,7 @@ export function PeopleCompanyPicker({ people }: OwnProps) { ); const [updatePeople] = useUpdatePeopleMutation(); - const closeEditableCell = useCloseEditableCell(); + const { closeEditableCell } = useEditableCell(); const companies = useFilteredSearchEntityQuery({ queryHook: useSearchCompanyQuery, diff --git a/front/src/modules/recoil-scope/components/RecoilScope.tsx b/front/src/modules/recoil-scope/components/RecoilScope.tsx new file mode 100644 index 000000000..947137641 --- /dev/null +++ b/front/src/modules/recoil-scope/components/RecoilScope.tsx @@ -0,0 +1,24 @@ +import { Context, useRef } from 'react'; +import { v4 } from 'uuid'; + +import { RecoilScopeContext } from '../states/RecoilScopeContext'; + +export function RecoilScope({ + SpecificContext, + children, +}: { + SpecificContext?: Context; + children: React.ReactNode; +}) { + const currentScopeId = useRef(v4()); + + return SpecificContext ? ( + + {children} + + ) : ( + + {children} + + ); +} diff --git a/front/src/modules/recoil-scope/hooks/useRecoilScopedState.ts b/front/src/modules/recoil-scope/hooks/useRecoilScopedState.ts new file mode 100644 index 000000000..566a3be2b --- /dev/null +++ b/front/src/modules/recoil-scope/hooks/useRecoilScopedState.ts @@ -0,0 +1,20 @@ +import { Context, useContext } from 'react'; +import { RecoilState, useRecoilState } from 'recoil'; + +import { RecoilScopeContext } from '../states/RecoilScopeContext'; + +export function useRecoilScopedState( + recoilState: (param: string) => RecoilState, + SpecificContext?: Context, +) { + const recoilScopeId = useContext(SpecificContext ?? RecoilScopeContext); + + if (!recoilScopeId) + throw new Error( + `Using a scoped atom without a RecoilScope : ${ + recoilState('').key + }, verify that you are using a RecoilScope with a specific context if you intended to do so.`, + ); + + return useRecoilState(recoilState(recoilScopeId)); +} diff --git a/front/src/modules/ui/hooks/useRecoilScopedValue.ts b/front/src/modules/recoil-scope/hooks/useRecoilScopedValue.ts similarity index 86% rename from front/src/modules/ui/hooks/useRecoilScopedValue.ts rename to front/src/modules/recoil-scope/hooks/useRecoilScopedValue.ts index b43005872..c3c415bcc 100644 --- a/front/src/modules/ui/hooks/useRecoilScopedValue.ts +++ b/front/src/modules/recoil-scope/hooks/useRecoilScopedValue.ts @@ -1,7 +1,7 @@ import { useContext } from 'react'; import { RecoilState, useRecoilValue } from 'recoil'; -import { RecoilScopeContext } from './RecoilScopeContext'; +import { RecoilScopeContext } from '../states/RecoilScopeContext'; export function useRecoilScopedValue( recoilState: (param: string) => RecoilState, diff --git a/front/src/modules/ui/hooks/RecoilScopeContext.ts b/front/src/modules/recoil-scope/states/RecoilScopeContext.ts similarity index 100% rename from front/src/modules/ui/hooks/RecoilScopeContext.ts rename to front/src/modules/recoil-scope/states/RecoilScopeContext.ts diff --git a/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts b/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts index f48bb4efa..1ca241992 100644 --- a/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts +++ b/front/src/modules/relation-picker/hooks/useEntitySelectLogic.ts @@ -3,7 +3,7 @@ import { debounce } from 'lodash'; import scrollIntoView from 'scroll-into-view'; import { useUpDownHotkeys } from '@/hotkeys/hooks/useUpDownHotkeys'; -import { useRecoilScopedState } from '@/ui/hooks/useRecoilScopedState'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; import { relationPickerSearchFilterScopedState } from '../states/relationPickerSearchFilterScopedState'; import { EntityForSelect } from '../types/EntityForSelect'; diff --git a/front/src/modules/ui/components/editable-cell/EditableCell.tsx b/front/src/modules/ui/components/editable-cell/EditableCell.tsx index ecdfc5e82..15152a501 100644 --- a/front/src/modules/ui/components/editable-cell/EditableCell.tsx +++ b/front/src/modules/ui/components/editable-cell/EditableCell.tsx @@ -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 ( {isEditMode ? ( {editModeContent} + ) : hasSoftFocus ? ( + + {nonEditModeContent} + ) : ( {nonEditModeContent} )} diff --git a/front/src/modules/ui/components/editable-cell/EditableCellDisplayMode.tsx b/front/src/modules/ui/components/editable-cell/EditableCellDisplayMode.tsx index 4cdbf559a..fe9a3e0d9 100644 --- a/front/src/modules/ui/components/editable-cell/EditableCellDisplayMode.tsx +++ b/front/src/modules/ui/components/editable-cell/EditableCellDisplayMode.tsx @@ -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` 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) { + const hasSoftFocus = useIsSoftFocusOnCurrentCell(); -export function EditableCellDisplayMode({ children }: OwnProps) { return ( - + {children} diff --git a/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx b/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx index 13d781726..79249bbe1 100644 --- a/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx +++ b/front/src/modules/ui/components/editable-cell/EditableCellEditMode.tsx @@ -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` 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 ( diff --git a/front/src/modules/ui/components/editable-cell/EditableCellSoftFocusMode.tsx b/front/src/modules/ui/components/editable-cell/EditableCellSoftFocusMode.tsx new file mode 100644 index 000000000..d67da443a --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/EditableCellSoftFocusMode.tsx @@ -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) { + 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 {children}; +} diff --git a/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx b/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx deleted file mode 100644 index 5d440749e..000000000 --- a/front/src/modules/ui/components/editable-cell/EditableCellV2.tsx +++ /dev/null @@ -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 ( - - {isEditMode ? ( - - {editModeContent} - - ) : ( - {nonEditModeContent} - )} - - ); -} diff --git a/front/src/modules/ui/components/editable-cell/hooks/useCloseEditableCell.ts b/front/src/modules/ui/components/editable-cell/hooks/useCloseEditableCell.ts index 4ac9d260d..c2cc3b4e8 100644 --- a/front/src/modules/ui/components/editable-cell/hooks/useCloseEditableCell.ts +++ b/front/src/modules/ui/components/editable-cell/hooks/useCloseEditableCell.ts @@ -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, + }; } diff --git a/front/src/modules/ui/components/editable-cell/hooks/useIsSoftFocusOnCurrentCell.ts b/front/src/modules/ui/components/editable-cell/hooks/useIsSoftFocusOnCurrentCell.ts new file mode 100644 index 000000000..329946eac --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/hooks/useIsSoftFocusOnCurrentCell.ts @@ -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; +} diff --git a/front/src/modules/ui/components/editable-cell/hooks/useSetSoftFocusOnCurrentCell.ts b/front/src/modules/ui/components/editable-cell/hooks/useSetSoftFocusOnCurrentCell.ts new file mode 100644 index 000000000..e31aa6818 --- /dev/null +++ b/front/src/modules/ui/components/editable-cell/hooks/useSetSoftFocusOnCurrentCell.ts @@ -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]); +} diff --git a/front/src/modules/ui/components/editable-cell/types/EditableChip.tsx b/front/src/modules/ui/components/editable-cell/types/EditableChip.tsx index 93d800de4..3c228b280 100644 --- a/front/src/modules/ui/components/editable-cell/types/EditableChip.tsx +++ b/front/src/modules/ui/components/editable-cell/types/EditableChip.tsx @@ -50,7 +50,6 @@ function EditableChip({ }: EditableChipProps) { const inputRef = useRef(null); const [inputValue, setInputValue] = useState(value); - const [isEditMode, setIsEditMode] = useState(false); const handleRightEndContentClick = ( event: React.MouseEvent, @@ -60,9 +59,6 @@ function EditableChip({ return ( setIsEditMode(false)} - onInsideClick={() => setIsEditMode(true)} - isEditMode={isEditMode} editModeHorizontalAlign={editModeHorizontalAlign} editModeContent={ ; @@ -60,9 +59,6 @@ export function EditableDate({ return ( setIsEditMode(false)} - onInsideClick={() => setIsEditMode(true)} editModeHorizontalAlign={editModeHorizontalAlign} editModeContent={ diff --git a/front/src/modules/ui/components/editable-cell/types/EditableDoubleText.tsx b/front/src/modules/ui/components/editable-cell/types/EditableDoubleText.tsx index 42d47ceab..c450c7eee 100644 --- a/front/src/modules/ui/components/editable-cell/types/EditableDoubleText.tsx +++ b/front/src/modules/ui/components/editable-cell/types/EditableDoubleText.tsx @@ -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(null); - const [isEditMode, setIsEditMode] = useState(false); return ( setIsEditMode(true)} - onOutsideClick={() => setIsEditMode(false)} - isEditMode={isEditMode} editModeContent={ 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` +const StyledEditInplaceInput = styled.input` margin: 0; width: 100%; ${textInputStyle} @@ -37,17 +33,12 @@ const StyledEditInplaceInput = styled.input` export function EditablePhone({ value, placeholder, changeHandler }: OwnProps) { const inputRef = useRef(null); const [inputValue, setInputValue] = useState(value); - const [isEditMode, setIsEditMode] = useState(false); return ( setIsEditMode(false)} - onInsideClick={() => setIsEditMode(true)} editModeContent={ ` +const StyledInplaceInput = styled.input` margin: 0; width: 100%; ${textInputStyle} @@ -38,17 +34,12 @@ export function EditableText({ }: OwnProps) { const inputRef = useRef(null); const [inputValue, setInputValue] = useState(content); - const [isEditMode, setIsEditMode] = useState(false); return ( setIsEditMode(false)} - onInsideClick={() => setIsEditMode(true)} editModeHorizontalAlign={editModeHorizontalAlign} editModeContent={ = { data: Array; @@ -100,11 +101,6 @@ const StyledTableScrollableContainer = styled.div` overflow: auto; `; -const StyledRow = styled.tr<{ selected: boolean }>` - background: ${(props) => - props.selected ? props.theme.background.secondary : 'none'}; -`; - export function EntityTable({ data, columns, @@ -118,13 +114,6 @@ export function EntityTable({ const [currentRowSelection, setCurrentRowSelection] = useRecoilState( currentRowSelectionState, ); - const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); - - const resetTableRowSelection = useResetTableRowSelection(); - - React.useEffect(() => { - resetTableRowSelection(); - }, [resetTableRowSelection]); const table = useReactTable({ data, @@ -138,16 +127,6 @@ export function EntityTable({ getRowId: (row) => row.id, }); - function handleContextMenu(event: React.MouseEvent, id: string) { - event.preventDefault(); - setCurrentRowSelection((prev) => ({ ...prev, [id]: true })); - - setContextMenuPosition({ - x: event.clientX, - y: event.clientY, - }); - } - return ( ({ {table.getRowModel().rows.map((row, index) => ( - - {row.getVisibleCells().map((cell) => { - return ( - - handleContextMenu(event, row.original.id) - } - style={{ - width: cell.column.getSize(), - minWidth: cell.column.getSize(), - maxWidth: cell.column.getSize(), - }} - > - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ); - })} - - + + + ))} diff --git a/front/src/modules/ui/components/table/EntityTableCell.tsx b/front/src/modules/ui/components/table/EntityTableCell.tsx new file mode 100644 index 000000000..cb755dfd2 --- /dev/null +++ b/front/src/modules/ui/components/table/EntityTableCell.tsx @@ -0,0 +1,56 @@ +import { useEffect } from 'react'; +import { flexRender } from '@tanstack/react-table'; +import { Cell, Row } from '@tanstack/table-core'; +import { useRecoilState, useSetRecoilState } from 'recoil'; + +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { CellContext } from '@/ui/tables/states/CellContext'; +import { contextMenuPositionState } from '@/ui/tables/states/contextMenuPositionState'; +import { currentColumnNumberScopedState } from '@/ui/tables/states/currentColumnNumberScopedState'; +import { currentRowSelectionState } from '@/ui/tables/states/rowSelectionState'; + +export function EntityTableCell({ + row, + cell, + cellIndex, +}: { + row: Row; + cell: Cell; + cellIndex: number; +}) { + const [, setCurrentRowSelection] = useRecoilState(currentRowSelectionState); + + const [, setCurrentColumnNumber] = useRecoilScopedState( + currentColumnNumberScopedState, + CellContext, + ); + + useEffect(() => { + setCurrentColumnNumber(cellIndex); + }, [cellIndex, setCurrentColumnNumber]); + + const setContextMenuPosition = useSetRecoilState(contextMenuPositionState); + + function handleContextMenu(event: React.MouseEvent, id: string) { + event.preventDefault(); + setCurrentRowSelection((prev) => ({ ...prev, [id]: true })); + + setContextMenuPosition({ + x: event.clientX, + y: event.clientY, + }); + } + + return ( + handleContextMenu(event, row.original.id)} + style={{ + width: cell.column.getSize(), + minWidth: cell.column.getSize(), + maxWidth: cell.column.getSize(), + }} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); +} diff --git a/front/src/modules/ui/components/table/EntityTableRow.tsx b/front/src/modules/ui/components/table/EntityTableRow.tsx new file mode 100644 index 000000000..bbf5c4298 --- /dev/null +++ b/front/src/modules/ui/components/table/EntityTableRow.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import styled from '@emotion/styled'; +import { Row } from '@tanstack/table-core'; +import { useRecoilState } from 'recoil'; + +import { RecoilScope } from '@/recoil-scope/components/RecoilScope'; +import { useRecoilScopedState } from '@/recoil-scope/hooks/useRecoilScopedState'; +import { CellContext } from '@/ui/tables/states/CellContext'; +import { currentRowNumberScopedState } from '@/ui/tables/states/currentRowNumberScopedState'; +import { RowContext } from '@/ui/tables/states/RowContext'; +import { currentRowSelectionState } from '@/ui/tables/states/rowSelectionState'; + +import { EntityTableCell } from './EntityTableCell'; + +const StyledRow = styled.tr<{ selected: boolean }>` + background: ${(props) => + props.selected ? props.theme.background.secondary : 'none'}; +`; + +export function EntityTableRow({ + row, + index, +}: { + row: Row; + index: number; +}) { + const [currentRowSelection] = useRecoilState(currentRowSelectionState); + + const [, setCurrentRowNumber] = useRecoilScopedState( + currentRowNumberScopedState, + RowContext, + ); + + useEffect(() => { + setCurrentRowNumber(index); + }, [index, setCurrentRowNumber]); + + return ( + + {row.getVisibleCells().map((cell, cellIndex) => { + return ( + + + + row={row} + cell={cell} + cellIndex={cellIndex} + /> + + + ); + })} + + + ); +} diff --git a/front/src/modules/ui/components/table/HooksEntityTable.tsx b/front/src/modules/ui/components/table/HooksEntityTable.tsx new file mode 100644 index 000000000..18e654449 --- /dev/null +++ b/front/src/modules/ui/components/table/HooksEntityTable.tsx @@ -0,0 +1,19 @@ +import { useInitializeEntityTable } from '@/ui/tables/hooks/useInitializeEntityTable'; +import { useMapKeyboardToSoftFocus } from '@/ui/tables/hooks/useMapKeyboardToSoftFocus'; + +export function HooksEntityTable({ + numberOfColumns, + numberOfRows, +}: { + numberOfColumns: number; + numberOfRows: number; +}) { + useMapKeyboardToSoftFocus(); + + useInitializeEntityTable({ + numberOfColumns, + numberOfRows, + }); + + return <>; +} diff --git a/front/src/modules/ui/components/table/table-header/DropdownButton.tsx b/front/src/modules/ui/components/table/table-header/DropdownButton.tsx index 517277469..2167bf535 100644 --- a/front/src/modules/ui/components/table/table-header/DropdownButton.tsx +++ b/front/src/modules/ui/components/table/table-header/DropdownButton.tsx @@ -1,6 +1,8 @@ import { ReactNode, useRef } from 'react'; import styled from '@emotion/styled'; +import { useRecoilState } from 'recoil'; +import { captureHotkeyTypeInFocusState } from '@/hotkeys/states/captureHotkeyTypeInFocusState'; import { IconChevronDown } from '@/ui/icons/index'; import { overlayBackground, textInputStyle } from '@/ui/themes/effects'; @@ -159,11 +161,17 @@ function DropdownButton({ setIsUnfolded, resetState, }: OwnProps) { + const [, setCaptureHotkeyTypeInFocus] = useRecoilState( + captureHotkeyTypeInFocusState, + ); + const onButtonClick = () => { setIsUnfolded && setIsUnfolded(!isUnfolded); + setCaptureHotkeyTypeInFocus(!isUnfolded); }; const onOutsideClick = () => { + setCaptureHotkeyTypeInFocus(false); setIsUnfolded && setIsUnfolded(false); resetState && resetState(); }; diff --git a/front/src/modules/ui/components/table/table-header/__stories__/TableHeader.stories.tsx b/front/src/modules/ui/components/table/table-header/__stories__/TableHeader.stories.tsx index 179cc6102..8f4743467 100644 --- a/front/src/modules/ui/components/table/table-header/__stories__/TableHeader.stories.tsx +++ b/front/src/modules/ui/components/table/table-header/__stories__/TableHeader.stories.tsx @@ -1,11 +1,9 @@ import React from 'react'; -import { ApolloProvider } from '@apollo/client'; import type { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; import { IconList } from '@/ui/icons/index'; -import { FullHeightStorybookLayout } from '~/testing/FullHeightStorybookLayout'; -import { mockedClient } from '~/testing/mockedClient'; +import { getRenderWrapperForComponent } from '~/testing/renderWrappers'; import { availableFilters } from '../../../../../../pages/companies/companies-filters'; import { availableSorts } from '../../../../../../pages/companies/companies-sorts'; @@ -20,32 +18,24 @@ export default meta; type Story = StoryObj; export const Empty: Story = { - render: () => ( - - - } - availableSorts={availableSorts} - availableFilters={availableFilters} - /> - - + render: getRenderWrapperForComponent( + } + availableSorts={availableSorts} + availableFilters={availableFilters} + />, ), }; export const WithSortsAndFilters: Story = { - render: () => ( - - - } - availableSorts={availableSorts} - availableFilters={availableFilters} - /> - - + render: getRenderWrapperForComponent( + } + availableSorts={availableSorts} + availableFilters={availableFilters} + />, ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); diff --git a/front/src/modules/ui/hooks/RecoilScope.tsx b/front/src/modules/ui/hooks/RecoilScope.tsx deleted file mode 100644 index 5888f31dc..000000000 --- a/front/src/modules/ui/hooks/RecoilScope.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { useState } from 'react'; -import { v4 } from 'uuid'; - -import { RecoilScopeContext } from './RecoilScopeContext'; - -export function RecoilScope({ children }: { children: React.ReactNode }) { - const [currentScopeId] = useState(v4()); - - return ( - - {children} - - ); -} diff --git a/front/src/modules/ui/hooks/useRecoilScopedState.ts b/front/src/modules/ui/hooks/useRecoilScopedState.ts deleted file mode 100644 index 708f9346e..000000000 --- a/front/src/modules/ui/hooks/useRecoilScopedState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useContext } from 'react'; -import { RecoilState, useRecoilState } from 'recoil'; - -import { RecoilScopeContext } from './RecoilScopeContext'; - -export function useRecoilScopedState( - recoilState: (param: string) => RecoilState, -) { - const recoilScopeId = useContext(RecoilScopeContext); - - if (!recoilScopeId) - throw new Error( - `Using a scoped atom without a RecoilScope : ${recoilState('').key}`, - ); - - return useRecoilState(recoilState(recoilScopeId)); -} diff --git a/front/src/modules/ui/tables/constants/index.ts b/front/src/modules/ui/tables/constants/index.ts new file mode 100644 index 000000000..f7a07d372 --- /dev/null +++ b/front/src/modules/ui/tables/constants/index.ts @@ -0,0 +1 @@ +export const TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN = 1; diff --git a/front/src/modules/ui/tables/hooks/useInitializeEntityTable.ts b/front/src/modules/ui/tables/hooks/useInitializeEntityTable.ts new file mode 100644 index 000000000..11ee8e774 --- /dev/null +++ b/front/src/modules/ui/tables/hooks/useInitializeEntityTable.ts @@ -0,0 +1,42 @@ +import { useEffect } from 'react'; +import { useRecoilState } from 'recoil'; + +import { TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN } from '../constants'; +import { entityTableDimensionsState } from '../states/entityTableDimensionsState'; + +import { useResetTableRowSelection } from './useResetTableRowSelection'; +import { useSetSoftFocusPosition } from './useSetSoftFocusPosition'; + +export type TableDimensions = { + numberOfRows: number; + numberOfColumns: number; +}; + +export function useInitializeEntityTable({ + numberOfRows, + numberOfColumns, +}: TableDimensions) { + const resetTableRowSelection = useResetTableRowSelection(); + + useEffect(() => { + resetTableRowSelection(); + }, [resetTableRowSelection]); + + const [, setTableDimensions] = useRecoilState(entityTableDimensionsState); + + useEffect(() => { + setTableDimensions({ + numberOfColumns, + numberOfRows, + }); + }, [numberOfRows, numberOfColumns, setTableDimensions]); + + const setSoftFocusPosition = useSetSoftFocusPosition(); + + useEffect(() => { + setSoftFocusPosition({ + row: 0, + column: TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN, + }); + }, [setSoftFocusPosition]); +} diff --git a/front/src/modules/ui/tables/hooks/useMapKeyboardToSoftFocus.ts b/front/src/modules/ui/tables/hooks/useMapKeyboardToSoftFocus.ts new file mode 100644 index 000000000..8177d54a7 --- /dev/null +++ b/front/src/modules/ui/tables/hooks/useMapKeyboardToSoftFocus.ts @@ -0,0 +1,72 @@ +import { useHotkeys } from 'react-hotkeys-hook'; +import { useRecoilState } from 'recoil'; + +import { isSomeInputInEditModeState } from '../states/isSomeInputInEditModeState'; + +import { useMoveSoftFocus } from './useMoveSoftFocus'; + +export function useMapKeyboardToSoftFocus() { + const { moveDown, moveLeft, moveRight, moveUp } = useMoveSoftFocus(); + + const [isSomeInputInEditMode] = useRecoilState(isSomeInputInEditModeState); + + useHotkeys( + 'up, shift+enter', + () => { + if (!isSomeInputInEditMode) { + moveUp(); + } + }, + [moveUp, isSomeInputInEditMode], + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + ); + + useHotkeys( + 'down', + () => { + if (!isSomeInputInEditMode) { + moveDown(); + } + }, + [moveDown, isSomeInputInEditMode], + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + ); + + useHotkeys( + ['left', 'shift+tab'], + () => { + if (!isSomeInputInEditMode) { + moveLeft(); + } + }, + [moveLeft, isSomeInputInEditMode], + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + ); + + useHotkeys( + ['right', 'tab'], + () => { + if (!isSomeInputInEditMode) { + moveRight(); + } + }, + [moveRight, isSomeInputInEditMode], + { + preventDefault: true, + enableOnContentEditable: true, + enableOnFormTags: true, + }, + ); +} diff --git a/front/src/modules/ui/tables/hooks/useMoveSoftFocus.ts b/front/src/modules/ui/tables/hooks/useMoveSoftFocus.ts new file mode 100644 index 000000000..54c33c814 --- /dev/null +++ b/front/src/modules/ui/tables/hooks/useMoveSoftFocus.ts @@ -0,0 +1,161 @@ +import { useRecoilCallback } from 'recoil'; + +import { TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN } from '../constants'; +import { numberOfTableColumnsSelectorState } from '../states/numberOfTableColumnsSelectorState'; +import { numberOfTableRowsSelectorState } from '../states/numberOfTableRowsSelectorState'; +import { softFocusPositionState } from '../states/softFocusPositionState'; + +import { useSetSoftFocusPosition } from './useSetSoftFocusPosition'; + +// TODO: stories +export function useMoveSoftFocus() { + const setSoftFocusPosition = useSetSoftFocusPosition(); + + const moveUp = useRecoilCallback( + ({ snapshot }) => + () => { + const softFocusPosition = snapshot + .getLoadable(softFocusPositionState) + .valueOrThrow(); + + let newRowNumber = softFocusPosition.row - 1; + + if (newRowNumber < 0) { + newRowNumber = 0; + } + + setSoftFocusPosition({ + ...softFocusPosition, + row: newRowNumber, + }); + }, + [setSoftFocusPosition], + ); + + const moveDown = useRecoilCallback( + ({ snapshot }) => + () => { + const softFocusPosition = snapshot + .getLoadable(softFocusPositionState) + .valueOrThrow(); + + const numberOfTableRows = snapshot + .getLoadable(numberOfTableRowsSelectorState) + .valueOrThrow(); + + let newRowNumber = softFocusPosition.row + 1; + + if (newRowNumber >= numberOfTableRows) { + newRowNumber = numberOfTableRows - 1; + } + + setSoftFocusPosition({ + ...softFocusPosition, + row: newRowNumber, + }); + }, + [setSoftFocusPosition], + ); + + const moveRight = useRecoilCallback( + ({ snapshot }) => + () => { + const softFocusPosition = snapshot + .getLoadable(softFocusPositionState) + .valueOrThrow(); + + const numberOfTableColumns = snapshot + .getLoadable(numberOfTableColumnsSelectorState) + .valueOrThrow(); + + const numberOfTableRows = snapshot + .getLoadable(numberOfTableRowsSelectorState) + .valueOrThrow(); + + const currentColumnNumber = softFocusPosition.column; + const currentRowNumber = softFocusPosition.row; + + const isLastRowAndLastColumn = + currentColumnNumber === numberOfTableColumns - 1 && + currentRowNumber === numberOfTableRows - 1; + + const isLastColumnButNotLastRow = + currentColumnNumber === numberOfTableColumns - 1 && + currentRowNumber !== numberOfTableRows - 1; + + const isNotLastColumn = + currentColumnNumber !== numberOfTableColumns - 1; + + if (isLastRowAndLastColumn) { + return; + } + + if (isNotLastColumn) { + setSoftFocusPosition({ + row: currentRowNumber, + column: currentColumnNumber + 1, + }); + } else if (isLastColumnButNotLastRow) { + setSoftFocusPosition({ + row: currentRowNumber + 1, + column: TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN, + }); + } + }, + [setSoftFocusPosition], + ); + + const moveLeft = useRecoilCallback( + ({ snapshot }) => + () => { + const softFocusPosition = snapshot + .getLoadable(softFocusPositionState) + .valueOrThrow(); + + const numberOfTableColumns = snapshot + .getLoadable(numberOfTableColumnsSelectorState) + .valueOrThrow(); + + const currentColumnNumber = softFocusPosition.column; + const currentRowNumber = softFocusPosition.row; + + const isFirstRowAndFirstColumn = + currentColumnNumber === + TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN && + currentRowNumber === 0; + + const isFirstColumnButNotFirstRow = + currentColumnNumber === + TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN && + currentRowNumber > 0; + + const isNotFirstColumn = + currentColumnNumber > + TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN; + + if (isFirstRowAndFirstColumn) { + return; + } + + if (isNotFirstColumn) { + setSoftFocusPosition({ + row: currentRowNumber, + column: currentColumnNumber - 1, + }); + } else if (isFirstColumnButNotFirstRow) { + setSoftFocusPosition({ + row: currentRowNumber - 1, + column: numberOfTableColumns - 1, + }); + } + }, + [setSoftFocusPosition, TABLE_MIN_COLUMN_NUMBER_BECAUSE_OF_CHECKBOX_COLUMN], + ); + + return { + moveDown, + moveLeft, + moveRight, + moveUp, + }; +} diff --git a/front/src/modules/ui/tables/hooks/useSetSoftFocusPosition.ts b/front/src/modules/ui/tables/hooks/useSetSoftFocusPosition.ts new file mode 100644 index 000000000..97b8926a9 --- /dev/null +++ b/front/src/modules/ui/tables/hooks/useSetSoftFocusPosition.ts @@ -0,0 +1,21 @@ +import { useRecoilCallback } from 'recoil'; + +import { isSoftFocusOnCellFamilyState } from '../states/isSoftFocusOnCellFamilyState'; +import { softFocusPositionState } from '../states/softFocusPositionState'; +import { TablePosition } from '../types/TablePosition'; + +export function useSetSoftFocusPosition() { + return useRecoilCallback(({ set, snapshot }) => { + return (newPosition: TablePosition) => { + const currentPosition = snapshot + .getLoadable(softFocusPositionState) + .valueOrThrow(); + + set(isSoftFocusOnCellFamilyState(currentPosition), false); + + set(softFocusPositionState, newPosition); + + set(isSoftFocusOnCellFamilyState(newPosition), true); + }; + }, []); +} diff --git a/front/src/modules/ui/tables/states/CellContext.ts b/front/src/modules/ui/tables/states/CellContext.ts new file mode 100644 index 000000000..10724b0bf --- /dev/null +++ b/front/src/modules/ui/tables/states/CellContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const CellContext = createContext(null); diff --git a/front/src/modules/ui/tables/states/RowContext.ts b/front/src/modules/ui/tables/states/RowContext.ts new file mode 100644 index 000000000..8b6ad6acc --- /dev/null +++ b/front/src/modules/ui/tables/states/RowContext.ts @@ -0,0 +1,3 @@ +import { createContext } from 'react'; + +export const RowContext = createContext(null); diff --git a/front/src/modules/ui/tables/states/currentColumnNumberScopedState.ts b/front/src/modules/ui/tables/states/currentColumnNumberScopedState.ts new file mode 100644 index 000000000..5ff4ea655 --- /dev/null +++ b/front/src/modules/ui/tables/states/currentColumnNumberScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const currentColumnNumberScopedState = atomFamily({ + key: 'currentColumnNumberScopedState', + default: 0, +}); diff --git a/front/src/modules/ui/tables/states/currentRowNumberScopedState.ts b/front/src/modules/ui/tables/states/currentRowNumberScopedState.ts new file mode 100644 index 000000000..fb70d46a9 --- /dev/null +++ b/front/src/modules/ui/tables/states/currentRowNumberScopedState.ts @@ -0,0 +1,6 @@ +import { atomFamily } from 'recoil'; + +export const currentRowNumberScopedState = atomFamily({ + key: 'currentRowNumberScopedState', + default: 0, +}); diff --git a/front/src/modules/ui/tables/states/entityTableDimensionsState.ts b/front/src/modules/ui/tables/states/entityTableDimensionsState.ts new file mode 100644 index 000000000..62078f9ca --- /dev/null +++ b/front/src/modules/ui/tables/states/entityTableDimensionsState.ts @@ -0,0 +1,11 @@ +import { atom } from 'recoil'; + +import { TableDimensions } from '../hooks/useInitializeEntityTable'; + +export const entityTableDimensionsState = atom({ + key: 'entityTableDimensionsState', + default: { + numberOfRows: 0, + numberOfColumns: 0, + }, +}); diff --git a/front/src/modules/ui/tables/states/isSoftFocusOnCellFamilyState.ts b/front/src/modules/ui/tables/states/isSoftFocusOnCellFamilyState.ts new file mode 100644 index 000000000..972514805 --- /dev/null +++ b/front/src/modules/ui/tables/states/isSoftFocusOnCellFamilyState.ts @@ -0,0 +1,8 @@ +import { atomFamily } from 'recoil'; + +import { TablePosition } from '../types/TablePosition'; + +export const isSoftFocusOnCellFamilyState = atomFamily({ + key: 'isSoftFocusOnCellFamilyState', + default: false, +}); diff --git a/front/src/modules/ui/tables/states/numberOfTableColumnsSelectorState.ts b/front/src/modules/ui/tables/states/numberOfTableColumnsSelectorState.ts new file mode 100644 index 000000000..4bcbd5171 --- /dev/null +++ b/front/src/modules/ui/tables/states/numberOfTableColumnsSelectorState.ts @@ -0,0 +1,12 @@ +import { selector } from 'recoil'; + +import { entityTableDimensionsState } from './entityTableDimensionsState'; + +export const numberOfTableColumnsSelectorState = selector({ + key: 'numberOfTableColumnsState', + get: ({ get }) => { + const { numberOfColumns } = get(entityTableDimensionsState); + + return numberOfColumns; + }, +}); diff --git a/front/src/modules/ui/tables/states/numberOfTableRowsSelectorState.ts b/front/src/modules/ui/tables/states/numberOfTableRowsSelectorState.ts new file mode 100644 index 000000000..bd601ec4b --- /dev/null +++ b/front/src/modules/ui/tables/states/numberOfTableRowsSelectorState.ts @@ -0,0 +1,12 @@ +import { selector } from 'recoil'; + +import { entityTableDimensionsState } from './entityTableDimensionsState'; + +export const numberOfTableRowsSelectorState = selector({ + key: 'numberOfTableRowsState', + get: ({ get }) => { + const { numberOfRows } = get(entityTableDimensionsState); + + return numberOfRows; + }, +}); diff --git a/front/src/modules/ui/tables/states/softFocusPositionState.ts b/front/src/modules/ui/tables/states/softFocusPositionState.ts new file mode 100644 index 000000000..f1b95f84c --- /dev/null +++ b/front/src/modules/ui/tables/states/softFocusPositionState.ts @@ -0,0 +1,11 @@ +import { atom } from 'recoil'; + +import { TablePosition } from '../types/TablePosition'; + +export const softFocusPositionState = atom({ + key: 'softFocusPositionState', + default: { + row: 0, + column: 1, + }, +}); diff --git a/front/src/modules/ui/tables/types/TableDimensions.ts b/front/src/modules/ui/tables/types/TableDimensions.ts new file mode 100644 index 000000000..5dde85478 --- /dev/null +++ b/front/src/modules/ui/tables/types/TableDimensions.ts @@ -0,0 +1,4 @@ +export type TablePosition = { + numberOfRows: number; + numberOfColumns: number; +}; diff --git a/front/src/modules/ui/tables/types/TablePosition.ts b/front/src/modules/ui/tables/types/TablePosition.ts new file mode 100644 index 000000000..21c4829c4 --- /dev/null +++ b/front/src/modules/ui/tables/types/TablePosition.ts @@ -0,0 +1,4 @@ +export type TablePosition = { + row: number; + column: number; +}; diff --git a/front/src/modules/ui/tables/types/guards/isTablePosition.ts b/front/src/modules/ui/tables/types/guards/isTablePosition.ts new file mode 100644 index 000000000..381494f0f --- /dev/null +++ b/front/src/modules/ui/tables/types/guards/isTablePosition.ts @@ -0,0 +1,7 @@ +import { TablePosition } from '../TablePosition'; + +export function isTablePosition(value: any): value is TablePosition { + return ( + value && typeof value.row === 'number' && typeof value.column === 'number' + ); +} diff --git a/front/src/modules/utils/hotkeys/isNonTextWritingKey.ts b/front/src/modules/utils/hotkeys/isNonTextWritingKey.ts new file mode 100644 index 000000000..aa7856917 --- /dev/null +++ b/front/src/modules/utils/hotkeys/isNonTextWritingKey.ts @@ -0,0 +1,57 @@ +export function isNonTextWritingKey(key: string) { + const nonTextWritingKeys = [ + 'Enter', + 'Tab', + 'Shift', + 'Escape', + 'ArrowUp', + 'ArrowDown', + 'ArrowLeft', + 'ArrowRight', + 'Delete', + 'Backspace', + 'F1', + 'F2', + 'F3', + 'F4', + 'F5', + 'F6', + 'F7', + 'F8', + 'F9', + 'F10', + 'F11', + 'F12', + 'Meta', + 'Alt', + 'Control', + 'CapsLock', + 'NumLock', + 'ScrollLock', + 'Pause', + 'Insert', + 'Home', + 'PageUp', + 'Delete', + 'End', + 'PageDown', + 'ContextMenu', + 'PrintScreen', + 'BrowserBack', + 'BrowserForward', + 'BrowserRefresh', + 'BrowserStop', + 'BrowserSearch', + 'BrowserFavorites', + 'BrowserHome', + 'VolumeMute', + 'VolumeDown', + 'VolumeUp', + 'MediaTrackNext', + 'MediaTrackPrevious', + 'MediaStop', + 'MediaPlayPause', + ]; + + return nonTextWritingKeys.includes(key); +} diff --git a/front/src/pages/companies/Companies.tsx b/front/src/pages/companies/Companies.tsx index 8fc6dcf2e..afed04361 100644 --- a/front/src/pages/companies/Companies.tsx +++ b/front/src/pages/companies/Companies.tsx @@ -17,6 +17,7 @@ import { import { SelectedFilterType } from '@/filters-and-sorts/interfaces/filters/interface'; import { EntityTableActionBar } from '@/ui/components/table/action-bar/EntityTableActionBar'; import { EntityTable } from '@/ui/components/table/EntityTable'; +import { HooksEntityTable } from '@/ui/components/table/HooksEntityTable'; import { IconBuildingSkyscraper } from '@/ui/icons/index'; import { IconList } from '@/ui/icons/index'; import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'; @@ -85,6 +86,10 @@ export function Companies() { > <> + { /> ), cell: (props) => ( - - - + ), }), ]; diff --git a/front/src/pages/people/People.tsx b/front/src/pages/people/People.tsx index ab79905ff..c0e94ea3b 100644 --- a/front/src/pages/people/People.tsx +++ b/front/src/pages/people/People.tsx @@ -17,6 +17,7 @@ import { } from '@/people/services'; import { EntityTableActionBar } from '@/ui/components/table/action-bar/EntityTableActionBar'; import { EntityTable } from '@/ui/components/table/EntityTable'; +import { HooksEntityTable } from '@/ui/components/table/HooksEntityTable'; import { IconList, IconUser } from '@/ui/icons/index'; import { WithTopBarContainer } from '@/ui/layout/containers/WithTopBarContainer'; import { @@ -76,6 +77,7 @@ export function People() { const peopleColumns = usePeopleColumns(); const theme = useTheme(); + return ( <> + { viewIcon={} /> ), - cell: (props) => ( - - - - ), + cell: (props) => , size: 150, }), columnHelper.accessor('phone', {