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
@@ -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}