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 { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage'; import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage'; 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 { CommandMenuSearchRecordsPage } from '@/command-menu/pages/search/components/CommandMenuSearchRecordsPage';
import { CommandMenuWorkflowSelectAction } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction'; import { CommandMenuWorkflowSelectAction } from '@/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectAction';
import { CommandMenuWorkflowEditStep } from '@/command-menu/pages/workflow/step/edit/components/CommandMenuWorkflowEditStep'; 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.ViewRecord, <CommandMenuRecordPage />],
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />], [CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />], [CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
[CommandMenuPages.EditRichText, <CommandMenuEditRichTextPage />],
[ [
CommandMenuPages.WorkflowStepSelectTriggerType, CommandMenuPages.WorkflowStepSelectTriggerType,
<CommandMenuWorkflowSelectTriggerType />, <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', ViewRecord = 'view-record',
ViewEmailThread = 'view-email-thread', ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event', ViewCalendarEvent = 'view-calendar-event',
EditRichText = 'edit-rich-text',
Copilot = 'copilot', Copilot = 'copilot',
WorkflowStepSelectTriggerType = 'workflow-step-select-trigger-type', WorkflowStepSelectTriggerType = 'workflow-step-select-trigger-type',
WorkflowStepSelectAction = 'workflow-step-select-action', 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 { isFieldPhones } from '@/object-record/record-field/types/guards/isFieldPhones';
import { isFieldRelationFromManyObjects } from '@/object-record/record-field/types/guards/isFieldRelationFromManyObjects'; 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 { 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 { isFieldAddress } from '@/object-record/record-field/types/guards/isFieldAddress';
import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray'; import { isFieldArray } from '@/object-record/record-field/types/guards/isFieldArray';
import { isFieldBoolean } from '@/object-record/record-field/types/guards/isFieldBoolean'; 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 { isFieldRating } from '@/object-record/record-field/types/guards/isFieldRating';
import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson'; import { isFieldRawJson } from '@/object-record/record-field/types/guards/isFieldRawJson';
import { isFieldRelationToOneObject } from '@/object-record/record-field/types/guards/isFieldRelationToOneObject'; 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 { isFieldSelect } from '@/object-record/record-field/types/guards/isFieldSelect';
import { FieldContext } from '../contexts/FieldContext'; import { FieldContext } from '../contexts/FieldContext';
import { BooleanFieldInput } from '../meta-types/input/components/BooleanFieldInput'; 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 { RelationToOneFieldInput } from '../meta-types/input/components/RelationToOneFieldInput';
import { TextFieldInput } from '../meta-types/input/components/TextFieldInput'; import { TextFieldInput } from '../meta-types/input/components/TextFieldInput';
import { FieldInputEvent } from '../types/FieldInputEvent'; import { FieldInputEvent } from '../types/FieldInputEvent';
import { FieldRichTextV2Metadata } from '../types/FieldMetadata';
import { isFieldText } from '../types/guards/isFieldText'; import { isFieldText } from '../types/guards/isFieldText';
type FieldInputProps = { type FieldInputProps = {
@ -64,7 +68,7 @@ export const FieldInput = ({
onClickOutside, onClickOutside,
isReadOnly, isReadOnly,
}: FieldInputProps) => { }: FieldInputProps) => {
const { fieldDefinition } = useContext(FieldContext); const { fieldDefinition, recordId } = useContext(FieldContext);
return ( return (
<> <>
@ -161,6 +165,22 @@ export const FieldInput = ({
onCancel={onCancel} onCancel={onCancel}
onClickOutside={(event) => onClickOutside?.(() => {}, event)} 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 { ActivityRichTextEditor } from '@/activities/components/ActivityRichTextEditor';
import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { useRichTextField } from '@/object-record/record-field/meta-types/hooks/useRichTextField'; 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 { 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 { 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 styled from '@emotion/styled';
import { useRef } from 'react';
import { useContext, useRef } from 'react'; import { IconLayoutSidebarLeftCollapse } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
const StyledRichTextContainer = styled.div`
height: 400px;
width: 500px;
overflow: auto;
`;
export type RichTextFieldInputProps = { export type RichTextFieldInputProps = {
onClickOutside?: FieldInputClickOutsideEvent; onClickOutside?: FieldInputClickOutsideEvent;
onCancel?: () => void;
onEnter?: FieldInputEvent;
onEscape?: FieldInputEvent;
}; };
export const RichTextFieldInput = ({ const StyledContainer = styled.div`
onClickOutside, background-color: ${({ theme }) => theme.background.primary};
}: RichTextFieldInputProps) => { width: 480px;
const containerRef = useRef<HTMLDivElement>(null); padding: ${({ theme }) => theme.spacing(2)};
const { recordId } = useContext(FieldContext); margin: 0 0 0 ${({ theme }) => theme.spacing(-6)};
const { draftValue, persistRichTextField, fieldDefinition } = display: flex;
useRichTextField(); `;
const editor = useCreateBlockNote({ const StyledCollapseButton = styled.div`
initialContent: draftValue, border-radius: ${({ theme }) => theme.border.radius.md};
domAttributes: { editor: { class: 'editor' } }, color: ${({ theme }) => theme.font.color.light};
schema: BLOCK_SCHEMA, 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) => { const handleClickOutside = (event: MouseEvent | TouchEvent) => {
onClickOutside?.(() => persistRichTextField(editor.document), event); onClickOutside?.(() => {}, event);
}; };
useRegisterInputEvents<PartialBlock[]>({ const handleEscape = () => {
onEscape?.(() => {});
};
useRegisterInputEvents({
inputRef: containerRef, inputRef: containerRef,
inputValue: draftValue, inputValue: null,
onClickOutside: handleClickOutside, onClickOutside: handleClickOutside,
onEscape: handleEscape,
hotkeyScope: DEFAULT_CELL_SCOPE.scope, hotkeyScope: DEFAULT_CELL_SCOPE.scope,
}); });
return ( return (
<StyledRichTextContainer ref={containerRef}> <StyledContainer ref={containerRef}>
<BlockEditorComponentInstanceContext.Provider <ActivityRichTextEditor
value={{ instanceId: `${recordId}-${fieldDefinition.fieldMetadataId}` }} activityId={targetableObject.id}
> activityObjectNameSingular={targetableObject.targetObjectNameSingular}
<BlockEditor editor={editor} /> />
</BlockEditorComponentInstanceContext.Provider> <StyledCollapseButton>
</StyledRichTextContainer> <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); 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({ const result = isFieldValueReadOnly({
fieldType: FieldMetadataType.RICH_TEXT_V2, fieldType: FieldMetadataType.RICH_TEXT_V2,
}); });
expect(result).toBe(true); expect(result).toBe(false);
}); });
it('should return true if fieldType is ACTOR', () => { 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 { isWorkflowSubObjectMetadata } from '@/object-metadata/utils/isWorkflowSubObjectMetadata';
import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor'; import { isFieldActor } from '@/object-record/record-field/types/guards/isFieldActor';
import { isFieldRichText } from '@/object-record/record-field/types/guards/isFieldRichText'; 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 { isDefined } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql'; import { FieldMetadataType } from '~/generated-metadata/graphql';
@ -61,9 +61,7 @@ export const isFieldValueReadOnly = ({
if ( if (
isDefined(fieldType) && isDefined(fieldType) &&
(isFieldActor({ type: fieldType }) || (isFieldActor({ type: fieldType }) || isFieldRichText({ type: fieldType }))
isFieldRichText({ type: fieldType }) ||
isFieldRichTextV2({ type: fieldType }))
) { ) {
return true; return true;
} }

View File

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

View File

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