diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index e4b442d13f..c90df66801 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -1,50 +1,29 @@ -import { useCallback, useMemo } from 'react'; -import { useAtom, useStore } from 'jotai'; +import { useStore } from 'jotai'; +import { useCallback, useRef } from 'react'; import { v4 } from 'uuid'; -import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { canCreateActivityState } from '@/activities/states/canCreateActivityState'; -import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; -import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { CoreObjectNameSingular } from 'twenty-shared/types'; -import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; -import { Key } from 'ts-key-enum'; -import { useDebouncedCallback } from 'use-debounce'; - -import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; -import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect'; -import { type Attachment } from '@/activities/files/types/Attachment'; import { type Note } from '@/activities/types/Note'; import { type Task } from '@/activities/types/Task'; -import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; -import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; +import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig'; import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; +import { RichTextFieldEditor } from '@/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; -import { BlockEditor } from '@/blocknote-editor/components/BlockEditor'; -import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig'; -import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync'; -import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote'; -import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls'; +import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId'; import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; -import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; -import { t } from '@lingui/core/macro'; -import '@blocknote/core/fonts/inter.css'; -import '@blocknote/mantine/style.css'; -import { useCreateBlockNote } from '@blocknote/react'; -import '@blocknote/react/style.css'; +import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { Key } from 'ts-key-enum'; +import { type CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { FeatureFlagKey } from '~/generated-metadata/graphql'; type ActivityRichTextEditorProps = { activityId: string; @@ -58,187 +37,96 @@ export const ActivityRichTextEditor = ({ activityObjectNameSingular, }: ActivityRichTextEditorProps) => { const store = useStore(); - const [activityInStore] = useAtom( - recordStoreFamilyState.atomFamily(activityId), - ); - const cache = useApolloCoreClient().cache; - const activity = activityInStore as Task | Note | null; + // oxlint-disable-next-line twenty/no-state-useref + const editorRef = useRef(null); - const { objectMetadataItem: objectMetadataItemActivity } = - useObjectMetadataItem({ - objectNameSingular: activityObjectNameSingular, - }); + const { upsertActivity } = useUpsertActivity({ + activityObjectNameSingular, + }); - const bodyV2FieldMetadataItem = objectMetadataItemActivity.fields.find( - (field) => field.name === 'bodyV2', + const [canCreateActivity, setCanCreateActivity] = useAtomState( + canCreateActivityState, ); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { removeFocusItemFromFocusStackById } = useRemoveFocusItemFromFocusStackById(); - const isAttachmentMigrated = useIsFeatureEnabled( - FeatureFlagKey.IS_ATTACHMENT_MIGRATED, - ); - - const attachmentTargetFieldIdName = getActivityTargetObjectFieldIdName({ - nameSingular: activityObjectNameSingular, - isMorphRelation: isAttachmentMigrated, - }); - - const { records: attachments } = useFindManyRecords({ - objectNameSingular: CoreObjectNameSingular.Attachment, - filter: { - [attachmentTargetFieldIdName]: { - eq: activityId, - }, - }, - }); - - const { syncAttachments } = useAttachmentSync(attachments); - - const { upsertActivity } = useUpsertActivity({ - activityObjectNameSingular: activityObjectNameSingular, - }); - - const isRecordFieldReadOnly = useIsRecordFieldReadOnly({ - recordId: activityId, - objectMetadataId: objectMetadataItemActivity.id, - fieldMetadataId: bodyV2FieldMetadataItem?.id ?? '', - }); - - const persistBodyDebounced = useDebouncedCallback((blocknote: string) => { - if (isRecordFieldReadOnly === true) return; - - const input = { - bodyV2: { - blocknote, - markdown: null, - }, - }; - - if (isDefined(activity)) { - upsertActivity({ - activity, - input, - }); - } - }, 300); - - const [canCreateActivity, setCanCreateActivity] = useAtomState( - canCreateActivityState, - ); - - const { uploadAttachmentFile } = useUploadAttachmentFile(); - - const handleUploadAttachment = async (file: File) => { - return await uploadAttachmentFile(file, { - id: activityId, - targetObjectNameSingular: activityObjectNameSingular, + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ + objectNameSingular: activityObjectNameSingular, }); - }; + + const recordTitleCellId = getRecordFieldInputInstanceId({ + recordId: activityId, + fieldName: labelIdentifierFieldMetadataItem?.name, + prefix: RecordTitleCellContainerType.ShowPage, + }); const handlePersistBody = useCallback( - (activityBody: string) => { + (blocknote: string) => { if (!canCreateActivity) { setCanCreateActivity(true); } - persistBodyDebounced(prepareBodyWithSignedUrls(activityBody)); - }, - [canCreateActivity, persistBodyDebounced, setCanCreateActivity], - ); - const handleBodyChange = useCallback( - async (newStringifiedBody: string) => { - const oldActivity = store.get( + const activity = store.get( recordStoreFamilyState.atomFamily(activityId), - ); + ) as Task | Note | null; - store.set( - recordStoreFamilyState.atomFamily(activityId), - (prev: typeof oldActivity) => ({ - ...prev, - id: activityId, - bodyV2: { - blocknote: newStringifiedBody, - markdown: null, + if (isDefined(activity)) { + upsertActivity({ + activity, + input: { + bodyV2: { blocknote, markdown: null }, }, - __typename: 'Activity', - }), - ); - - modifyRecordFromCache({ - recordId: activityId, - fieldModifiers: { - bodyV2: () => { - return { - blocknote: newStringifiedBody, - markdown: null, - }; - }, - }, - cache, - objectMetadataItem: objectMetadataItemActivity, - }); - - handlePersistBody(newStringifiedBody); - - await syncAttachments(newStringifiedBody, oldActivity?.bodyV2.blocknote); + }); + } }, [ + canCreateActivity, + setCanCreateActivity, store, activityId, - cache, - objectMetadataItemActivity, - handlePersistBody, - syncAttachments, + upsertActivity, ], ); - const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); + const handleFocus = useCallback(() => { + const isRecordTitleCellOpen = store.get( + isTitleCellInEditModeComponentState.atomFamily({ + instanceId: recordTitleCellId, + }), + ); - const handleEditorChange = () => { - const newStringifiedBody = JSON.stringify(editor.document) ?? ''; - - handleBodyChangeDebounced(newStringifiedBody); - }; - - const initialBody = useMemo(() => { - if (!isDefined(activity)) { - return undefined; + if (isRecordTitleCellOpen) { + editorRef.current?.domElement?.blur(); + return; } - return parseInitialBlocknote( - activity?.bodyV2?.blocknote, - `Failed to parse body for activity ${activityId}, for rich text version 'v2'`, + pushFocusItemToFocusStack({ + component: { + instanceId: activityId, + type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, + }, + focusId: activityId, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, + }); + }, [recordTitleCellId, activityId, pushFocusItemToFocusStack, store]); + + const handleBlur = useCallback(() => { + const isRecordTitleCellOpen = store.get( + isTitleCellInEditModeComponentState.atomFamily({ + instanceId: recordTitleCellId, + }), ); - }, [activity, activityId]); - const handleEditorBuiltInUploadFile = async (file: File) => { - const { attachmentAbsoluteURL } = await handleUploadAttachment(file); + if (isRecordTitleCellOpen) { + return; + } - return attachmentAbsoluteURL; - }; - - const editor = useCreateBlockNote({ - initialContent: initialBody, - domAttributes: { editor: { class: 'editor' } }, - schema: BLOCK_SCHEMA, - uploadFile: handleEditorBuiltInUploadFile, - placeholders: { - default: t`Type '/' for commands, '@' for mentions`, - }, - }); - - useHotkeysOnFocusedElement({ - keys: Key.Escape, - callback: () => { - editor.domElement?.blur(); - }, - focusId: activityId, - dependencies: [editor], - }); + removeFocusItemFromFocusStackById({ focusId: activityId }); + }, [activityId, recordTitleCellId, removeFocusItemFromFocusStackById, store]); const focusRichTextEditorWhenFocusOnSidePanel = ( keyboardEvent: KeyboardEvent, @@ -256,6 +144,12 @@ export const ActivityRichTextEditor = ({ return; } + const editor = editorRef.current; + + if (!editor) { + return; + } + keyboardEvent.preventDefault(); keyboardEvent.stopPropagation(); keyboardEvent.stopImmediatePropagation(); @@ -281,77 +175,15 @@ export const ActivityRichTextEditor = ({ dependencies: [focusRichTextEditorWhenFocusOnSidePanel], }); - const { labelIdentifierFieldMetadataItem } = - useLabelIdentifierFieldMetadataItem({ - objectNameSingular: activityObjectNameSingular, - }); - - const recordTitleCellId = getRecordFieldInputInstanceId({ - recordId: activityId, - fieldName: labelIdentifierFieldMetadataItem?.name, - // TODO: see comments below, this is a very temporary fix, - // it won't work for the breadcrumb title input, but that's ok for now. - prefix: RecordTitleCellContainerType.ShowPage, - }); - - // TODO: Here instead of closing the input, as it was intially planned, we should block if there is anything open, - // This information should be derived from the focus stack - // The problem with this library is that it takes the focus before anything else and does not prevent the event from bubbling - // Because of this, other events listen at the same time, and when we're in luck, the click outside gets triggered, - // but this leaves the door open for unpredicted behavior with click handlers conflicts, - // we recently had a bug which was deleting what the user typed and closed the side panel if he used backspace key. - // We could maybe use the types of components in the focus stack. - const handleBlockEditorFocus = useCallback(() => { - const isRecordTitleCellOpen = store.get( - isTitleCellInEditModeComponentState.atomFamily({ - instanceId: recordTitleCellId, - }), - ); - - if (isRecordTitleCellOpen) { - editor.domElement?.blur(); - return; - } - - pushFocusItemToFocusStack({ - component: { - instanceId: activityId, - type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, - }, - focusId: activityId, - globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, - }); - }, [recordTitleCellId, activityId, editor, pushFocusItemToFocusStack, store]); - - const handlerBlockEditorBlur = useCallback(() => { - const isRecordTitleCellOpen = store.get( - isTitleCellInEditModeComponentState.atomFamily({ - instanceId: recordTitleCellId, - }), - ); - - if (isRecordTitleCellOpen) { - return; - } - - removeFocusItemFromFocusStackById({ - focusId: activityId, - }); - }, [activityId, recordTitleCellId, removeFocusItemFromFocusStackById, store]); - return ( - <> - - - + ); }; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx deleted file mode 100644 index 64b8c83e82..0000000000 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; -import { useReplaceActivityBlockEditorContent } from '@/activities/hooks/useReplaceActivityBlockEditorContent'; -import { useEffect, useState } from 'react'; - -type ActivityRichTextEditorChangeOnActivityIdEffectProps = { - activityId: string; - editor: typeof BLOCK_SCHEMA.BlockNoteEditor; -}; - -export const ActivityRichTextEditorChangeOnActivityIdEffect = ({ - activityId, - editor, -}: ActivityRichTextEditorChangeOnActivityIdEffectProps) => { - const { replaceBlockEditorContent } = - useReplaceActivityBlockEditorContent(editor); - - const [currentActivityId, setCurrentActivityId] = useState(activityId); - - useEffect(() => { - if (currentActivityId !== activityId) { - replaceBlockEditorContent(activityId); - setCurrentActivityId(activityId); - } - }, [activityId, currentActivityId, replaceBlockEditorContent]); - - return <>; -}; diff --git a/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts b/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts deleted file mode 100644 index f6cb4a7392..0000000000 --- a/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useCallback } from 'react'; -import { useStore } from 'jotai'; - -import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; -import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; -import { isNonEmptyString } from '@sniptt/guards'; -import { isDefined } from 'twenty-shared/utils'; -import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; - -export const useReplaceActivityBlockEditorContent = ( - editor: typeof BLOCK_SCHEMA.BlockNoteEditor, -) => { - const store = useStore(); - const replaceBlockEditorContent = useCallback( - (activityId: string) => { - if (isDefined(editor)) { - const activityInStore = store.get( - recordStoreFamilyState.atomFamily(activityId), - ); - - const content = isNonEmptyString(activityInStore?.bodyV2.blocknote) - ? JSON.parse(activityInStore?.bodyV2.blocknote) - : [{ type: 'paragraph', content: '' }]; - - if (!isDeeplyEqual(editor.document, content)) { - editor.replaceBlocks(editor.document, content); - } - } - }, - [store, editor], - ); - - return { - replaceBlockEditorContent, - }; -}; diff --git a/packages/twenty-front/src/modules/blocknote-editor/hooks/useReplaceBlockEditorContent.ts b/packages/twenty-front/src/modules/blocknote-editor/hooks/useReplaceBlockEditorContent.ts new file mode 100644 index 0000000000..8090a3e8ce --- /dev/null +++ b/packages/twenty-front/src/modules/blocknote-editor/hooks/useReplaceBlockEditorContent.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; +import { useStore } from 'jotai'; + +import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { isNonEmptyString } from '@sniptt/guards'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useReplaceBlockEditorContent = ( + editor: typeof BLOCK_SCHEMA.BlockNoteEditor, + fieldName: string, +) => { + const store = useStore(); + + const replaceBlockEditorContent = useCallback( + (recordId: string) => { + const record = store.get(recordStoreFamilyState.atomFamily(recordId)); + + const fieldValue = record?.[fieldName] as + | { blocknote?: string | null } + | undefined; + + const content = isNonEmptyString(fieldValue?.blocknote) + ? JSON.parse(fieldValue.blocknote) + : [{ type: 'paragraph', content: '' }]; + + if (!isDeeplyEqual(editor.document, content)) { + editor.replaceBlocks(editor.document, content); + } + }, + [store, editor, fieldName], + ); + + return { replaceBlockEditorContent }; +}; diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts index 8057906d3b..f8c3b1a71d 100644 --- a/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts @@ -74,4 +74,19 @@ describe('getFirstNonEmptyLineOfRichText', () => { const result = getFirstNonEmptyLineOfRichText(input); expect(result).toBe('First non-empty line'); }); + + it('should handle non-array string content (e.g. heading blocks)', () => { + const input = [{ content: 'Hello heading' }] as unknown as PartialBlock[]; + const result = getFirstNonEmptyLineOfRichText(input); + expect(result).toBe('Hello heading'); + }); + + it('should skip empty non-array string content', () => { + const input = [ + { content: ' ' }, + { content: [{ text: 'Fallback text', type: 'text', styles: {} }] }, + ] as unknown as PartialBlock[]; + const result = getFirstNonEmptyLineOfRichText(input); + expect(result).toBe('Fallback text'); + }); }); diff --git a/packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts index 8205fb95fe..9cbe1f690c 100644 --- a/packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts @@ -9,19 +9,25 @@ export const getFirstNonEmptyLineOfRichText = ( } for (const block of blocks) { if (!isUndefinedOrNull(block.content)) { - const contentArray = block.content as Array< - { text: string } | { link: string } - >; - if (contentArray.length > 0) { - for (const content of contentArray) { - if ('link' in content) { - return content.link; + const contentArray = Array.isArray(block.content) + ? (block.content as Array<{ text: string } | { link: string }>) + : [block.content as { text: string } | { link: string } | string]; + + for (const content of contentArray) { + if (typeof content === 'string') { + const value = content.trim(); + if (value !== '') { + return value; } - if ('text' in content) { - const value = content.text.trim(); - if (value !== '') { - return value; - } + continue; + } + if ('link' in content) { + return content.link; + } + if ('text' in content) { + const value = content.text.trim(); + if (value !== '') { + return value; } } } diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts index 36ecc5c57f..4c9a6f1552 100644 --- a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts @@ -6,7 +6,7 @@ import { categorizeRelationFields } from '@/object-record/record-field-list/util import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import groupBy from 'lodash.groupby'; -import { FieldMetadataType } from 'twenty-shared/types'; +import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; type UseFieldListFieldMetadataItemsProps = { @@ -65,7 +65,12 @@ export const useFieldListFieldMetadataItems = ({ ) .filter( (fieldMetadataItem) => - fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT, + !( + fieldMetadataItem.type === FieldMetadataType.RICH_TEXT && + fieldMetadataItem.name === 'bodyV2' && + (objectNameSingular === CoreObjectNameSingular.Note || + objectNameSingular === CoreObjectNameSingular.Task) + ), ), (fieldMetadataItem) => fieldMetadataItem.type === FieldMetadataType.RELATION || diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts index 1e7cd68250..631257df0a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/__mocks__/fieldDefinitions.ts @@ -6,6 +6,7 @@ import { FieldLinksMetadata, FieldMorphRelationMetadata, FieldRatingMetadata, + FieldRichTextMetadata, FieldSelectMetadata, FieldTextMetadata, } from '@/object-record/record-field/ui/types/FieldMetadata'; @@ -131,6 +132,17 @@ export const linksFieldDefinition: FieldDefinition = { }, }; +export const richTextFieldDefinition: FieldDefinition = { + fieldMetadataId, + label: 'Description', + iconName: 'IconAlignLeft', + type: FieldMetadataType.RICH_TEXT, + defaultValue: { blocknote: null, markdown: null }, + metadata: { + fieldName: 'description', + }, +}; + export const morphRelationFieldDefinition: FieldDefinition = { fieldMetadataId, diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx new file mode 100644 index 0000000000..276880c25b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor.tsx @@ -0,0 +1,286 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useAtom, useStore } from 'jotai'; + +import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema'; +import { BlockEditor } from '@/blocknote-editor/components/BlockEditor'; +import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig'; +import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync'; +import { useReplaceBlockEditorContent } from '@/blocknote-editor/hooks/useReplaceBlockEditorContent'; +import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote'; +import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls'; +import { type Attachment } from '@/activities/files/types/Attachment'; +import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; +import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { modifyRecordFromCache } from '@/object-record/cache/utils/modifyRecordFromCache'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly'; +import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack'; +import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById'; +import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType'; +import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { t } from '@lingui/core/macro'; +import '@blocknote/core/fonts/inter.css'; +import '@blocknote/mantine/style.css'; +import { useCreateBlockNote } from '@blocknote/react'; +import '@blocknote/react/style.css'; +import { Key } from 'ts-key-enum'; +import { isDefined } from 'twenty-shared/utils'; +import { useDebouncedCallback } from 'use-debounce'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +type RichTextFieldEditorProps = { + recordId: string; + objectNameSingular: string; + fieldName: string; + onPersistBody?: (blocknote: string) => void; + onFocus?: () => void; + onBlur?: () => void; + editorRef?: React.MutableRefObject< + typeof BLOCK_SCHEMA.BlockNoteEditor | null + >; +}; + +export const RichTextFieldEditor = ({ + recordId, + objectNameSingular, + fieldName, + onPersistBody, + onFocus: onFocusOverride, + onBlur: onBlurOverride, + editorRef, +}: RichTextFieldEditorProps) => { + const store = useStore(); + const [recordInStore] = useAtom(recordStoreFamilyState.atomFamily(recordId)); + + const cache = useApolloCoreClient().cache; + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const fieldMetadataItem = objectMetadataItem.fields.find( + (field) => field.name === fieldName, + ); + + const { updateOneRecord } = useUpdateOneRecord(); + + const isRecordFieldReadOnly = useIsRecordFieldReadOnly({ + recordId, + objectMetadataId: objectMetadataItem.id, + fieldMetadataId: fieldMetadataItem?.id ?? '', + }); + + const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); + const { removeFocusItemFromFocusStackById } = + useRemoveFocusItemFromFocusStackById(); + + const focusId = `${recordId}-${fieldName}`; + + const isAttachmentMigrated = useIsFeatureEnabled( + FeatureFlagKey.IS_ATTACHMENT_MIGRATED, + ); + + const attachmentTargetFieldIdName = getActivityTargetObjectFieldIdName({ + nameSingular: objectNameSingular, + isMorphRelation: isAttachmentMigrated, + }); + + const { records: attachments } = useFindManyRecords({ + objectNameSingular: CoreObjectNameSingular.Attachment, + filter: { + [attachmentTargetFieldIdName]: { + eq: recordId, + }, + }, + }); + + const { syncAttachments } = useAttachmentSync(attachments); + + const { uploadAttachmentFile } = useUploadAttachmentFile(); + + const handleUploadAttachment = async (file: File) => { + return await uploadAttachmentFile(file, { + id: recordId, + targetObjectNameSingular: objectNameSingular, + }); + }; + + const handleEditorBuiltInUploadFile = async (file: File) => { + const { attachmentAbsoluteURL } = await handleUploadAttachment(file); + + return attachmentAbsoluteURL; + }; + + const persistBodyDebounced = useDebouncedCallback((blocknote: string) => { + if (isRecordFieldReadOnly === true) return; + + if (onPersistBody) { + onPersistBody(blocknote); + return; + } + + updateOneRecord({ + idToUpdate: recordId, + objectNameSingular, + updateOneRecordInput: { + [fieldName]: { + blocknote, + markdown: null, + }, + }, + }); + }, 300); + + const handleBodyChange = useCallback( + async (newStringifiedBody: string) => { + const oldRecord = store.get(recordStoreFamilyState.atomFamily(recordId)); + + store.set( + recordStoreFamilyState.atomFamily(recordId), + (prev: typeof oldRecord) => ({ + ...prev, + id: recordId, + [fieldName]: { + blocknote: newStringifiedBody, + markdown: null, + }, + __typename: prev?.__typename ?? objectNameSingular, + }), + ); + + modifyRecordFromCache({ + recordId, + fieldModifiers: { + [fieldName]: () => ({ + blocknote: newStringifiedBody, + markdown: null, + }), + }, + cache, + objectMetadataItem, + }); + + persistBodyDebounced(prepareBodyWithSignedUrls(newStringifiedBody)); + + const oldFieldValue = oldRecord?.[fieldName] as + | { blocknote?: string | null } + | undefined; + + await syncAttachments(newStringifiedBody, oldFieldValue?.blocknote); + }, + [ + store, + recordId, + fieldName, + objectNameSingular, + cache, + objectMetadataItem, + persistBodyDebounced, + syncAttachments, + ], + ); + + const handleBodyChangeDebounced = useDebouncedCallback(handleBodyChange, 500); + + const handleEditorChange = () => { + const newStringifiedBody = JSON.stringify(editor.document) ?? ''; + + handleBodyChangeDebounced(newStringifiedBody); + }; + + const fieldValue = isDefined(recordInStore) + ? (recordInStore as Record)?.[ + fieldName + ] + : null; + + const initialBody = useMemo(() => { + if (!isDefined(fieldValue)) { + return undefined; + } + + return parseInitialBlocknote( + fieldValue?.blocknote, + `Failed to parse body for field ${fieldName} on record ${recordId}`, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fieldName, recordId]); + + const editor = useCreateBlockNote({ + initialContent: initialBody, + domAttributes: { editor: { class: 'editor' } }, + schema: BLOCK_SCHEMA, + uploadFile: handleEditorBuiltInUploadFile, + placeholders: { + default: t`Type '/' for commands, '@' for mentions`, + }, + }); + + if (editorRef) { + editorRef.current = editor; + } + + const { replaceBlockEditorContent } = useReplaceBlockEditorContent( + editor, + fieldName, + ); + + const [currentRecordId, setCurrentRecordId] = useState(recordId); + + useEffect(() => { + if (currentRecordId !== recordId) { + replaceBlockEditorContent(recordId); + setCurrentRecordId(recordId); + } + }, [recordId, currentRecordId, replaceBlockEditorContent]); + + useHotkeysOnFocusedElement({ + keys: Key.Escape, + callback: () => { + editor.domElement?.blur(); + }, + focusId, + dependencies: [editor], + }); + + const handleBlockEditorFocus = useCallback(() => { + if (onFocusOverride) { + onFocusOverride(); + return; + } + + pushFocusItemToFocusStack({ + component: { + instanceId: focusId, + type: FocusComponentType.ACTIVITY_RICH_TEXT_EDITOR, + }, + focusId, + globalHotkeysConfig: BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG, + }); + }, [focusId, pushFocusItemToFocusStack, onFocusOverride]); + + const handleBlockEditorBlur = useCallback(() => { + if (onBlurOverride) { + onBlurOverride(); + return; + } + + removeFocusItemFromFocusStackById({ focusId }); + }, [focusId, removeFocusItemFromFocusStackById, onBlurOverride]); + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput.tsx index 0a69020ee3..bae7509b50 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/input/components/RichTextFieldInput.tsx @@ -1,6 +1,6 @@ import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; import { useOpenRichTextInSidePanel } from '@/side-panel/hooks/useOpenRichTextInSidePanel'; -import { type CoreObjectNameSingular } from 'twenty-shared/types'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents'; import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; @@ -22,6 +22,14 @@ const ActivityRichTextEditor = lazy(() => })), ); +const RichTextFieldEditor = lazy(() => + import( + '@/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor' + ).then((module) => ({ + default: module.RichTextFieldEditor, + })), +); + const StyledContainer = styled.div` background-color: ${themeCssVariables.background.primary}; box-sizing: border-box; @@ -52,19 +60,23 @@ const LoadingSkeleton = () => { ); }; + +const isActivityObject = ( + objectNameSingular: string, +): objectNameSingular is + | CoreObjectNameSingular.Note + | CoreObjectNameSingular.Task => + objectNameSingular === CoreObjectNameSingular.Note || + objectNameSingular === CoreObjectNameSingular.Task; + export const RichTextFieldInput = () => { const { fieldDefinition, recordId } = useContext(FieldContext); - const targetableObject = { - id: recordId, - targetObjectNameSingular: ( - fieldDefinition as { - metadata: FieldRichTextMetadata; - } - ).metadata.objectMetadataNameSingular as - | CoreObjectNameSingular.Note - | CoreObjectNameSingular.Task, - }; + const metadata = (fieldDefinition as { metadata: FieldRichTextMetadata }) + .metadata; + + const objectNameSingular = metadata.objectMetadataNameSingular ?? ''; + const fieldName = metadata.fieldName; const { openRichTextInSidePanel } = useOpenRichTextInSidePanel(); const containerRef = useRef(null); @@ -93,10 +105,18 @@ export const RichTextFieldInput = () => { return ( }> - + {isActivityObject(objectNameSingular) ? ( + + ) : ( + + )} { size="small" onClick={() => { onEscape?.({ skipPersist: true }); - openRichTextInSidePanel( - targetableObject.id, - targetableObject.targetObjectNameSingular, - ); + openRichTextInSidePanel(recordId, objectNameSingular, fieldName); }} /> diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isFieldValueEmpty.test.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isFieldValueEmpty.test.ts index e92401551e..2906582f48 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isFieldValueEmpty.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/__tests__/isFieldValueEmpty.test.ts @@ -5,6 +5,7 @@ import { linksFieldDefinition, morphRelationFieldDefinition, relationFieldDefinition, + richTextFieldDefinition, selectFieldDefinition, } from '@/object-record/record-field/ui/__mocks__/fieldDefinitions'; import { type FieldDefinition } from '@/object-record/record-field/ui/types/FieldDefinition'; @@ -215,6 +216,46 @@ describe('isFieldValueEmpty', () => { ).toBe(false); }); + it('should return correct value for rich text field', () => { + expect( + isFieldValueEmpty({ + fieldDefinition: richTextFieldDefinition, + fieldValue: null, + }), + ).toBe(true); + + expect( + isFieldValueEmpty({ + fieldDefinition: richTextFieldDefinition, + fieldValue: { blocknote: null, markdown: null }, + }), + ).toBe(true); + + expect( + isFieldValueEmpty({ + fieldDefinition: richTextFieldDefinition, + fieldValue: { blocknote: '', markdown: null }, + }), + ).toBe(true); + + expect( + isFieldValueEmpty({ + fieldDefinition: richTextFieldDefinition, + fieldValue: { blocknote: '[{"type":"paragraph"}]', markdown: null }, + }), + ).toBe(false); + + expect( + isFieldValueEmpty({ + fieldDefinition: richTextFieldDefinition, + fieldValue: { + blocknote: '[{"type":"paragraph"}]', + markdown: 'some text', + }, + }), + ).toBe(false); + }); + it('should return correct value for morph relation field', () => { expect( isFieldValueEmpty({ diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts index 01316c0d3d..ec6f8ad2f7 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/utils/isFieldValueEmpty.ts @@ -179,7 +179,9 @@ export const isFieldValueEmpty = ({ if (isFieldRichText(fieldDefinition)) { return ( - !isFieldRichTextValue(fieldValue) || isValueEmpty(fieldValue?.markdown) + !isFieldRichTextValue(fieldValue) || + (isValueEmpty(fieldValue?.blocknote) && + isValueEmpty(fieldValue?.markdown)) ); } diff --git a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx index fbf4e2b49b..432d3eef90 100644 --- a/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx +++ b/packages/twenty-front/src/modules/settings/data-model/fields/forms/components/SettingsDataModelFieldSettingsFormCard.tsx @@ -202,6 +202,7 @@ const previewableTypes = [ FieldMetadataType.RAW_JSON, FieldMetadataType.RELATION, FieldMetadataType.MORPH_RELATION, + FieldMetadataType.RICH_TEXT, FieldMetadataType.SELECT, FieldMetadataType.TEXT, FieldMetadataType.UUID, diff --git a/packages/twenty-front/src/modules/settings/data-model/types/SettingsExcludedFieldType.ts b/packages/twenty-front/src/modules/settings/data-model/types/SettingsExcludedFieldType.ts index 627215b633..2f9e76d0a9 100644 --- a/packages/twenty-front/src/modules/settings/data-model/types/SettingsExcludedFieldType.ts +++ b/packages/twenty-front/src/modules/settings/data-model/types/SettingsExcludedFieldType.ts @@ -3,5 +3,5 @@ import { type PickLiteral } from '~/types/PickLiteral'; export type SettingsExcludedFieldType = PickLiteral< FieldType, - 'POSITION' | 'TS_VECTOR' | 'RICH_TEXT' | 'NUMERIC' + 'POSITION' | 'TS_VECTOR' | 'NUMERIC' >; diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRichTextInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRichTextInSidePanel.ts index 83984f90e1..f561a5e867 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRichTextInSidePanel.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRichTextInSidePanel.ts @@ -12,10 +12,11 @@ export const useOpenRichTextInSidePanel = () => { const store = useStore(); const openRichTextInSidePanel = useCallback( - (activityId: string, activityObjectNameSingular: string) => { + (recordId: string, objectNameSingular: string, fieldName?: string) => { store.set(viewableRichTextComponentState.atom, { - activityId, - activityObjectNameSingular, + recordId, + objectNameSingular, + fieldName: fieldName ?? 'bodyV2', }); openSidePanelMenu(); diff --git a/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx b/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx index 50826d9e8c..c7de651251 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx +++ b/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/components/SidePanelEditRichTextPage.tsx @@ -13,6 +13,14 @@ const ActivityRichTextEditor = lazy(() => })), ); +const RichTextFieldEditor = lazy(() => + import( + '@/object-record/record-field/ui/meta-types/input/components/RichTextFieldEditor' + ).then((module) => ({ + default: module.RichTextFieldEditor, + })), +); + const StyledContainer = styled.div` box-sizing: border-box; margin: ${themeCssVariables.spacing[4]} -8px; @@ -33,27 +41,34 @@ const LoadingSkeleton = () => { ); }; +const isActivityObject = ( + objectNameSingular: string, +): objectNameSingular is + | CoreObjectNameSingular.Note + | CoreObjectNameSingular.Task => + objectNameSingular === CoreObjectNameSingular.Note || + objectNameSingular === CoreObjectNameSingular.Task; + export const SidePanelEditRichTextPage = () => { - const { activityId, activityObjectNameSingular } = useAtomStateValue( + const { recordId, objectNameSingular, fieldName } = useAtomStateValue( viewableRichTextComponentState, ); - if ( - activityObjectNameSingular !== CoreObjectNameSingular.Note && - activityObjectNameSingular !== CoreObjectNameSingular.Task - ) { - throw new Error( - `Invalid activity object name singular: ${activityObjectNameSingular}`, - ); - } - return ( }> - + {isActivityObject(objectNameSingular) ? ( + + ) : ( + + )} ); diff --git a/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/states/viewableRichTextComponentState.ts b/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/states/viewableRichTextComponentState.ts index 9b48ecccfc..4b3c843f37 100644 --- a/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/states/viewableRichTextComponentState.ts +++ b/packages/twenty-front/src/modules/side-panel/pages/rich-text-page/states/viewableRichTextComponentState.ts @@ -1,12 +1,14 @@ import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; export const viewableRichTextComponentState = createAtomState<{ - activityId: string; - activityObjectNameSingular: string; + recordId: string; + objectNameSingular: string; + fieldName: string; }>({ key: 'viewableRichTextComponentState', defaultValue: { - activityId: '', - activityObjectNameSingular: '', + recordId: '', + objectNameSingular: '', + fieldName: 'bodyV2', }, }); diff --git a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx index 33865fbf30..be6a3df2a8 100644 --- a/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx +++ b/packages/twenty-front/src/pages/settings/data-model/new-field/SettingsObjectNewFieldSelect.tsx @@ -47,7 +47,6 @@ export const SettingsObjectNewFieldSelect = () => { const excludedFieldTypes: FieldType[] = ( [ FieldMetadataType.NUMERIC, - FieldMetadataType.RICH_TEXT, FieldMetadataType.ACTOR, FieldMetadataType.UUID, ] as const