1eb284c87f
closes https://github.com/twentyhq/twenty/issues/18264 https://github.com/user-attachments/assets/7b576a00-78bc-46a2-9528-d8b3bcbdd530 https://github.com/user-attachments/assets/4102468e-e85f-46a0-8b23-e7abd77bfc95 ### PR description - This fixes flaky persistence in command menu text and number inputs. - moved commit logic to onBlur (single commit path) - Enter now blurs, so it uses the same commit path - Escape now cancels edit (restores draft + exits) without persisting - removed dependency on input click-outside commit timing ### Outcome - - clicking anywhere outside the input now reliably persists edits - Escape consistently discards edits
151 lines
3.9 KiB
TypeScript
151 lines
3.9 KiB
TypeScript
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
|
|
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
|
|
import { TextInput } from '@/ui/input/components/TextInput';
|
|
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
|
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
|
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
|
|
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
|
import styled from '@emotion/styled';
|
|
import { useStore } from 'jotai';
|
|
import { useRef, useState } from 'react';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
import { type IconComponent } from 'twenty-ui/display';
|
|
import {
|
|
canBeCastAsNumberOrNull,
|
|
castAsNumberOrNull,
|
|
} from '~/utils/cast-as-number-or-null';
|
|
|
|
type CommandMenuItemNumberInputProps = {
|
|
id: string;
|
|
label: string;
|
|
Icon?: IconComponent;
|
|
value: string;
|
|
onChange: (value: number | null) => void;
|
|
onValidate?: (value: number | null) => boolean;
|
|
placeholder?: string;
|
|
};
|
|
|
|
const StyledRightAlignedTextInput = styled(TextInput)`
|
|
input {
|
|
text-align: right;
|
|
}
|
|
`;
|
|
|
|
export const CommandMenuItemNumberInput = ({
|
|
id,
|
|
label,
|
|
Icon,
|
|
value,
|
|
onChange,
|
|
onValidate,
|
|
placeholder,
|
|
}: CommandMenuItemNumberInputProps) => {
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const focusId = `${id}-input`;
|
|
const [draftValue, setDraftValue] = useState(value);
|
|
const [hasError, setHasError] = useState(false);
|
|
const store = useStore();
|
|
|
|
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
|
const { removeFocusItemFromFocusStackById } =
|
|
useRemoveFocusItemFromFocusStackById();
|
|
|
|
const handleCommit = (draftValue: string) => {
|
|
if (!canBeCastAsNumberOrNull(draftValue)) {
|
|
setHasError(true);
|
|
return;
|
|
}
|
|
|
|
const numericValue = castAsNumberOrNull(draftValue);
|
|
|
|
if (isDefined(onValidate)) {
|
|
const isValid = onValidate(numericValue);
|
|
if (!isValid) {
|
|
setHasError(true);
|
|
return;
|
|
}
|
|
}
|
|
|
|
onChange(numericValue);
|
|
setHasError(false);
|
|
};
|
|
|
|
const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
|
|
event.target.select();
|
|
pushFocusItemToFocusStack({
|
|
focusId,
|
|
component: {
|
|
type: FocusComponentType.TEXT_INPUT,
|
|
instanceId: focusId,
|
|
},
|
|
globalHotkeysConfig: {
|
|
enableGlobalHotkeysConflictingWithKeyboard: false,
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleBlur = () => {
|
|
const isInputStillFocused =
|
|
store.get(currentFocusIdSelector.atom) === focusId;
|
|
|
|
if (isInputStillFocused && draftValue !== value) {
|
|
handleCommit(draftValue);
|
|
}
|
|
|
|
removeFocusItemFromFocusStackById({ focusId });
|
|
};
|
|
|
|
const handleEscape = () => {
|
|
removeFocusItemFromFocusStackById({ focusId });
|
|
setDraftValue(value);
|
|
setHasError(false);
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
const handleEnter = () => {
|
|
inputRef.current?.blur();
|
|
};
|
|
|
|
useRegisterInputEvents<string>({
|
|
focusId,
|
|
inputRef: inputRef,
|
|
inputValue: draftValue,
|
|
onEscape: handleEscape,
|
|
onEnter: handleEnter,
|
|
});
|
|
|
|
const handleChange = (text: string) => {
|
|
setDraftValue(text);
|
|
if (hasError) {
|
|
setHasError(false);
|
|
}
|
|
};
|
|
|
|
const focusInput = () => {
|
|
inputRef.current?.focus();
|
|
};
|
|
|
|
return (
|
|
<CommandMenuItem
|
|
id={id}
|
|
label={label}
|
|
Icon={Icon}
|
|
onClick={focusInput}
|
|
RightComponent={
|
|
<StyledRightAlignedTextInput
|
|
ref={inputRef}
|
|
value={draftValue}
|
|
sizeVariant="sm"
|
|
onChange={handleChange}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
placeholder={placeholder}
|
|
error={hasError ? ' ' : undefined}
|
|
noErrorHelper
|
|
textClickOutsideId={focusId}
|
|
/>
|
|
}
|
|
/>
|
|
);
|
|
};
|