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:
committed by
GitHub
parent
b1ada79d72
commit
6ce7d04f9d
+37
-5
@@ -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"
|
||||
/>
|
||||
|
||||
+142
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user