Fix field widget textarea focus reset (#21959)

Short description: Keeps the field widget textarea on a local draft
value while focused so record-store rewrites do not reset the active
caret.

# Before

Typing in an editor-mode text field can lose caret position when the
global record store is rewritten by an external record update/refetch.


https://github.com/user-attachments/assets/1ee7a819-2c27-4d09-aae5-814c2cf27181


# After

The focused textarea should preserve the in-progress draft and caret
while still updating sibling previews optimistically and flushing the
final value on blur.


https://github.com/user-attachments/assets/41832493-021f-46e8-bc75-722bbb1cd7b7



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21959?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas des Francs
2026-06-22 17:45:49 +02:00
committed by GitHub
parent b1ada79d72
commit 6ce7d04f9d
4 changed files with 217 additions and 5 deletions
@@ -7,7 +7,7 @@ import { recordStoreFamilySelector } from '@/object-record/record-store/states/s
import { TextArea } from '@/ui/input/components/TextArea';
import { useAtomFamilySelectorState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorState';
import { styled } from '@linaria/react';
import { useCallback, useEffect } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
@@ -46,6 +46,11 @@ export const FieldWidgetTextEditor = ({
});
const textAreaId = `field-widget-text-editor-${recordId}-${fieldName}`;
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
const [draftText, setDraftText] = useState(fieldTextValue);
const [isFocused, setIsFocused] = useState(false);
const [isDraftDirty, setIsDraftDirty] = useState(false);
const persistTextDebounced = useDebouncedCallback((text: string) => {
if (isRecordFieldReadOnly === true) {
@@ -63,12 +68,28 @@ export const FieldWidgetTextEditor = ({
useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]);
useEffect(() => {
if (isFocused) {
return;
}
setDraftText(fieldTextValue);
setIsDraftDirty(false);
}, [fieldTextValue, isFocused]);
const handleFocus = useCallback(() => {
setIsFocused(true);
setIsDraftDirty(false);
}, []);
const handleChange = useCallback(
(text: string) => {
if (isRecordFieldReadOnly === true) {
return;
}
setDraftText(text);
setIsDraftDirty(true);
setFieldValue(text);
persistTextDebounced(text);
@@ -77,18 +98,29 @@ export const FieldWidgetTextEditor = ({
);
const handleBlur = useCallback(() => {
persistTextDebounced.flush();
}, [persistTextDebounced]);
setIsFocused(false);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
if (isDraftDirty && isRecordFieldReadOnly !== true) {
setFieldValue(draftText);
}
persistTextDebounced.flush();
}, [
draftText,
isDraftDirty,
isRecordFieldReadOnly,
persistTextDebounced,
setFieldValue,
]);
return (
<StyledContainer>
<TextArea
textAreaId={textAreaId}
value={fieldTextValue}
value={draftText}
readOnly={isRecordFieldReadOnly}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
variant="transparent"
/>
@@ -0,0 +1,142 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { FieldWidgetTextEditor } from '@/page-layout/widgets/field/components/FieldWidgetTextEditor';
import { getTestEnrichedObjectMetadataItemsMock } from '~/testing/utils/getTestEnrichedObjectMetadataItemsMock';
const mockUpdateOneRecord = jest.fn();
jest.mock('@/object-record/hooks/useUpdateOneRecord', () => ({
useUpdateOneRecord: () => ({
updateOneRecord: mockUpdateOneRecord,
}),
}));
jest.mock('@/object-record/read-only/hooks/useIsRecordFieldReadOnly', () => ({
useIsRecordFieldReadOnly: () => false,
}));
const RECORD_ID = 'record-id';
const FIELD_NAME = 'name';
const objectMetadataItem = getTestEnrichedObjectMetadataItemsMock().find(
({ nameSingular }) => nameSingular === 'opportunity',
);
if (objectMetadataItem === undefined) {
throw new Error('Opportunity object metadata item not found');
}
const fieldMetadataItem = objectMetadataItem.fields.find(
({ name }) => name === FIELD_NAME,
);
if (fieldMetadataItem === undefined) {
throw new Error('Opportunity name field metadata item not found');
}
const createRecord = (fieldValue: string): ObjectRecord => ({
id: RECORD_ID,
__typename: 'Opportunity',
[FIELD_NAME]: fieldValue,
});
const setStoredFieldValue = (
store: ReturnType<typeof createStore>,
fieldValue: string,
) => {
act(() => {
const currentRecord = store.get(
recordStoreFamilyState.atomFamily(RECORD_ID),
);
if (currentRecord === null || currentRecord === undefined) {
throw new Error('Stored record not found');
}
store.set(recordStoreFamilyState.atomFamily(RECORD_ID), {
...currentRecord,
[FIELD_NAME]: fieldValue,
});
});
};
const renderEditor = (initialFieldValue = 'Initial use case') => {
const store = createStore();
store.set(
recordStoreFamilyState.atomFamily(RECORD_ID),
createRecord(initialFieldValue),
);
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={store}>{children}</JotaiProvider>
);
render(
<FieldWidgetTextEditor
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={RECORD_ID}
/>,
{ wrapper: Wrapper },
);
return { store };
};
describe('FieldWidgetTextEditor', () => {
beforeEach(() => {
mockUpdateOneRecord.mockClear();
});
it('does not replace the focused textarea value when the record store changes externally', () => {
const { store } = renderEditor('Initial use case');
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.focus(textarea);
fireEvent.change(textarea, {
target: { value: 'Initial edited use case' },
});
setStoredFieldValue(store, 'Externally refreshed use case');
expect(textarea.value).toBe('Initial edited use case');
});
it('flushes the final draft on blur even after a stale store rewrite while focused', () => {
const { store } = renderEditor('Initial use case');
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.focus(textarea);
fireEvent.change(textarea, {
target: { value: 'Final edited use case' },
});
setStoredFieldValue(store, 'Externally refreshed use case');
fireEvent.blur(textarea);
expect(mockUpdateOneRecord).toHaveBeenCalledTimes(1);
expect(mockUpdateOneRecord).toHaveBeenCalledWith({
objectNameSingular: 'opportunity',
idToUpdate: RECORD_ID,
updateOneRecordInput: {
[FIELD_NAME]: 'Final edited use case',
},
});
expect(
store.get(recordStoreFamilyState.atomFamily(RECORD_ID))?.[FIELD_NAME],
).toBe('Final edited use case');
});
it('syncs external record store changes when the textarea is not focused', () => {
const { store } = renderEditor('Initial use case');
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
setStoredFieldValue(store, 'Externally refreshed use case');
expect(textarea.value).toBe('Externally refreshed use case');
});
});
@@ -22,6 +22,7 @@ export type TextAreaProps = {
placeholder?: string;
value?: string;
className?: string;
onFocus?: () => void;
onBlur?: () => void;
readOnly?: boolean;
variant?: TextAreaVariant;
@@ -102,6 +103,7 @@ export const TextArea = ({
value = '',
className,
onChange,
onFocus,
onBlur,
readOnly = false,
variant = 'default',
@@ -127,6 +129,8 @@ export const TextArea = ({
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
onFocus?.();
};
const handleBlur: FocusEventHandler<HTMLTextAreaElement> = () => {
@@ -0,0 +1,34 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { createStore, Provider as JotaiProvider } from 'jotai';
import { TextArea } from '@/ui/input/components/TextArea';
import { focusStackState } from '@/ui/utilities/focus/states/focusStackState';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
describe('TextArea', () => {
it('calls onFocus after pushing the text area to the focus stack', () => {
const store = createStore();
const onFocus = jest.fn();
const textAreaId = 'test-text-area-id';
render(
<JotaiProvider store={store}>
<TextArea textAreaId={textAreaId} value="" onFocus={onFocus} />
</JotaiProvider>,
);
fireEvent.focus(screen.getByRole('textbox'));
expect(onFocus).toHaveBeenCalledTimes(1);
expect(store.get(focusStackState.atom).at(-1)).toMatchObject({
focusId: textAreaId,
componentInstance: {
componentType: FocusComponentType.TEXT_AREA,
componentInstanceId: textAreaId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
});
});