Add editor mode for text field widgets (#20779)

## Summary

Tested ↓

<img width="3456" height="1990" alt="CleanShot 2026-05-20 at 21 14
04@2x"
src="https://github.com/user-attachments/assets/b4e0d3d3-715f-4ad7-bd03-e8e1922b3c6c"
/>


- Enable `FieldDisplayMode.EDITOR` for plain `TEXT` field widgets while
keeping `FIELD` as the default display mode.
- Add a plain multiline text editor renderer for `TEXT + EDITOR` field
widgets with optimistic record-store/cache updates and debounced
persistence.
- Reuse the shared `TextArea` component through a transparent, uncapped
variant so the editor has no input chrome and lets the widget grow.
- Add unit coverage for text display-mode config and a Storybook
scenario for a text field widget in editor mode.

## useEffect cleanup note

`FieldWidgetTextEditor` flushes the debounced persist callback in a
`useEffect` cleanup:

```ts
useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]);
```

This follows the existing debounced autosave cleanup pattern already
used in `WorkflowEditActionHttpRequest`. It ensures pending text changes
are persisted when the widget unmounts, while `onBlur` still flushes
immediately for normal editor exits.

## Validation

- `npx nx test twenty-front --testPathPattern=page-layout`
- `npx nx typecheck twenty-front`
- `npx nx lint twenty-front`
- Browser check on
`http://apple.localhost:3001/object/company/20202020-a305-41e7-8c72-ba44072a4c58`
for transparent textarea, no internal max-height/scroll, equal padding,
and widget growth.

Note: lint passes with two unrelated existing warnings in
`NavigationDrawerItem.tsx` and `ConfigVariableEdit.tsx`.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Thomas des Francs
2026-05-21 22:29:29 +02:00
committed by GitHub
parent b0a1002838
commit c721fa8502
16 changed files with 568 additions and 1470 deletions
@@ -29,6 +29,7 @@ export const LogicFunctionLogs = ({
label={t`Logs`}
value={value}
height={height}
maxRows={5}
readOnly
/>
<ResizeHandle
@@ -4,6 +4,7 @@ import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/uti
import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { isFieldRichText } from '@/object-record/record-field/ui/types/guards/isFieldRichText';
import { isFieldText } from '@/object-record/record-field/ui/types/guards/isFieldText';
import { hasJunctionConfig } from '@/object-record/record-field/ui/utils/junction/hasJunctionConfig';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { useResolveFieldMetadataIdFromNameOrId } from '@/page-layout/hooks/useResolveFieldMetadataIdFromNameOrId';
@@ -17,6 +18,7 @@ import { FieldWidgetMorphRelationField } from '@/page-layout/widgets/field/compo
import { FieldWidgetRelationCard } from '@/page-layout/widgets/field/components/FieldWidgetRelationCard';
import { FieldWidgetRelationField } from '@/page-layout/widgets/field/components/FieldWidgetRelationField';
import { assertFieldWidgetOrThrow } from '@/page-layout/widgets/field/utils/assertFieldWidgetOrThrow';
import { FieldWidgetTextEditor } from '@/page-layout/widgets/field/components/FieldWidgetTextEditor';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
@@ -179,6 +181,19 @@ export const FieldWidget = ({ widget }: FieldWidgetProps) => {
);
}
if (
isFieldText(fieldDefinition) &&
fieldDisplayMode === FieldDisplayMode.EDITOR
) {
return (
<FieldWidgetTextEditor
fieldMetadataItem={fieldMetadataItem}
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
/>
);
}
return (
<FieldWidgetDisplay
fieldDefinition={fieldDefinition}
@@ -0,0 +1,97 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { isFieldTextValue } from '@/object-record/record-field/ui/types/guards/isFieldTextValue';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
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 { themeCssVariables } from 'twenty-ui/theme-constants';
import { useDebouncedCallback } from 'use-debounce';
const StyledContainer = styled.div`
box-sizing: border-box;
padding: ${themeCssVariables.spacing[4]};
width: 100%;
`;
type FieldWidgetTextEditorProps = {
fieldMetadataItem: FieldMetadataItem;
objectMetadataItem: EnrichedObjectMetadataItem;
recordId: string;
};
export const FieldWidgetTextEditor = ({
fieldMetadataItem,
objectMetadataItem,
recordId,
}: FieldWidgetTextEditorProps) => {
const fieldName = fieldMetadataItem.name;
const { updateOneRecord } = useUpdateOneRecord();
const [fieldValue, setFieldValue] = useAtomFamilySelectorState(
recordStoreFamilySelector,
{
recordId,
fieldName,
},
);
const isRecordFieldReadOnly = useIsRecordFieldReadOnly({
recordId,
objectMetadataId: objectMetadataItem.id,
fieldMetadataId: fieldMetadataItem.id,
});
const textAreaId = `field-widget-text-editor-${recordId}-${fieldName}`;
const persistTextDebounced = useDebouncedCallback((text: string) => {
if (isRecordFieldReadOnly === true) {
return;
}
updateOneRecord({
objectNameSingular: objectMetadataItem.nameSingular,
idToUpdate: recordId,
updateOneRecordInput: {
[fieldName]: text,
},
});
}, 300);
useEffect(() => () => persistTextDebounced.flush(), [persistTextDebounced]);
const handleChange = useCallback(
(text: string) => {
if (isRecordFieldReadOnly === true) {
return;
}
setFieldValue(text);
persistTextDebounced(text);
},
[isRecordFieldReadOnly, persistTextDebounced, setFieldValue],
);
const handleBlur = useCallback(() => {
persistTextDebounced.flush();
}, [persistTextDebounced]);
const fieldTextValue = isFieldTextValue(fieldValue) ? fieldValue : '';
return (
<StyledContainer>
<TextArea
textAreaId={textAreaId}
value={fieldTextValue}
readOnly={isRecordFieldReadOnly}
onChange={handleChange}
onBlur={handleBlur}
variant="transparent"
/>
</StyledContainer>
);
};
@@ -9,6 +9,10 @@ type FieldWidgetFieldTypeConfig = {
export const FIELD_WIDGET_CONFIG: Partial<
Record<FieldMetadataType, FieldWidgetFieldTypeConfig>
> = {
[FieldMetadataType.TEXT]: {
availableDisplayModes: [FieldDisplayMode.FIELD, FieldDisplayMode.EDITOR],
defaultDisplayMode: FieldDisplayMode.FIELD,
},
[FieldMetadataType.RELATION]: {
availableDisplayModes: [FieldDisplayMode.FIELD, FieldDisplayMode.CARD],
defaultDisplayMode: FieldDisplayMode.CARD,
@@ -0,0 +1,34 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldDisplayMode } from '~/generated-metadata/graphql';
import {
getFieldWidgetAvailableDisplayModes,
getFieldWidgetDefaultDisplayMode,
isDisplayModeValidForFieldType,
} from '@/page-layout/widgets/field/utils/getFieldWidgetDisplayModeConfig';
describe('getFieldWidgetDisplayModeConfig', () => {
it('allows text fields to use field or editor display modes', () => {
expect(getFieldWidgetAvailableDisplayModes(FieldMetadataType.TEXT)).toEqual(
[FieldDisplayMode.FIELD, FieldDisplayMode.EDITOR],
);
});
it('keeps field as the default display mode for text fields', () => {
expect(getFieldWidgetDefaultDisplayMode(FieldMetadataType.TEXT)).toBe(
FieldDisplayMode.FIELD,
);
});
it('falls back to field mode for field types that do not support editor mode', () => {
expect(
isDisplayModeValidForFieldType(
FieldMetadataType.NUMBER,
FieldDisplayMode.EDITOR,
),
).toBe(false);
expect(getFieldWidgetDefaultDisplayMode(FieldMetadataType.NUMBER)).toBe(
FieldDisplayMode.FIELD,
);
});
});
@@ -156,6 +156,7 @@ export const ConfigVariableDatabaseInput = ({
<TextArea
textAreaId={jsonArrayTextAreaId}
label={label}
maxRows={5}
value={
Array.isArray(value)
? JSON.stringify(value)
@@ -41,6 +41,7 @@ export const SettingsDataModelFieldDescriptionForm = ({
textAreaId={descriptionTextAreaId}
placeholder={t`Write a description`}
minRows={4}
maxRows={5}
value={value ?? undefined}
onChange={onChange}
disabled={disabled}
@@ -246,6 +246,7 @@ export const SettingsDataModelObjectAboutForm = ({
textAreaId={descriptionTextAreaId}
placeholder={t`Write a description`}
minRows={4}
maxRows={5}
value={value ?? undefined}
onChange={(nextValue) => onChange(nextValue ?? null)}
onBlur={() => onNewDirtyField?.()}
@@ -135,6 +135,7 @@ export const SettingsDevelopersWebhookForm = ({
textAreaId={descriptionTextAreaId}
placeholder={t`Write a description`}
minRows={4}
maxRows={5}
value={value || ''}
onChange={onChange}
/>
@@ -48,6 +48,7 @@ export const SettingsLogicFunctionNewForm = ({
textAreaId={descriptionTextAreaId}
placeholder={t`Description`}
minRows={4}
maxRows={5}
value={formValues.description}
onChange={onChange('description')}
readOnly={readonly}
@@ -87,6 +87,7 @@ export const SettingsRoleSettings = ({
<TextArea
textAreaId={descriptionTextAreaId}
minRows={4}
maxRows={5}
placeholder={t`Write a description`}
value={settingsDraftRole.description || ''}
onChange={(value: string) => {
@@ -9,7 +9,7 @@ import { isDefined } from 'twenty-shared/utils';
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const MAX_ROWS = 5;
type TextAreaVariant = 'default' | 'transparent';
export type TextAreaProps = {
textAreaId: string;
@@ -24,6 +24,7 @@ export type TextAreaProps = {
className?: string;
onBlur?: () => void;
readOnly?: boolean;
variant?: TextAreaVariant;
};
const StyledContainer = styled.div`
@@ -40,26 +41,43 @@ const StyledLabel = styled.label`
margin-bottom: ${themeCssVariables.spacing[1]};
`;
const StyledTextAreaContainer = styled.div`
const StyledTextAreaContainer = styled.div<{ variant: TextAreaVariant }>`
> textarea {
background-color: ${themeCssVariables.background.transparent.lighter};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.sm};
background-color: ${({ variant }) =>
variant === 'transparent'
? 'transparent'
: themeCssVariables.background.transparent.lighter};
border: ${({ variant }) =>
variant === 'transparent'
? 'none'
: `1px solid ${themeCssVariables.border.color.medium}`};
border-radius: ${({ variant }) =>
variant === 'transparent' ? '0' : themeCssVariables.border.radius.sm};
box-sizing: border-box;
color: ${themeCssVariables.font.color.primary};
display: block;
font-family: inherit;
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.regular};
line-height: 16px;
overflow: auto;
padding: ${themeCssVariables.spacing[2]};
line-height: ${({ variant }) =>
variant === 'transparent' ? 'inherit' : '16px'};
overflow: ${({ variant }) =>
variant === 'transparent' ? 'hidden' : 'auto'};
padding: ${({ variant }) =>
variant === 'transparent' ? '0' : themeCssVariables.spacing[2]};
resize: none;
width: 100%;
&:focus {
outline: none;
box-shadow: 0px 0px 0px 3px ${themeCssVariables.color.transparent.blue2};
border-color: ${themeCssVariables.color.blue};
box-shadow: ${({ variant }) =>
variant === 'transparent'
? 'none'
: `0px 0px 0px 3px ${themeCssVariables.color.transparent.blue2}`};
border-color: ${({ variant }) =>
variant === 'transparent'
? 'transparent'
: themeCssVariables.color.blue};
}
&::placeholder {
@@ -80,14 +98,17 @@ export const TextArea = ({
height,
placeholder,
minRows = 1,
maxRows = MAX_ROWS,
maxRows,
value = '',
className,
onChange,
onBlur,
readOnly = false,
variant = 'default',
}: TextAreaProps) => {
const computedMinRows = Math.min(minRows, maxRows);
const computedMinRows = isDefined(maxRows)
? Math.min(minRows, maxRows)
: minRows;
const instanceId = useId();
@@ -117,7 +138,7 @@ export const TextArea = ({
<StyledContainer>
{label && <StyledLabel htmlFor={instanceId}>{label}</StyledLabel>}
<StyledTextAreaContainer>
<StyledTextAreaContainer variant={variant}>
<TextareaAutosize
id={instanceId}
placeholder={placeholder}
@@ -22,7 +22,7 @@ const meta: Meta<typeof TextArea> = {
title: 'UI/Input/TextArea',
component: TextArea,
decorators: [ComponentDecorator],
args: { minRows: 4, placeholder: 'Lorem Ipsum' },
args: { minRows: 4, maxRows: 5, placeholder: 'Lorem Ipsum' },
render: Render,
};
@@ -55,3 +55,20 @@ export const WithLabel: Story = {
expect(input).toHaveFocus();
},
};
export const Transparent: Story = {
args: {
variant: 'transparent',
value: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
},
};
export const TransparentUnbounded: Story = {
args: {
variant: 'transparent',
maxRows: undefined,
value: Array.from({ length: 10 }, (_, index) => `Line ${index + 1}`).join(
'\n',
),
},
};
@@ -481,6 +481,7 @@ export const SettingsSkillForm = ({ mode }: { mode: 'create' | 'edit' }) => {
textAreaId="skill-description-textarea"
placeholder={t`Write a description`}
minRows={3}
maxRows={5}
value={formValues.description}
onChange={(value) =>
handleFieldChange('description', value ?? '')
@@ -229,6 +229,7 @@ export const SettingsToolDetail = () => {
textAreaId="tool-description-textarea"
placeholder={t`Write a description`}
minRows={3}
maxRows={5}
value={editedDescription ?? description ?? ''}
onChange={handleDescriptionChange}
disabled={isReadOnly}