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:
committed by
GitHub
parent
b0a1002838
commit
c721fa8502
@@ -29,6 +29,7 @@ export const LogicFunctionLogs = ({
|
||||
label={t`Logs`}
|
||||
value={value}
|
||||
height={height}
|
||||
maxRows={5}
|
||||
readOnly
|
||||
/>
|
||||
<ResizeHandle
|
||||
|
||||
+15
@@ -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}
|
||||
|
||||
+97
@@ -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>
|
||||
);
|
||||
};
|
||||
+357
-1456
File diff suppressed because it is too large
Load Diff
+4
@@ -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,
|
||||
|
||||
+34
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
+1
@@ -156,6 +156,7 @@ export const ConfigVariableDatabaseInput = ({
|
||||
<TextArea
|
||||
textAreaId={jsonArrayTextAreaId}
|
||||
label={label}
|
||||
maxRows={5}
|
||||
value={
|
||||
Array.isArray(value)
|
||||
? JSON.stringify(value)
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+1
@@ -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?.()}
|
||||
|
||||
+1
@@ -135,6 +135,7 @@ export const SettingsDevelopersWebhookForm = ({
|
||||
textAreaId={descriptionTextAreaId}
|
||||
placeholder={t`Write a description`}
|
||||
minRows={4}
|
||||
maxRows={5}
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
/>
|
||||
|
||||
+1
@@ -48,6 +48,7 @@ export const SettingsLogicFunctionNewForm = ({
|
||||
textAreaId={descriptionTextAreaId}
|
||||
placeholder={t`Description`}
|
||||
minRows={4}
|
||||
maxRows={5}
|
||||
value={formValues.description}
|
||||
onChange={onChange('description')}
|
||||
readOnly={readonly}
|
||||
|
||||
+1
@@ -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}
|
||||
|
||||
+18
-1
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user