Fixed scoped hotkeys (#6322)

- Removed enabled props from useScopedHotkeys becayse it doesn't work
- Moved enter useScopedHotkeys in a handler that we drill down to the
text inputs on the settings form

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2024-07-19 19:25:12 +02:00
committed by GitHub
parent de20c564c7
commit b64e8096d6
7 changed files with 123 additions and 66 deletions

View File

@ -68,9 +68,6 @@ export const RecordTableCellSoftFocusMode = ({
}, },
TableHotkeyScope.TableSoftFocus, TableHotkeyScope.TableSoftFocus,
[clearField, isFieldClearable, isFieldInputOnly], [clearField, isFieldClearable, isFieldInputOnly],
{
enabled: !isFieldInputOnly,
},
); );
useScopedHotkeys( useScopedHotkeys(

View File

@ -1,8 +1,7 @@
import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { DropResult } from '@hello-pangea/dnd'; import { DropResult } from '@hello-pangea/dnd';
import { Key } from 'ts-key-enum'; import { useState } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { IconPlus } from 'twenty-ui'; import { IconPlus } from 'twenty-ui';
import { z } from 'zod'; import { z } from 'zod';
@ -21,8 +20,6 @@ import { CardContent } from '@/ui/layout/card/components/CardContent';
import { CardFooter } from '@/ui/layout/card/components/CardFooter'; import { CardFooter } from '@/ui/layout/card/components/CardFooter';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { moveArrayItem } from '~/utils/array/moveArrayItem';
import { toSpliced } from '~/utils/array/toSpliced'; import { toSpliced } from '~/utils/array/toSpliced';
@ -189,19 +186,15 @@ export const SettingsDataModelFieldSelectForm = ({
setFormValue('options', newOptions); setFormValue('options', newOptions);
}; };
useScopedHotkeys( const handleInputEnter = () => {
Key.Enter, const newOptions = getOptionsWithNewOption();
() => {
const newOptions = getOptionsWithNewOption();
setFormValue('options', newOptions); setFormValue('options', newOptions);
const lastOptionId = newOptions[newOptions.length - 1].id; const lastOptionId = newOptions[newOptions.length - 1].id;
setFocusedOptionId(lastOptionId); setFocusedOptionId(lastOptionId);
}, };
AppHotkeyScope.App,
);
return ( return (
<> <>
@ -270,6 +263,7 @@ export const SettingsDataModelFieldSelectForm = ({
onRemoveAsDefault={() => onRemoveAsDefault={() =>
handleRemoveOptionAsDefault(option.value) handleRemoveOptionAsDefault(option.value)
} }
onInputEnter={handleInputEnter}
/> />
} }
/> />

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef } from 'react';
import { useTheme } from '@emotion/react'; import { useTheme } from '@emotion/react';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import { useMemo } from 'react';
import { import {
ColorSample, ColorSample,
IconCheck, IconCheck,
@ -31,6 +31,7 @@ type SettingsDataModelFieldSelectFormOptionRowProps = {
onRemove?: () => void; onRemove?: () => void;
onSetAsDefault?: () => void; onSetAsDefault?: () => void;
onRemoveAsDefault?: () => void; onRemoveAsDefault?: () => void;
onInputEnter?: () => void;
option: FieldMetadataItemOption; option: FieldMetadataItemOption;
focused?: boolean; focused?: boolean;
}; };
@ -64,11 +65,10 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
onRemove, onRemove,
onSetAsDefault, onSetAsDefault,
onRemoveAsDefault, onRemoveAsDefault,
onInputEnter,
option, option,
focused, focused,
}: SettingsDataModelFieldSelectFormOptionRowProps) => { }: SettingsDataModelFieldSelectFormOptionRowProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const theme = useTheme(); const theme = useTheme();
const dropdownIds = useMemo(() => { const dropdownIds = useMemo(() => {
@ -84,11 +84,9 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
dropdownIds.actions, dropdownIds.actions,
); );
useEffect(() => { const handleInputEnter = () => {
if (focused === true) { onInputEnter?.();
inputRef.current?.focus(); };
}
}, [focused]);
return ( return (
<StyledRow className={className}> <StyledRow className={className}>
@ -123,8 +121,6 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
} }
/> />
<StyledOptionInput <StyledOptionInput
ref={inputRef}
disableHotkeys
value={option.label} value={option.label}
onChange={(label) => onChange={(label) =>
onChange({ onChange({
@ -133,8 +129,10 @@ export const SettingsDataModelFieldSelectFormOptionRow = ({
value: getOptionValueFromLabel(label), value: getOptionValueFromLabel(label),
}) })
} }
focused={focused}
RightIcon={isDefault ? IconCheck : undefined} RightIcon={isDefault ? IconCheck : undefined}
maxLength={OPTION_VALUE_MAXIMUM_LENGTH} maxLength={OPTION_VALUE_MAXIMUM_LENGTH}
onInputEnter={handleInputEnter}
/> />
<Dropdown <Dropdown
dropdownId={dropdownIds.actions} dropdownId={dropdownIds.actions}

View File

@ -1,4 +1,4 @@
import { FocusEventHandler, forwardRef, ForwardRefRenderFunction } from 'react'; import { FocusEventHandler, useEffect, useRef, useState } from 'react';
import { Key } from 'ts-key-enum'; import { Key } from 'ts-key-enum';
import { import {
@ -10,14 +10,31 @@ import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousH
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { isDefined } from '~/utils/isDefined'; import { isDefined } from '~/utils/isDefined';
export type TextInputComponentProps = TextInputV2ComponentProps & { export type TextInputProps = TextInputV2ComponentProps & {
disableHotkeys?: boolean; disableHotkeys?: boolean;
onInputEnter?: () => void;
focused?: boolean;
}; };
const TextInputComponent: ForwardRefRenderFunction< export const TextInput = ({
HTMLInputElement, onFocus,
TextInputComponentProps onBlur,
> = ({ onFocus, onBlur, disableHotkeys = false, ...props }, ref) => { onInputEnter,
disableHotkeys = false,
focused,
...props
}: TextInputProps) => {
const inputRef = useRef<HTMLInputElement>(null);
const [isFocused, setIsFocused] = useState(false);
useEffect(() => {
if (focused === true) {
inputRef.current?.focus();
setIsFocused(true);
}
}, [focused]);
const { const {
goBackToPreviousHotkeyScope, goBackToPreviousHotkeyScope,
setHotkeyScopeAndMemorizePreviousScope, setHotkeyScopeAndMemorizePreviousScope,
@ -25,6 +42,7 @@ const TextInputComponent: ForwardRefRenderFunction<
const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => { const handleFocus: FocusEventHandler<HTMLInputElement> = (e) => {
onFocus?.(e); onFocus?.(e);
setIsFocused(true);
if (!disableHotkeys) { if (!disableHotkeys) {
setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput); setHotkeyScopeAndMemorizePreviousScope(InputHotkeyScope.TextInput);
@ -33,6 +51,7 @@ const TextInputComponent: ForwardRefRenderFunction<
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => { const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e); onBlur?.(e);
setIsFocused(false);
if (!disableHotkeys) { if (!disableHotkeys) {
goBackToPreviousHotkeyScope(); goBackToPreviousHotkeyScope();
@ -40,19 +59,48 @@ const TextInputComponent: ForwardRefRenderFunction<
}; };
useScopedHotkeys( useScopedHotkeys(
[Key.Escape, Key.Enter], [Key.Escape],
() => { () => {
if (isDefined(ref) && 'current' in ref) { if (!isFocused) {
ref.current?.blur(); return;
}
if (isDefined(inputRef) && 'current' in inputRef) {
inputRef.current?.blur();
setIsFocused(false);
} }
}, },
InputHotkeyScope.TextInput, InputHotkeyScope.TextInput,
{ enabled: !disableHotkeys }, [inputRef, isFocused],
{
preventDefault: false,
},
);
useScopedHotkeys(
[Key.Enter],
() => {
if (!isFocused) {
return;
}
onInputEnter?.();
if (isDefined(inputRef) && 'current' in inputRef) {
inputRef.current?.blur();
setIsFocused(false);
}
},
InputHotkeyScope.TextInput,
[inputRef, isFocused, onInputEnter],
{
preventDefault: false,
},
); );
return ( return (
<TextInputV2 <TextInputV2
ref={ref} ref={inputRef}
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
{...props} {...props}
onFocus={handleFocus} onFocus={handleFocus}
@ -60,5 +108,3 @@ const TextInputComponent: ForwardRefRenderFunction<
/> />
); );
}; };
export const TextInput = forwardRef(TextInputComponent);

View File

@ -1,10 +1,10 @@
import { useState } from 'react';
import { Meta, StoryObj } from '@storybook/react'; import { Meta, StoryObj } from '@storybook/react';
import { useState } from 'react';
import { ComponentDecorator } from 'twenty-ui'; import { ComponentDecorator } from 'twenty-ui';
import { TextInput, TextInputComponentProps } from '../TextInput'; import { TextInput, TextInputProps } from '../TextInput';
type RenderProps = TextInputComponentProps; type RenderProps = TextInputProps;
const Render = (args: RenderProps) => { const Render = (args: RenderProps) => {
const [value, setValue] = useState(args.value); const [value, setValue] = useState(args.value);

View File

@ -1,4 +1,7 @@
import { Hotkey } from 'react-hotkeys-hook/dist/types'; import {
Hotkey,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilCallback } from 'recoil'; import { useRecoilCallback } from 'recoil';
import { logDebug } from '~/utils/logDebug'; import { logDebug } from '~/utils/logDebug';
@ -7,15 +10,19 @@ import { internalHotkeysEnabledScopesState } from '../states/internal/internalHo
export const DEBUG_HOTKEY_SCOPE = false; export const DEBUG_HOTKEY_SCOPE = false;
export const useScopedHotkeyCallback = () => export const useScopedHotkeyCallback = (
useRecoilCallback( dependencies?: OptionsOrDependencyArray,
) => {
const dependencyArray = Array.isArray(dependencies) ? dependencies : [];
return useRecoilCallback(
({ snapshot }) => ({ snapshot }) =>
({ ({
callback, callback,
hotkeysEvent, hotkeysEvent,
keyboardEvent, keyboardEvent,
scope, scope,
preventDefault = true, preventDefault,
}: { }: {
keyboardEvent: KeyboardEvent; keyboardEvent: KeyboardEvent;
hotkeysEvent: Hotkey; hotkeysEvent: Hotkey;
@ -53,7 +60,14 @@ export const useScopedHotkeyCallback = () =>
); );
} }
if (preventDefault) { if (preventDefault === true) {
if (DEBUG_HOTKEY_SCOPE) {
logDebug(
`DEBUG: %cI prevent default for hotkey (${hotkeysEvent.keys})`,
'color: gray;',
);
}
keyboardEvent.stopPropagation(); keyboardEvent.stopPropagation();
keyboardEvent.preventDefault(); keyboardEvent.preventDefault();
keyboardEvent.stopImmediatePropagation(); keyboardEvent.stopImmediatePropagation();
@ -61,5 +75,7 @@ export const useScopedHotkeyCallback = () =>
return callback(keyboardEvent, hotkeysEvent); return callback(keyboardEvent, hotkeysEvent);
}, },
[], // eslint-disable-next-line react-hooks/exhaustive-deps
dependencyArray,
); );
};

View File

@ -1,30 +1,36 @@
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { import { HotkeyCallback, Keys, Options } from 'react-hotkeys-hook/dist/types';
HotkeyCallback,
Keys,
Options,
OptionsOrDependencyArray,
} from 'react-hotkeys-hook/dist/types';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { pendingHotkeyState } from '../states/internal/pendingHotkeysState'; import { pendingHotkeyState } from '../states/internal/pendingHotkeysState';
import { isDefined } from 'twenty-ui';
import { useScopedHotkeyCallback } from './useScopedHotkeyCallback'; import { useScopedHotkeyCallback } from './useScopedHotkeyCallback';
type UseHotkeysOptionsWithoutBuggyOptions = Omit<Options, 'enabled'>;
export const useScopedHotkeys = ( export const useScopedHotkeys = (
keys: Keys, keys: Keys,
callback: HotkeyCallback, callback: HotkeyCallback,
scope: string, scope: string,
dependencies?: OptionsOrDependencyArray, dependencies?: unknown[],
options: Options = { options?: UseHotkeysOptionsWithoutBuggyOptions,
enableOnContentEditable: true,
enableOnFormTags: true,
preventDefault: true,
},
) => { ) => {
const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState); const [pendingHotkey, setPendingHotkey] = useRecoilState(pendingHotkeyState);
const callScopedHotkeyCallback = useScopedHotkeyCallback(); const callScopedHotkeyCallback = useScopedHotkeyCallback(dependencies);
const enableOnContentEditable = isDefined(options?.enableOnContentEditable)
? options.enableOnContentEditable
: true;
const enableOnFormTags = isDefined(options?.enableOnFormTags)
? options.enableOnFormTags
: true;
const preventDefault = isDefined(options?.preventDefault)
? options.preventDefault === true
: true;
return useHotkeys( return useHotkeys(
keys, keys,
@ -40,12 +46,12 @@ export const useScopedHotkeys = (
setPendingHotkey(null); setPendingHotkey(null);
}, },
scope, scope,
preventDefault: !!options.preventDefault, preventDefault,
}); });
}, },
{ {
enableOnContentEditable: options.enableOnContentEditable, enableOnContentEditable,
enableOnFormTags: options.enableOnFormTags, enableOnFormTags,
}, },
dependencies, dependencies,
); );