Add Edit Rich Text functionality to table view (#11390)

Fixes https://github.com/twentyhq/core-team-issues/issues/729


[recording.webm](https://github.com/user-attachments/assets/ea95d67b-64a3-4fef-91ed-b06318099a78)

---------

Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Gaurav
2025-04-14 17:16:22 +05:30
committed by GitHub
parent 8f7a82f177
commit abecdbafc1
12 changed files with 329 additions and 52 deletions

View File

@ -2,6 +2,7 @@ import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage';
import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/search/components/CommandMenuSearchRecordsPage';
import { CommandMenuWorkflowSelectAction } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction';
import { CommandMenuWorkflowEditStep } from '@/command-menu/pages/workflow/step/edit/components/CommandMenuWorkflowEditStep';
@ -18,6 +19,7 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
[CommandMenuPages.ViewRecord, <CommandMenuRecordPage />],
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
[CommandMenuPages.EditRichText, <CommandMenuEditRichTextPage />],
[
CommandMenuPages.WorkflowStepSelectTriggerType,
<CommandMenuWorkflowSelectTriggerType />,

View File

@ -0,0 +1,45 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { viewableRichTextComponentState } from '@/command-menu/pages/rich-text-page/states/viewableRichTextComponentState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useCallback } from 'react';
import { useRecoilCallback } from 'recoil';
import { IconPencil } from 'twenty-ui/display';
export const useRichTextCommandMenu = () => {
const { navigateCommandMenu, openCommandMenu } = useCommandMenu();
const openRichTextInCommandMenu = useRecoilCallback(
({ set }) =>
({
activityId,
activityObjectNameSingular,
}: {
activityId: string;
activityObjectNameSingular: string;
}) => {
set(viewableRichTextComponentState, {
activityId,
activityObjectNameSingular,
});
openCommandMenu();
navigateCommandMenu({
page: CommandMenuPages.EditRichText,
pageTitle: 'Rich Text',
pageIcon: IconPencil,
});
},
[navigateCommandMenu, openCommandMenu],
);
const editRichText = useCallback(
(activityId: string, activityObjectNameSingular: string) => {
openRichTextInCommandMenu({ activityId, activityObjectNameSingular });
},
[openRichTextInCommandMenu],
);
return {
editRichText,
};
};

View File

@ -0,0 +1,33 @@
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { viewableRichTextComponentState } from '../states/viewableRichTextComponentState';
const StyledContainer = styled.div`
margin: ${({ theme }) => theme.spacing(4)} ${({ theme }) => theme.spacing(-2)};
`;
export const CommandMenuEditRichTextPage = () => {
const { activityId, activityObjectNameSingular } = useRecoilValue(
viewableRichTextComponentState,
);
if (
activityObjectNameSingular !== CoreObjectNameSingular.Note &&
activityObjectNameSingular !== CoreObjectNameSingular.Task
) {
throw new Error(
`Invalid activity object name singular: ${activityObjectNameSingular}`,
);
}
return (
<StyledContainer>
<ActivityRichTextEditor
activityId={activityId}
activityObjectNameSingular={activityObjectNameSingular}
/>
</StyledContainer>
);
};

View File

@ -0,0 +1,12 @@
import { atom, RecoilState } from 'recoil';
export const viewableRichTextComponentState: RecoilState<{
activityId: string;
activityObjectNameSingular: string;
}> = atom({
key: 'viewableRichTextComponentState',
default: {
activityId: '',
activityObjectNameSingular: '',
},
});

View File

@ -3,6 +3,7 @@ export enum CommandMenuPages {
ViewRecord = 'view-record',
ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event',
EditRichText = 'edit-rich-text',
Copilot = 'copilot',
WorkflowStepSelectTriggerType = 'workflow-step-select-trigger-type',
WorkflowStepSelectAction = 'workflow-step-select-action',

View File

@ -13,7 +13,9 @@ import { SelectFieldInput } from '@/object-record/record-field/meta-types/input/
import { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { ArrayFieldInput } from '@/object-record/record-field/meta-types/input/components/ArrayFieldInput';
import { RichTextFieldInput } from '@/object-record/record-field/meta-types/input/components/RichTextFieldInput';
import { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean';
@ -28,6 +30,7 @@ import { isFieldNumber } from '@/object-record/record-field/types/guards/isField
import { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject';
import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput';
@ -38,6 +41,7 @@ import { RatingFieldInput } from '../meta-types/input/components/RatingFieldInpu
import { RelationToOneFieldInput } from '../meta-types/input/components/RelationToOneFieldInput';
import { TextFieldInput } from '../meta-types/input/components/TextFieldInput';
import { FieldInputEvent } from '../types/FieldInputEvent';
import { FieldRichTextV2Metadata } from '../types/FieldMetadata';
import { isFieldText } from '../types/guards/isFieldText';
type FieldInputProps = {
@ -64,7 +68,7 @@ export const FieldInput = ({
onClickOutside,
isReadOnly,
}: FieldInputProps) => {
const { fieldDefinition } = useContext(FieldContext);
const { fieldDefinition, recordId } = useContext(FieldContext);
return (
<>
@ -161,6 +165,22 @@ export const FieldInput = ({
onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)}
/>
) : isFieldRichTextV2(fieldDefinition) ? (
<RichTextFieldInput
targetableObject={{
id: recordId,
targetObjectNameSingular: (
fieldDefinition as {
metadata: FieldRichTextV2Metadata;
}
).metadata.objectMetadataNameSingular as
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task,
}}
onCancel={onCancel}
onClickOutside={onClickOutside}
onEscape={onEscape}
/>
) : (
<></>
)}

View File

@ -1,60 +1,89 @@
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { useRichTextField } from '@/object-record/record-field/meta-types/hooks/useRichTextField';
import { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useRichTextCommandMenu } from '@/command-menu/hooks/useRichTextCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useRegisterInputEvents } from '@/object-record/record-field/meta-types/input/hooks/useRegisterInputEvents';
import { FieldInputClickOutsideEvent } from '@/object-record/record-field/types/FieldInputEvent';
import {
FieldInputClickOutsideEvent,
FieldInputEvent,
} from '@/object-record/record-field/types/FieldInputEvent';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
import { BlockEditorComponentInstanceContext } from '@/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext';
import { PartialBlock } from '@blocknote/core';
import { useCreateBlockNote } from '@blocknote/react';
import styled from '@emotion/styled';
import { useContext, useRef } from 'react';
const StyledRichTextContainer = styled.div`
height: 400px;
width: 500px;
overflow: auto;
`;
import { useRef } from 'react';
import { IconLayoutSidebarLeftCollapse } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
export type RichTextFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent;
onCancel?: () => void;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
};
export const RichTextFieldInput = ({
onClickOutside,
}: RichTextFieldInputProps) => {
const containerRef = useRef<HTMLDivElement>(null);
const { recordId } = useContext(FieldContext);
const { draftValue, persistRichTextField, fieldDefinition } =
useRichTextField();
const StyledContainer = styled.div`
background-color: ${({ theme }) => theme.background.primary};
width: 480px;
padding: ${({ theme }) => theme.spacing(2)};
margin: 0 0 0 ${({ theme }) => theme.spacing(-6)};
display: flex;
`;
const editor = useCreateBlockNote({
initialContent: draftValue,
domAttributes: { editor: { class: 'editor' } },
schema: BLOCK_SCHEMA,
});
const StyledCollapseButton = styled.div`
border-radius: ${({ theme }) => theme.border.radius.md};
color: ${({ theme }) => theme.font.color.light};
cursor: pointer;
display: flex;
`;
export const RichTextFieldInput = ({
targetableObject,
onClickOutside,
onEscape,
}: {
targetableObject: Pick<ActivityTargetableObject, 'id'> & {
targetObjectNameSingular:
| CoreObjectNameSingular.Note
| CoreObjectNameSingular.Task;
};
} & RichTextFieldInputProps) => {
const { editRichText } = useRichTextCommandMenu();
const containerRef = useRef<HTMLDivElement>(null);
const handleClickOutside = (event: MouseEvent | TouchEvent) => {
onClickOutside?.(() => persistRichTextField(editor.document), event);
onClickOutside?.(() => {}, event);
};
useRegisterInputEvents<PartialBlock[]>({
const handleEscape = () => {
onEscape?.(() => {});
};
useRegisterInputEvents({
inputRef: containerRef,
inputValue: draftValue,
inputValue: null,
onClickOutside: handleClickOutside,
onEscape: handleEscape,
hotkeyScope: DEFAULT_CELL_SCOPE.scope,
});
return (
<StyledRichTextContainer ref={containerRef}>
<BlockEditorComponentInstanceContext.Provider
value={{ instanceId: `${recordId}-${fieldDefinition.fieldMetadataId}` }}
>
<BlockEditor editor={editor} />
</BlockEditorComponentInstanceContext.Provider>
</StyledRichTextContainer>
<StyledContainer ref={containerRef}>
<ActivityRichTextEditor
activityId={targetableObject.id}
activityObjectNameSingular={targetableObject.targetObjectNameSingular}
/>
<StyledCollapseButton>
<FloatingIconButton
Icon={IconLayoutSidebarLeftCollapse}
size="small"
onClick={() => {
onEscape?.(() => {});
editRichText(
targetableObject.id,
targetableObject.targetObjectNameSingular,
);
}}
/>
</StyledCollapseButton>
</StyledContainer>
);
};

View File

@ -0,0 +1,132 @@
import { expect, fn, userEvent, waitFor, within } from '@storybook/test';
import { useEffect } from 'react';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/states/contexts/RecordFieldComponentInstanceContext';
import { DEFAULT_CELL_SCOPE } from '@/object-record/record-table/record-table-cell/hooks/useOpenRecordTableCellV2';
import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope';
import { Decorator, Meta, StoryObj } from '@storybook/react';
import { FieldMetadataType } from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
import { RichTextFieldInput } from '../RichTextFieldInput';
const clickOutsideJestFn = fn();
const escapeJestFn = fn();
type RichTextFieldInputWithContextProps = {
targetableObjectId?: string;
onClickOutside?: typeof clickOutsideJestFn;
onEscape?: typeof escapeJestFn;
};
const clearMocksDecorator: Decorator = (Story, context) => {
if (context.parameters.clearMocks !== false) {
clickOutsideJestFn.mockClear();
escapeJestFn.mockClear();
}
return <Story />;
};
const RichTextFieldInputWithContext = ({
targetableObjectId = 'test-id',
onClickOutside,
onEscape,
}: RichTextFieldInputWithContextProps) => {
const setHotKeyScope = useSetHotkeyScope();
useEffect(() => {
setHotKeyScope(DEFAULT_CELL_SCOPE.scope);
}, [setHotKeyScope]);
return (
<RecordFieldComponentInstanceContext.Provider
value={{
instanceId: 'record-field-component-instance-id',
}}
>
<FieldContext.Provider
value={{
recordId: targetableObjectId,
fieldDefinition: {
fieldMetadataId: 'richText',
label: 'Rich Text',
type: FieldMetadataType.RICH_TEXT,
iconName: 'IconRichText',
metadata: {
fieldName: 'richText',
objectMetadataNameSingular: 'note',
},
},
isLabelIdentifier: false,
isReadOnly: false,
}}
>
<RichTextFieldInput
targetableObject={{
id: targetableObjectId,
targetObjectNameSingular: CoreObjectNameSingular.Note,
}}
onClickOutside={onClickOutside}
onEscape={onEscape}
/>
</FieldContext.Provider>
<div data-testid="click-outside-element" />
</RecordFieldComponentInstanceContext.Provider>
);
};
const meta: Meta = {
title: 'UI/Data/Field/Input/RichTextFieldInput',
component: RichTextFieldInputWithContext,
args: {
targetableObjectId: 'test-id',
onClickOutside: clickOutsideJestFn,
onEscape: escapeJestFn,
},
argTypes: {
onClickOutside: { control: false },
onEscape: { control: false },
},
decorators: [
clearMocksDecorator,
SnackBarDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
],
parameters: {
clearMocks: true,
},
};
export default meta;
type Story = StoryObj<typeof RichTextFieldInputWithContext>;
export const Default: Story = {};
export const Escape: Story = {
play: async () => {
expect(escapeJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
userEvent.keyboard('{esc}');
expect(escapeJestFn).toHaveBeenCalledTimes(1);
});
},
};
export const ClickOutside: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(0);
await waitFor(() => {
const outsideElement = canvas.getByTestId('click-outside-element');
userEvent.click(outsideElement);
expect(clickOutsideJestFn).toHaveBeenCalledTimes(1);
});
},
};

View File

@ -54,12 +54,12 @@ describe('isFieldValueReadOnly', () => {
expect(result).toBe(true);
});
it('should return true if fieldType is RICH_TEXT_V2', () => {
it('should return false if fieldType is RICH_TEXT_V2', () => {
const result = isFieldValueReadOnly({
fieldType: FieldMetadataType.RICH_TEXT_V2,
});
expect(result).toBe(true);
expect(result).toBe(false);
});
it('should return true if fieldType is ACTOR', () => {

View File

@ -2,7 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText';
import { isFieldRichTextV2 } from '@/object-record/record-field/types/guards/isFieldRichTextV2';
import { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -61,9 +61,7 @@ export const isFieldValueReadOnly = ({
if (
isDefined(fieldType) &&
(isFieldActor({ type: fieldType }) ||
isFieldRichText({ type: fieldType }) ||
isFieldRichTextV2({ type: fieldType }))
(isFieldActor({ type: fieldType }) || isFieldRichText({ type: fieldType }))
) {
return true;
}

View File

@ -55,11 +55,16 @@ export const FieldsCard = ({
);
const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy(
availableFieldMetadataItems.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
),
availableFieldMetadataItems
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.name !== 'createdAt' &&
fieldMetadataItem.name !== 'deletedAt',
)
.filter(
(fieldMetadataItem) =>
fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2,
),
(fieldMetadataItem) =>
fieldMetadataItem.type === FieldMetadataType.RELATION
? 'relationFieldMetadataItems'

View File

@ -1,4 +1,5 @@
import { FieldInput } from '@/object-record/record-field/components/FieldInput';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext';
import {
FieldInputClickOutsideEvent,
@ -12,7 +13,6 @@ import { useRecoilCallback } from 'recoil';
export const RecordTableCellFieldInput = () => {
const { onMoveFocus, onCloseTableCell } = useRecordTableBodyContextOrThrow();
const { isReadOnly } = useContext(FieldContext);
const handleEnter: FieldInputEvent = (persistField) => {