Refactored WorkflowSendEmailBody to FormAdvancedTextFieldInput (#15157)

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 <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Rounak Joshi
2025-10-20 21:26:49 +05:30
committed by GitHub
parent dea08a3a41
commit 23d8bbbf92
22 changed files with 474 additions and 461 deletions
@@ -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 (
<StyledEditorContainer readonly={readonly}>
<StyledEditorContainer
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
>
<EditorContent className="editor-content" editor={editor} />
<ImageBubbleMenu editor={editor} />
<TextBubbleMenu editor={editor} />
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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 <div>Loading editor...</div>;
}
return <WorkflowEmailEditor editor={editor} readonly={readonly} />;
return (
<AdvancedTextEditor
editor={editor}
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
/>
);
};
const meta: Meta<typeof EditorWrapper> = {
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...',
},
};
@@ -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,
@@ -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<UploadImagePluginProps, 'editor'> & {};
@@ -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 }) => {
@@ -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';
@@ -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<string>;
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(
<div data-globally-prevent-click-outside="true">
<StyledFullScreenEmailEditorContainer>
<WorkflowEmailEditor editor={editor} readonly={readonly} />
</StyledFullScreenEmailEditorContainer>
</div>,
isFullScreen,
);
const fullScreenOverlay = enableFullScreen
? renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
<StyledFullScreenEditorContainer>
<AdvancedTextEditor
editor={editor}
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
/>
</StyledFullScreenEditorContainer>
</div>,
isFullScreen,
)
: null;
if (!isDefined(editor)) {
return null;
@@ -221,26 +198,33 @@ export const WorkflowSendEmailBody = ({
return (
<>
<StyledWorkflowSendEmailBodyContainer>
<StyledAdvancedTextFieldContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledWorkflowSendEmailFieldContainer>
<StyledWorkflowSendEmailBodyInnerContainer>
<StyledAdvancedTextFieldFieldContainer>
<StyledAdvancedTextFieldInnerContainer>
{!isFullScreen && (
<WorkflowEmailEditor editor={editor} readonly={readonly} />
<AdvancedTextEditor
editor={editor}
readonly={readonly}
minHeight={minHeight}
maxWidth={maxWidth}
/>
)}
<StyledEmailEditorActionButtonContainer>
{!readonly && !isFullScreen && (
<StyledFullScreenButtonContainer
isUnfolded={false}
transparentBackground
onClick={handleEnterFullScreen}
>
<IconMaximize size={theme.icon.size.md} />
</StyledFullScreenButtonContainer>
)}
</StyledEmailEditorActionButtonContainer>
{enableFullScreen && (
<StyledEditorActionButtonContainer>
{!readonly && !isFullScreen && (
<StyledFullScreenButtonContainer
isUnfolded={false}
transparentBackground
onClick={handleEnterFullScreen}
>
<IconMaximize size={theme.icon.size.md} />
</StyledFullScreenButtonContainer>
)}
</StyledEditorActionButtonContainer>
)}
{VariablePicker && !readonly ? (
<VariablePicker
@@ -249,11 +233,11 @@ export const WorkflowSendEmailBody = ({
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledWorkflowSendEmailBodyInnerContainer>
</StyledWorkflowSendEmailFieldContainer>
</StyledAdvancedTextFieldInnerContainer>
</StyledAdvancedTextFieldFieldContainer>
{hint && <InputHint>{hint}</InputHint>}
{error && <InputErrorHelper>{error}</InputErrorHelper>}
</StyledWorkflowSendEmailBodyContainer>
</StyledAdvancedTextFieldContainer>
{fullScreenOverlay}
</>
@@ -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<typeof FormAdvancedTextFieldInput> = {
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<typeof meta>;
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,
},
};
@@ -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<SendEmailFormData>({
@@ -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}
/>
<WorkflowSendEmailBody
action={action}
<FormAdvancedTextFieldInput
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
defaultValue={formData.body}
onChange={(body) => {
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}
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
@@ -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<typeof WorkflowSendEmailBody> = {
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<typeof meta>;
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,
},
};
@@ -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