From 23d8bbbf926a84054328b72b8d7888d88ab84492 Mon Sep 17 00:00:00 2001 From: Rounak Joshi Date: Mon, 20 Oct 2025 21:26:49 +0530 Subject: [PATCH] Refactored WorkflowSendEmailBody to FormAdvancedTextFieldInput (#15157) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 towards fixing issue #14976 Refactored WorkflowSendEmailBody to FormAdvancedTextFieldInput For its reusability across application - `useEmailEditor` is transformed to `useAdvancedTextEditor`: A reuseable hook for managing the text editor state and functionality. - `WorkflowSendEmailBody` is transformed to `FormAdvancedTextFieldEditor`: A wrapper component for the advanced text editor. - `WorkflowSendEmailBody` is transformed to `FormAdvancedTextFieldInput`: A component integrating the advanced text editor into forms. - `ImageBubbleMenu`, `LinkBubbleMenu`, and `TextBubbleMenu`: Contextual menus for image, link, and text formatting options. are moved to ui/components for access in FormAdvancedTextFieldInput Additionally, the email action workflow component was updated to utilize the new reuseable text editor, enhancing the user experience for composing emails. This update also includes storybook entries for the new components to facilitate testing and documentation. --------- Co-authored-by: Félix Malfait Co-authored-by: Félix Malfait --- .../components/AdvancedTextEditor.tsx} | 31 +- .../components}/BubbleMenuIconButton.tsx | 0 .../components}/EditLinkPopover.tsx | 2 +- .../components}/ImageBubbleMenu.tsx | 4 +- .../components}/LinkBubbleMenu.tsx | 6 +- .../components}/TextBubbleMenu.tsx | 10 +- .../components}/TurnIntoBlockDropdown.tsx | 2 +- .../AdvancedTextEditor.stories.tsx} | 49 ++- .../resizable-image/ResizableImage.ts | 2 +- .../resizable-image/ResizableImageView.tsx | 0 .../resizable-image/UploadImageExtension.ts | 2 +- .../resizable-image/UploadImagePlugin.ts | 0 .../hooks/useAdvancedTextEditor.ts} | 14 +- .../hooks/useTextBubbleState.ts | 0 .../hooks/useTurnIntoBlockOptions.ts | 0 .../src/modules/advanced-text-editor/index.ts | 5 + .../utils/isTextSelected.ts | 0 .../FormAdvancedTextFieldInput.tsx} | 158 ++++----- .../FormAdvancedTextFieldInput.stories.tsx | 270 +++++++++++++++ .../WorkflowEditActionSendEmail.tsx | 58 +++- .../WorkflowSendEmailBody.stories.tsx | 320 ------------------ ...=> getInitialAdvancedTextEditorContent.ts} | 2 +- 22 files changed, 474 insertions(+), 461 deletions(-) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx => advanced-text-editor/components/AdvancedTextEditor.tsx} (72%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu => advanced-text-editor/components}/BubbleMenuIconButton.tsx (100%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu => advanced-text-editor/components}/EditLinkPopover.tsx (94%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu => advanced-text-editor/components}/ImageBubbleMenu.tsx (88%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu => advanced-text-editor/components}/LinkBubbleMenu.tsx (77%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu => advanced-text-editor/components}/TextBubbleMenu.tsx (77%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu => advanced-text-editor/components}/TurnIntoBlockDropdown.tsx (95%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx => advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx} (83%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/extensions/resizable-image/ResizableImage.ts (74%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/extensions/resizable-image/ResizableImageView.tsx (100%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/extensions/resizable-image/UploadImageExtension.ts (91%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/extensions/resizable-image/UploadImagePlugin.ts (100%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts => advanced-text-editor/hooks/useAdvancedTextEditor.ts} (82%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/hooks/useTextBubbleState.ts (100%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/hooks/useTurnIntoBlockOptions.ts (100%) create mode 100644 packages/twenty-front/src/modules/advanced-text-editor/index.ts rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action => advanced-text-editor}/utils/isTextSelected.ts (100%) rename packages/twenty-front/src/modules/{workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx => object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput.tsx} (56%) create mode 100644 packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormAdvancedTextFieldInput.stories.tsx delete mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx rename packages/twenty-front/src/modules/workflow/workflow-variables/utils/{getInitialEmailEditorContent.ts => getInitialAdvancedTextEditorContent.ts} (93%) diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx similarity index 72% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx index b80adeceb0..da7e9f889d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/AdvancedTextEditor.tsx @@ -1,15 +1,14 @@ +import { ImageBubbleMenu } from '@/advanced-text-editor/components/ImageBubbleMenu'; +import { LinkBubbleMenu } from '@/advanced-text-editor/components/LinkBubbleMenu'; +import { TextBubbleMenu } from '@/advanced-text-editor/components/TextBubbleMenu'; import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles'; -import { ImageBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu'; -import { LinkBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu'; -import { TextBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu'; import styled from '@emotion/styled'; import { EditorContent, type Editor } from '@tiptap/react'; -const EMAIL_EDITOR_MIN_HEIGHT = 340; -const EMAIL_EDITOR_MAX_WIDTH = 600; - const StyledEditorContainer = styled.div<{ readonly?: boolean; + minHeight: number; + maxWidth: number; }>` height: 100%; display: flex; @@ -21,8 +20,8 @@ const StyledEditorContainer = styled.div<{ flex-grow: 1; width: 100%; height: 100%; - min-height: ${EMAIL_EDITOR_MIN_HEIGHT}px; - max-width: ${EMAIL_EDITOR_MAX_WIDTH}px; + min-height: ${({ minHeight }) => minHeight}px; + max-width: ${({ maxWidth }) => maxWidth}px; margin: 0 auto; } @@ -78,17 +77,25 @@ const StyledEditorContainer = styled.div<{ } `; -type WorkflowEmailEditorProps = { +type AdvancedTextEditorProps = { readonly: boolean | undefined; editor: Editor; + minHeight: number; + maxWidth: number; }; -export const WorkflowEmailEditor = ({ +export const AdvancedTextEditor = ({ readonly, editor, -}: WorkflowEmailEditorProps) => { + minHeight, + maxWidth, +}: AdvancedTextEditorProps) => { return ( - + diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/BubbleMenuIconButton.tsx similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/BubbleMenuIconButton.tsx diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/EditLinkPopover.tsx similarity index 94% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/EditLinkPopover.tsx index c5eb44bf9b..c1a61fff77 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/EditLinkPopover.tsx @@ -1,9 +1,9 @@ +import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton'; import { TextInput } from '@/ui/input/components/TextInput'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown'; -import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton'; import { useLingui } from '@lingui/react/macro'; import { isNonEmptyString } from '@sniptt/guards'; import { type Editor } from '@tiptap/core'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/ImageBubbleMenu.tsx similarity index 88% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/ImageBubbleMenu.tsx index c7eea22f3b..de389a1184 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/ImageBubbleMenu.tsx @@ -1,5 +1,5 @@ -import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton'; -import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu'; +import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton'; +import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/TextBubbleMenu'; import { type Editor } from '@tiptap/core'; import { useEditorState } from '@tiptap/react'; import { BubbleMenu } from '@tiptap/react/menus'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/LinkBubbleMenu.tsx similarity index 77% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/LinkBubbleMenu.tsx index 024a0ace7c..01cd753e15 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/LinkBubbleMenu.tsx @@ -1,6 +1,6 @@ -import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton'; -import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover'; -import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu'; +import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton'; +import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover'; +import { StyledBubbleMenuContainer } from '@/advanced-text-editor/components/TextBubbleMenu'; import { type Editor } from '@tiptap/core'; import { useEditorState } from '@tiptap/react'; import { BubbleMenu } from '@tiptap/react/menus'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx similarity index 77% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx index 8705bdb165..872d15a154 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/TextBubbleMenu.tsx @@ -1,8 +1,8 @@ -import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton'; -import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover'; -import { TurnIntoBlockDropdown } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown'; -import { useTextBubbleState } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState'; -import { isTextSelected } from '@/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected'; +import { BubbleMenuIconButton } from '@/advanced-text-editor/components/BubbleMenuIconButton'; +import { EditLinkPopover } from '@/advanced-text-editor/components/EditLinkPopover'; +import { TurnIntoBlockDropdown } from '@/advanced-text-editor/components/TurnIntoBlockDropdown'; +import { useTextBubbleState } from '@/advanced-text-editor/hooks/useTextBubbleState'; +import { isTextSelected } from '@/advanced-text-editor/utils/isTextSelected'; import styled from '@emotion/styled'; import { type Editor } from '@tiptap/core'; import { BubbleMenu } from '@tiptap/react/menus'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/TurnIntoBlockDropdown.tsx similarity index 95% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/TurnIntoBlockDropdown.tsx index c1bfa1653d..03b79277ac 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/TurnIntoBlockDropdown.tsx @@ -1,8 +1,8 @@ +import { useTurnIntoBlockOptions } from '@/advanced-text-editor/hooks/useTurnIntoBlockOptions'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown'; -import { useTurnIntoBlockOptions } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { type Editor } from '@tiptap/react'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx b/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx similarity index 83% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx index 5f2999211b..914592d367 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowEmailEditor.stories.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/components/__stories__/AdvancedTextEditor.stories.tsx @@ -1,5 +1,5 @@ -import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor'; -import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor'; +import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor'; +import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor'; import { type Meta, type StoryObj } from '@storybook/react'; import { fn } from '@storybook/test'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; @@ -13,16 +13,20 @@ import { graphqlMocks } from '~/testing/graphqlMocks'; const EditorWrapper = ({ readonly = false, - placeholder = 'Enter email content...', + placeholder = 'Enter text content...', defaultValue = null, onUpdate = fn(), + minHeight = 200, + maxWidth = 800, }: { readonly?: boolean; placeholder?: string; defaultValue?: string | null; onUpdate?: (content: string) => void; + minHeight?: number; + maxWidth?: number; }) => { - const editor = useEmailEditor({ + const editor = useAdvancedTextEditor({ placeholder, readonly, defaultValue, @@ -43,11 +47,18 @@ const EditorWrapper = ({ return
Loading editor...
; } - return ; + return ( + + ); }; const meta: Meta = { - title: 'Modules/Workflow/Actions/SendEmail/EmailEditor', + title: 'Modules/AdvancedTextEditor/AdvancedTextEditor', component: EditorWrapper, parameters: { msw: graphqlMocks, @@ -87,7 +98,7 @@ export const WithContent: Story = { { type: 'text', marks: [{ type: 'bold' }], - text: 'John', + text: 'World', }, { type: 'text', @@ -100,7 +111,7 @@ export const WithContent: Story = { content: [ { type: 'text', - text: 'This is a sample email with ', + text: 'This is a sample text with ', }, { type: 'text', @@ -135,30 +146,30 @@ export const WithHeadings: Story = { { type: 'heading', attrs: { level: 1 }, - content: [{ type: 'text', text: 'Welcome Email' }], + content: [{ type: 'text', text: 'Main Title' }], }, { type: 'heading', attrs: { level: 2 }, - content: [{ type: 'text', text: 'Getting Started' }], + content: [{ type: 'text', text: 'Subtitle' }], }, { type: 'paragraph', content: [ { type: 'text', - text: 'Thank you for joining us! Here are some next steps:', + text: 'This is a paragraph with some content.', }, ], }, { type: 'heading', attrs: { level: 3 }, - content: [{ type: 'text', text: 'Next Steps' }], + content: [{ type: 'text', text: 'Smaller Heading' }], }, { type: 'paragraph', - content: [{ type: 'text', text: '1. Complete your profile' }], + content: [{ type: 'text', text: 'Another paragraph here.' }], }, ], }), @@ -224,7 +235,7 @@ export const WithVariableTags: Story = { type: 'variableTag', attrs: { variable: '{{orderNumber}}' }, }, - { type: 'text', text: ' has been shipped!' }, + { type: 'text', text: ' has been processed!' }, ], }, ], @@ -264,7 +275,7 @@ export const ReadOnly: Story = { export const Empty: Story = { args: { - placeholder: 'Start typing your email content...', + placeholder: 'Start typing your content...', }, }; @@ -274,3 +285,11 @@ export const Interactive: Story = { placeholder: 'Try typing, formatting text, or uploading images...', }, }; + +export const CustomSize: Story = { + args: { + minHeight: 300, + maxWidth: 600, + placeholder: 'This editor has custom dimensions...', + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/ResizableImage.ts similarity index 74% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts rename to packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/ResizableImage.ts index 2ada3295d5..6413ca93d2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/ResizableImage.ts @@ -1,4 +1,4 @@ -import { ResizableImageView } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView'; +import { ResizableImageView } from '@/advanced-text-editor/extensions/resizable-image/ResizableImageView'; import { type ImageOptions, Image as TiptapImage, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView.tsx b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/ResizableImageView.tsx similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView.tsx rename to packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/ResizableImageView.tsx diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/UploadImageExtension.ts similarity index 91% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts rename to packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/UploadImageExtension.ts index 9b4f9de589..0346f01240 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/UploadImageExtension.ts @@ -1,7 +1,7 @@ import { UploadImagePlugin, type UploadImagePluginProps, -} from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin'; +} from '@/advanced-text-editor/extensions/resizable-image/UploadImagePlugin'; import { Extension } from '@tiptap/core'; type UploadImageOptions = Omit & {}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/UploadImagePlugin.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin.ts rename to packages/twenty-front/src/modules/advanced-text-editor/extensions/resizable-image/UploadImagePlugin.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts similarity index 82% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts rename to packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts index 9bc1172641..b552c5fa5f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts @@ -1,6 +1,6 @@ -import { ResizableImage } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage'; -import { UploadImageExtension } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension'; -import { getInitialEmailEditorContent } from '@/workflow/workflow-variables/utils/getInitialEmailEditorContent'; +import { ResizableImage } from '@/advanced-text-editor/extensions/resizable-image/ResizableImage'; +import { UploadImageExtension } from '@/advanced-text-editor/extensions/resizable-image/UploadImageExtension'; +import { getInitialAdvancedTextEditorContent } from '@/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent'; import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag'; import { Bold } from '@tiptap/extension-bold'; import { Document } from '@tiptap/extension-document'; @@ -17,7 +17,7 @@ import { type Editor, useEditor } from '@tiptap/react'; import { type DependencyList, useMemo } from 'react'; import { isDefined } from 'twenty-shared/utils'; -type UseEmailEditorProps = { +type UseAdvancedTextEditorProps = { placeholder: string | undefined; readonly: boolean | undefined; defaultValue: string | undefined | null; @@ -28,7 +28,7 @@ type UseEmailEditorProps = { onImageUploadError?: (error: Error, file: File) => void; }; -export const useEmailEditor = ( +export const useAdvancedTextEditor = ( { placeholder, readonly, @@ -38,7 +38,7 @@ export const useEmailEditor = ( onBlur, onImageUpload, onImageUploadError, - }: UseEmailEditorProps, + }: UseAdvancedTextEditorProps, dependencies?: DependencyList, ) => { const extensions = useMemo( @@ -78,7 +78,7 @@ export const useEmailEditor = ( { extensions, content: isDefined(defaultValue) - ? getInitialEmailEditorContent(defaultValue) + ? getInitialAdvancedTextEditorContent(defaultValue) : undefined, editable: !readonly, onUpdate: ({ editor }) => { diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState.ts rename to packages/twenty-front/src/modules/advanced-text-editor/hooks/useTextBubbleState.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useTurnIntoBlockOptions.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions.ts rename to packages/twenty-front/src/modules/advanced-text-editor/hooks/useTurnIntoBlockOptions.ts diff --git a/packages/twenty-front/src/modules/advanced-text-editor/index.ts b/packages/twenty-front/src/modules/advanced-text-editor/index.ts new file mode 100644 index 0000000000..12b282d7b9 --- /dev/null +++ b/packages/twenty-front/src/modules/advanced-text-editor/index.ts @@ -0,0 +1,5 @@ +export { AdvancedTextEditor } from './components/AdvancedTextEditor'; +export { ImageBubbleMenu } from './components/ImageBubbleMenu'; +export { LinkBubbleMenu } from './components/LinkBubbleMenu'; +export { TextBubbleMenu } from './components/TextBubbleMenu'; +export { useAdvancedTextEditor } from './hooks/useAdvancedTextEditor'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected.ts b/packages/twenty-front/src/modules/advanced-text-editor/utils/isTextSelected.ts similarity index 100% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected.ts rename to packages/twenty-front/src/modules/advanced-text-editor/utils/isTextSelected.ts diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput.tsx similarity index 56% rename from packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx rename to packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput.tsx index ca6cafe6fb..c93537cbb3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput.tsx @@ -1,8 +1,7 @@ -import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { AdvancedTextEditor } from '@/advanced-text-editor/components/AdvancedTextEditor'; +import { useAdvancedTextEditor } from '@/advanced-text-editor/hooks/useAdvancedTextEditor'; import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer'; import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent'; -import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper'; import { InputHint } from '@/ui/input/components/InputHint'; import { InputLabel } from '@/ui/input/components/InputLabel'; @@ -12,12 +11,6 @@ import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Bre 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 { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; -import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; -import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; -import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow'; -import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor'; -import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { useLingui } from '@lingui/react/macro'; @@ -26,11 +19,11 @@ import { isDefined } from 'twenty-shared/utils'; import { IconMaximize } from 'twenty-ui/display'; import { useIsMobile } from 'twenty-ui/utilities'; -const StyledWorkflowSendEmailBodyContainer = styled(FormFieldInputContainer)` +const StyledAdvancedTextFieldContainer = styled(FormFieldInputContainer)` flex-grow: 1; `; -const StyledWorkflowSendEmailFieldContainer = styled.div` +const StyledAdvancedTextFieldFieldContainer = styled.div` position: relative; display: flex; flex-direction: column; @@ -38,7 +31,7 @@ const StyledWorkflowSendEmailFieldContainer = styled.div` flex-grow: 1; `; -const StyledWorkflowSendEmailBodyInnerContainer = styled.div` +const StyledAdvancedTextFieldInnerContainer = styled.div` flex-grow: 1; background-color: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -50,14 +43,14 @@ const StyledWorkflowSendEmailBodyInnerContainer = styled.div` width: 100%; `; -const StyledEmailEditorActionButtonContainer = styled.div` +const StyledEditorActionButtonContainer = styled.div` position: absolute; top: ${({ theme }) => theme.spacing(0)}; right: ${({ theme }) => theme.spacing(7.5)}; z-index: 1; `; -const StyledFullScreenEmailEditorContainer = styled.div` +const StyledFullScreenEditorContainer = styled.div` background-color: ${({ theme }) => theme.background.secondary}; border: 1px solid ${({ theme }) => theme.border.color.medium}; border-radius: ${({ theme }) => theme.border.radius.sm}; @@ -68,7 +61,7 @@ const StyledFullScreenEmailEditorContainer = styled.div` `; const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)` - background-color: 'transparent'; + background-color: transparent; color: ${({ theme }) => theme.font.color.tertiary}; padding: ${({ theme }) => theme.spacing(2)}; @@ -78,8 +71,7 @@ const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)` } `; -type WorkflowSendEmailBodyProps = { - action: WorkflowSendEmailAction; +type FormAdvancedTextFieldInputProps = { label?: string; error?: string; hint?: string; @@ -88,10 +80,15 @@ type WorkflowSendEmailBodyProps = { readonly?: boolean; placeholder?: string; VariablePicker?: VariablePickerComponent; + onImageUpload?: (file: File) => Promise; + onImageUploadError?: (error: Error, file: File) => void; + enableFullScreen?: boolean; + fullScreenBreadcrumbs?: BreadcrumbProps['links']; + minHeight: number; + maxWidth: number; }; -export const WorkflowSendEmailBody = ({ - action, +export const FormAdvancedTextFieldInput = ({ label, error, hint, @@ -100,47 +97,26 @@ export const WorkflowSendEmailBody = ({ onChange, readonly, VariablePicker, -}: WorkflowSendEmailBodyProps) => { + onImageUpload, + onImageUploadError, + enableFullScreen = true, + fullScreenBreadcrumbs, + minHeight, + maxWidth, +}: FormAdvancedTextFieldInputProps) => { const instanceId = useId(); const isMobile = useIsMobile(); const [isFullScreen, setIsFullScreen] = useState(false); const theme = useTheme(); - const { uploadAttachmentFile } = useUploadAttachmentFile(); - const { enqueueErrorSnackBar } = useSnackBar(); const { t } = useLingui(); const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack(); const { removeFocusItemFromFocusStackById } = useRemoveFocusItemFromFocusStackById(); - const workflowVisualizerWorkflowId = useRecoilComponentValue( - workflowVisualizerWorkflowIdComponentState, - ); - const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); - const headerTitle = isDefined(action.name) ? action.name : 'Send Email'; - - const handleUploadAttachment = async (file: File) => { - if (!isDefined(workflowVisualizerWorkflowId)) { - return undefined; - } - - const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, { - id: workflowVisualizerWorkflowId, - targetObjectNameSingular: CoreObjectNameSingular.Workflow, - }); - - return attachmentAbsoluteURL; - }; - - const handleImageUploadError = (error: Error, file: File) => { - enqueueErrorSnackBar({ - message: t`Failed to upload image: `.concat(file.name), - }); - }; - - const editor = useEmailEditor( + const editor = useAdvancedTextEditor( { - placeholder: placeholder ?? 'Enter email', + placeholder: placeholder ?? 'Enter text', readonly, defaultValue, onUpdate: (editor) => { @@ -162,8 +138,8 @@ export const WorkflowSendEmailBody = ({ onBlur: () => { removeFocusItemFromFocusStackById({ focusId: instanceId }); }, - onImageUpload: handleUploadAttachment, - onImageUploadError: handleImageUploadError, + onImageUpload, + onImageUploadError, }, [isFullScreen], ); @@ -186,34 +162,35 @@ export const WorkflowSendEmailBody = ({ editor.commands.insertVariableTag(variableName); }; - const breadcrumbLinks: BreadcrumbProps['links'] = [ + const defaultBreadcrumbs: BreadcrumbProps['links'] = [ { - children: workflow?.name?.trim() || t`Untitled Workflow`, - href: '#', - }, - { - children: headerTitle, - href: '#', - }, - { - children: t`Email Editor`, + children: t`Text Editor`, }, ]; + const breadcrumbLinks = fullScreenBreadcrumbs || defaultBreadcrumbs; + const { renderFullScreenModal } = useFullScreenModal({ links: breadcrumbLinks, onClose: handleExitFullScreen, hasClosePageButton: !isMobile, }); - const fullScreenOverlay = renderFullScreenModal( -
- - - -
, - isFullScreen, - ); + const fullScreenOverlay = enableFullScreen + ? renderFullScreenModal( +
+ + + +
, + isFullScreen, + ) + : null; if (!isDefined(editor)) { return null; @@ -221,26 +198,33 @@ export const WorkflowSendEmailBody = ({ return ( <> - + {label ? {label} : null} - - + + {!isFullScreen && ( - + )} - - {!readonly && !isFullScreen && ( - - - - )} - + {enableFullScreen && ( + + {!readonly && !isFullScreen && ( + + + + )} + + )} {VariablePicker && !readonly ? ( ) : null} - - + + {hint && {hint}} {error && {error}} - + {fullScreenOverlay} diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormAdvancedTextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormAdvancedTextFieldInput.stories.tsx new file mode 100644 index 0000000000..ae6d1161d9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/form-types/components/__stories__/FormAdvancedTextFieldInput.stories.tsx @@ -0,0 +1,270 @@ +import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput'; +import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; +import { type Meta, type StoryObj } from '@storybook/react'; +import { expect, fn, userEvent, within } from '@storybook/test'; +import { graphql, HttpResponse } from 'msw'; +import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; +import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; +import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; +import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; +import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; +import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; +import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; +import { graphqlMocks } from '~/testing/graphqlMocks'; +import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; + +const DEFAULT_PROPS = { + label: 'Advanced Text Field', + placeholder: 'Enter your content...', + defaultValue: '', + onChange: fn(), + readonly: false, + maxHeight: 200, + maxWidth: 800, +}; + +const RICH_CONTENT_VALUE = JSON.stringify({ + type: 'doc', + content: [ + { + type: 'heading', + attrs: { level: 1 }, + content: [{ type: 'text', text: 'Welcome!' }], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Dear ' }, + { + type: 'variableTag', + attrs: { variable: '{{firstName}}' }, + }, + { type: 'text', text: ',' }, + ], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Thank you for joining us! We are ' }, + { + type: 'text', + marks: [{ type: 'bold' }], + text: 'excited', + }, + { type: 'text', text: ' to have you on board.' }, + ], + }, + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Visit our ' }, + { + type: 'text', + marks: [{ type: 'link', attrs: { href: 'https://twenty.com' } }], + text: 'website', + }, + { type: 'text', text: ' to get started.' }, + ], + }, + ], +}); + +const meta: Meta = { + title: + 'Modules/ObjectRecord/RecordField/FormTypes/FormAdvancedTextFieldInput', + component: FormAdvancedTextFieldInput, + parameters: { + msw: { + handlers: [ + ...graphqlMocks.handlers, + graphql.query('FindManyWorkflows', () => { + return HttpResponse.json({ + data: { + workflows: [ + { + id: getWorkflowNodeIdMock(), + name: 'Test Workflow', + __typename: 'Workflow', + }, + ], + }, + }); + }), + ], + }, + }, + decorators: [ + WorkflowStepActionDrawerDecorator, + WorkflowStepDecorator, + ComponentDecorator, + ObjectMetadataItemsDecorator, + SnackBarDecorator, + RouterDecorator, + WorkspaceDecorator, + I18nFrontDecorator, + ], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + ...DEFAULT_PROPS, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Advanced Text Field')).toBeVisible(); + expect(await canvas.findByRole('textbox')).toBeVisible(); + }, +}; + +export const WithRichContent: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Rich Text Content', + defaultValue: RICH_CONTENT_VALUE, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Rich Text Content')).toBeVisible(); + expect(await canvas.findByText('Welcome!')).toBeVisible(); + expect(await canvas.findByText('excited')).toBeVisible(); + expect(await canvas.findByText('website')).toBeVisible(); + }, +}; + +export const ReadOnly: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Read Only Field', + defaultValue: RICH_CONTENT_VALUE, + readonly: true, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Read Only Field')).toBeVisible(); + expect(await canvas.findByText('Welcome!')).toBeVisible(); + }, +}; + +export const WithError: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field with Error', + error: 'This field is required', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Field with Error')).toBeVisible(); + expect(await canvas.findByText('This field is required')).toBeVisible(); + }, +}; + +export const WithHint: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field with Hint', + hint: 'You can use variables, format text, and add images to your content.', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Field with Hint')).toBeVisible(); + expect( + await canvas.findByText( + 'You can use variables, format text, and add images to your content.', + ), + ).toBeVisible(); + }, +}; + +export const Interactive: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Interactive Field', + placeholder: 'Start typing to see the editor in action...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Interactive Field')).toBeVisible(); + + const editor = await canvas.findByRole('textbox'); + expect(editor).toBeVisible(); + + await userEvent.click(editor); + await userEvent.type(editor, 'Hello World!'); + }, +}; + +export const WithVariablePicker: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field with Variable Picker', + VariablePicker: WorkflowVariablePicker, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect(await canvas.findByText('Field with Variable Picker')).toBeVisible(); + expect(await canvas.findByRole('textbox')).toBeVisible(); + }, +}; + +export const WithoutVariablePicker: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field without Variable Picker', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + expect( + await canvas.findByText('Field without Variable Picker'), + ).toBeVisible(); + expect(await canvas.findByRole('textbox')).toBeVisible(); + }, +}; + +export const WithLongContent: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field with Long Content', + defaultValue: JSON.stringify({ + type: 'doc', + content: Array.from({ length: 20 }, (_, i) => ({ + type: 'paragraph', + content: [ + { + type: 'text', + text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`, + }, + ], + })), + }), + }, +}; + +export const CustomSize: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Custom Size Field', + maxHeight: 300, + maxWidth: 600, + placeholder: 'This field has custom dimensions...', + }, +}; + +export const DisabledFullScreen: Story = { + args: { + ...DEFAULT_PROPS, + label: 'Field without Full Screen', + enableFullScreen: false, + }, +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx index 0b2fffa7c9..159bd28518 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail.tsx @@ -1,23 +1,28 @@ import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; import { MICROSOFT_SEND_SCOPE } from '@/accounts/constants/MicrosoftSendScope'; import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader'; import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { FormAdvancedTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormAdvancedTextFieldInput'; import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput'; import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Select } from '@/ui/input/components/Select'; import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow'; import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter'; import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; -import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody'; import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; import { useTheme } from '@emotion/react'; +import { t } from '@lingui/core/macro'; import { useEffect, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types'; @@ -28,6 +33,9 @@ import { type JsonValue } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; +const EMAIL_EDITOR_MIN_HEIGHT = 340; +const EMAIL_EDITOR_MAX_WIDTH = 600; + type WorkflowEditActionSendEmailProps = { action: WorkflowSendEmailAction; actionOptions: @@ -55,10 +63,15 @@ export const WorkflowEditActionSendEmail = ({ const { getIcon } = useIcons(); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); const { triggerApisOAuth } = useTriggerApisOAuth(); + const { enqueueErrorSnackBar } = useSnackBar(); + const { uploadAttachmentFile } = useUploadAttachmentFile(); const workflowVisualizerWorkflowId = useRecoilComponentValue( workflowVisualizerWorkflowIdComponentState, ); + + const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); + const redirectUrl = `/object/workflow/${workflowVisualizerWorkflowId}`; const [formData, setFormData] = useState({ @@ -115,7 +128,6 @@ export const WorkflowEditActionSendEmail = ({ if (actionOptions.readonly === true) { return; } - actionOptions.onActionUpdate({ ...action, settings: { @@ -154,6 +166,25 @@ export const WorkflowEditActionSendEmail = ({ saveAction(newFormData); }; + const handleUploadAttachment = async (file: File) => { + if (!isDefined(workflowVisualizerWorkflowId)) { + return undefined; + } + + const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, { + id: workflowVisualizerWorkflowId, + targetObjectNameSingular: CoreObjectNameSingular.Workflow, + }); + + return attachmentAbsoluteURL; + }; + + const handleImageUploadError = (error: Error, file: File) => { + enqueueErrorSnackBar({ + message: t`Failed to upload image: `.concat(file.name), + }); + }; + const filter: { or: object[] } = { or: [ { @@ -285,16 +316,33 @@ export const WorkflowEditActionSendEmail = ({ }} VariablePicker={WorkflowVariablePicker} /> - { + onChange={(body: string) => { handleFieldChange('body', body); }} VariablePicker={WorkflowVariablePicker} + enableFullScreen={true} + fullScreenBreadcrumbs={[ + { + children: workflow?.name?.trim() || t`Untitled Workflow`, + href: '#', + }, + { + children: headerTitle, + href: '#', + }, + { + children: t`Email Editor`, + }, + ]} + onImageUpload={handleUploadAttachment} + onImageUploadError={handleImageUploadError} + minHeight={EMAIL_EDITOR_MIN_HEIGHT} + maxWidth={EMAIL_EDITOR_MAX_WIDTH} /> {!actionOptions.readonly && } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx deleted file mode 100644 index 82b558123c..0000000000 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/email-action/components/__stories__/WorkflowSendEmailBody.stories.tsx +++ /dev/null @@ -1,320 +0,0 @@ -import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow'; -import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody'; -import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker'; -import { type Meta, type StoryObj } from '@storybook/react'; -import { expect, fn, userEvent, within } from '@storybook/test'; -import { graphql, HttpResponse } from 'msw'; -import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing'; -import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator'; -import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; -import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator'; -import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator'; -import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator'; -import { graphqlMocks } from '~/testing/graphqlMocks'; -import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow'; - -const DEFAULT_ACTION: WorkflowSendEmailAction = { - id: getWorkflowNodeIdMock(), - name: 'Send Email', - type: 'SEND_EMAIL', - valid: false, - settings: { - input: { - connectedAccountId: '', - email: '', - subject: '', - body: '', - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: false }, - continueOnFailure: { value: false }, - }, - }, -}; - -const RICH_CONTENT_ACTION: WorkflowSendEmailAction = { - id: getWorkflowNodeIdMock(), - name: 'Welcome Email', - type: 'SEND_EMAIL', - valid: true, - settings: { - input: { - connectedAccountId: 'test-account-id', - email: 'user@example.com', - subject: 'Welcome to Twenty!', - body: JSON.stringify({ - type: 'doc', - content: [ - { - type: 'heading', - attrs: { level: 1 }, - content: [{ type: 'text', text: 'Welcome to Twenty!' }], - }, - { - type: 'paragraph', - content: [ - { type: 'text', text: 'Dear ' }, - { - type: 'variableTag', - attrs: { variable: '{{firstName}}' }, - }, - { type: 'text', text: ',' }, - ], - }, - { - type: 'paragraph', - content: [ - { type: 'text', text: 'Thank you for joining us! We are ' }, - { - type: 'text', - marks: [{ type: 'bold' }], - text: 'excited', - }, - { type: 'text', text: ' to have you on board.' }, - ], - }, - { - type: 'paragraph', - content: [ - { type: 'text', text: 'Visit our ' }, - { - type: 'text', - marks: [ - { type: 'link', attrs: { href: 'https://twenty.com' } }, - ], - text: 'website', - }, - { type: 'text', text: ' to get started.' }, - ], - }, - ], - }), - }, - outputSchema: {}, - errorHandlingOptions: { - retryOnFailure: { value: true }, - continueOnFailure: { value: false }, - }, - }, -}; - -const meta: Meta = { - title: 'Modules/Workflow/Actions/SendEmail/EmailBody', - component: WorkflowSendEmailBody, - parameters: { - msw: { - handlers: [ - ...graphqlMocks.handlers, - graphql.query('FindManyWorkflows', () => { - return HttpResponse.json({ - data: { - workflows: [ - { - id: getWorkflowNodeIdMock(), - name: 'Test Workflow', - __typename: 'Workflow', - }, - ], - }, - }); - }), - ], - }, - }, - decorators: [ - WorkflowStepActionDrawerDecorator, - WorkflowStepDecorator, - ComponentDecorator, - ObjectMetadataItemsDecorator, - SnackBarDecorator, - RouterDecorator, - WorkspaceDecorator, - I18nFrontDecorator, - ], -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - action: DEFAULT_ACTION, - label: 'Email Body', - placeholder: 'Enter your email content...', - defaultValue: '', - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - expect(await canvas.findByRole('textbox')).toBeVisible(); - }, -}; - -export const WithRichContent: Story = { - args: { - action: RICH_CONTENT_ACTION, - label: 'Email Body', - placeholder: 'Enter your email content...', - defaultValue: RICH_CONTENT_ACTION.settings.input.body, - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible(); - expect(await canvas.findByText('excited')).toBeVisible(); - expect(await canvas.findByText('website')).toBeVisible(); - }, -}; - -export const ReadOnly: Story = { - args: { - action: RICH_CONTENT_ACTION, - label: 'Email Body (Read Only)', - defaultValue: RICH_CONTENT_ACTION.settings.input.body, - onChange: fn(), - readonly: true, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body (Read Only)')).toBeVisible(); - expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible(); - }, -}; - -export const WithError: Story = { - args: { - action: DEFAULT_ACTION, - label: 'Email Body', - error: 'Email body is required', - placeholder: 'Enter your email content...', - defaultValue: '', - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - expect(await canvas.findByText('Email body is required')).toBeVisible(); - }, -}; - -export const WithHint: Story = { - args: { - action: DEFAULT_ACTION, - label: 'Email Body', - hint: 'You can use variables, format text, and add images to your email content.', - placeholder: 'Enter your email content...', - defaultValue: '', - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - expect( - await canvas.findByText( - 'You can use variables, format text, and add images to your email content.', - ), - ).toBeVisible(); - }, -}; - -export const Interactive: Story = { - args: { - action: DEFAULT_ACTION, - label: 'Email Body', - placeholder: 'Start typing to see the editor in action...', - defaultValue: '', - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - - const editor = await canvas.findByRole('textbox'); - expect(editor).toBeVisible(); - - await userEvent.click(editor); - await userEvent.type(editor, 'Hello World!'); - }, -}; - -export const WithoutVariablePicker: Story = { - args: { - action: DEFAULT_ACTION, - label: 'Email Body', - placeholder: 'Enter your email content...', - defaultValue: '', - onChange: fn(), - readonly: false, - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - expect(await canvas.findByText('Email Body')).toBeVisible(); - expect(await canvas.findByRole('textbox')).toBeVisible(); - }, -}; - -export const WithLongContent: Story = { - args: { - action: { - ...DEFAULT_ACTION, - settings: { - ...DEFAULT_ACTION.settings, - input: { - ...DEFAULT_ACTION.settings.input, - body: JSON.stringify({ - type: 'doc', - content: Array.from({ length: 20 }, (_, i) => ({ - type: 'paragraph', - content: [ - { - type: 'text', - text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`, - }, - ], - })), - }), - }, - }, - }, - label: 'Email Body', - placeholder: 'Enter your email content...', - defaultValue: JSON.stringify({ - type: 'doc', - content: Array.from({ length: 20 }, (_, i) => ({ - type: 'paragraph', - content: [ - { - type: 'text', - text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`, - }, - ], - })), - }), - onChange: fn(), - readonly: false, - VariablePicker: WorkflowVariablePicker, - }, -}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialEmailEditorContent.ts b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent.ts similarity index 93% rename from packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialEmailEditorContent.ts rename to packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent.ts index c2fd911435..4f99d628fb 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialEmailEditorContent.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent.ts @@ -5,7 +5,7 @@ import { logError } from '~/utils/logError'; // Previous format of the email body was plain text, // but from now on we will save it as JSON. // So it will fail to parse the content, that's why we have this fallback. -export const getInitialEmailEditorContent = ( +export const getInitialAdvancedTextEditorContent = ( rawContent: string, ): JSONContent => { // Handle empty or null content