From abecdbafc127959a2b1118dbf515115009d1217c Mon Sep 17 00:00:00 2001 From: Gaurav Date: Mon, 14 Apr 2025 17:16:22 +0530 Subject: [PATCH] Add Edit Rich Text functionality to table view (#11390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../constants/CommandMenuPagesConfig.tsx | 2 + .../hooks/useRichTextCommandMenu.ts | 45 ++++++ .../CommandMenuEditRichTextPage.tsx | 33 +++++ .../states/viewableRichTextComponentState.ts | 12 ++ .../command-menu/types/CommandMenuPages.ts | 1 + .../record-field/components/FieldInput.tsx | 22 ++- .../input/components/RichTextFieldInput.tsx | 107 ++++++++------ .../RichTextFieldInput.stories.tsx | 132 ++++++++++++++++++ .../__tests__/isFieldValueReadOnly.test.ts | 4 +- .../utils/isFieldValueReadOnly.ts | 6 +- .../record-show/components/FieldsCard.tsx | 15 +- .../components/RecordTableCellFieldInput.tsx | 2 +- 12 files changed, 329 insertions(+), 52 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/hooks/useRichTextCommandMenu.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage.tsx create mode 100644 packages/twenty-front/src/modules/command-menu/pages/rich-text-page/states/viewableRichTextComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx index 2ecbad5ae..f4ecbdb70 100644 --- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx +++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx @@ -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, ], [CommandMenuPages.ViewEmailThread, ], [CommandMenuPages.ViewCalendarEvent, ], + [CommandMenuPages.EditRichText, ], [ CommandMenuPages.WorkflowStepSelectTriggerType, , diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useRichTextCommandMenu.ts b/packages/twenty-front/src/modules/command-menu/hooks/useRichTextCommandMenu.ts new file mode 100644 index 000000000..efddebfc7 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/hooks/useRichTextCommandMenu.ts @@ -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, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage.tsx new file mode 100644 index 000000000..2aa1066b3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage.tsx @@ -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 ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/states/viewableRichTextComponentState.ts b/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/states/viewableRichTextComponentState.ts new file mode 100644 index 000000000..39f003c2f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/rich-text-page/states/viewableRichTextComponentState.ts @@ -0,0 +1,12 @@ +import { atom, RecoilState } from 'recoil'; + +export const viewableRichTextComponentState: RecoilState<{ + activityId: string; + activityObjectNameSingular: string; +}> = atom({ + key: 'viewableRichTextComponentState', + default: { + activityId: '', + activityObjectNameSingular: '', + }, +}); diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts index 57bf842fd..6341ceebf 100644 --- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts +++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts @@ -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', diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx index 0b1453f1e..edca66ba9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/FieldInput.tsx @@ -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) ? ( + ) : ( <> )} diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx index 2cbc0ba19..7956192f9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RichTextFieldInput.tsx @@ -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(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 & { + targetObjectNameSingular: + | CoreObjectNameSingular.Note + | CoreObjectNameSingular.Task; + }; +} & RichTextFieldInputProps) => { + const { editRichText } = useRichTextCommandMenu(); + const containerRef = useRef(null); const handleClickOutside = (event: MouseEvent | TouchEvent) => { - onClickOutside?.(() => persistRichTextField(editor.document), event); + onClickOutside?.(() => {}, event); }; - useRegisterInputEvents({ + const handleEscape = () => { + onEscape?.(() => {}); + }; + + useRegisterInputEvents({ inputRef: containerRef, - inputValue: draftValue, + inputValue: null, onClickOutside: handleClickOutside, + onEscape: handleEscape, hotkeyScope: DEFAULT_CELL_SCOPE.scope, }); return ( - - - - - + + + + { + onEscape?.(() => {}); + editRichText( + targetableObject.id, + targetableObject.targetObjectNameSingular, + ); + }} + /> + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx new file mode 100644 index 000000000..d61e1e3b7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RichTextFieldInput.stories.tsx @@ -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 ; +}; + +const RichTextFieldInputWithContext = ({ + targetableObjectId = 'test-id', + onClickOutside, + onEscape, +}: RichTextFieldInputWithContextProps) => { + const setHotKeyScope = useSetHotkeyScope(); + + useEffect(() => { + setHotKeyScope(DEFAULT_CELL_SCOPE.scope); + }, [setHotKeyScope]); + + return ( + + + + +
+ + ); +}; + +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; + +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); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueReadOnly.test.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueReadOnly.test.ts index 69f49bb33..67dcd1243 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueReadOnly.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueReadOnly.test.ts @@ -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', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts index 321ba81cd..8db90998a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueReadOnly.ts @@ -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; } diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx index 04b2846c2..faae2cd10 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx @@ -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' diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx index 0716666d7..e60e79f17 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-cell/components/RecordTableCellFieldInput.tsx @@ -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) => {