feat: rich text email body (#14482)

Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
Arik Chakma
2025-09-21 20:55:34 +06:00
committed by GitHub
parent a89e5d0f27
commit 9a2766feae
66 changed files with 3312 additions and 276 deletions
@@ -1,13 +1,8 @@
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
import {
Breadcrumb,
type BreadcrumbProps,
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
@@ -47,8 +42,7 @@ import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/Workf
import { type Monaco } from '@monaco-editor/react';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useEffect, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
@@ -68,35 +62,6 @@ const StyledCodeEditorContainer = styled.div`
overflow: hidden;
`;
const StyledFullScreenOverlay = styled.div`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: ${({ theme }) => theme.background.noisy};
display: flex;
flex-direction: column;
width: 100%;
height: 100vh;
z-index: ${RootStackingContextZIndices.Dialog};
`;
const StyledFullScreenHeader = styled(PageHeader)`
padding-left: ${({ theme }) => theme.spacing(3)};
`;
const StyledFullScreenContent = styled.div`
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(3)};
height: calc(
100% - ${PAGE_BAR_MIN_HEIGHT}px - ${({ theme }) => theme.spacing(2 * 2 + 5)}
);
padding: ${({ theme }) =>
`0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}`};
`;
const StyledTabList = styled(TabList)`
background-color: ${({ theme }) => theme.background.secondary};
padding-left: ${({ theme }) => theme.spacing(2)};
@@ -131,7 +96,6 @@ export const WorkflowEditActionServerlessFunction = ({
const { t } = useLingui();
const [isFullScreen, setIsFullScreen] = useState(false);
const isMobile = useIsMobile();
const fullScreenOverlayRef = useRef<HTMLDivElement>(null);
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const fullScreenFocusId = `code-editor-fullscreen-${serverlessFunctionId}`;
const activeTabId = useRecoilComponentValue(
@@ -352,17 +316,6 @@ export const WorkflowEditActionServerlessFunction = ({
dependencies: [isFullScreen],
});
useListenClickOutside({
refs: [fullScreenOverlayRef],
callback: () => {
if (isFullScreen) {
handleExitFullScreen();
}
},
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
enabled: isFullScreen,
});
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
@@ -372,19 +325,6 @@ export const WorkflowEditActionServerlessFunction = ({
const testLogsTextAreaId = `${serverlessFunctionId}-test-logs`;
const handleEnterFullScreen = () => {
setIsFullScreen(true);
setTimeout(() => {
if (isDefined(fullScreenOverlayRef.current)) {
fullScreenOverlayRef.current.focus();
}
}, 0);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
const breadcrumbLinks: BreadcrumbProps['links'] = [
{
children: workflow?.name?.trim() || t`Untitled Workflow`,
@@ -399,46 +339,64 @@ export const WorkflowEditActionServerlessFunction = ({
},
];
const fullScreenOverlay = isFullScreen
? createPortal(
<StyledFullScreenOverlay
ref={fullScreenOverlayRef}
data-globally-prevent-click-outside="true"
tabIndex={-1}
>
<StyledFullScreenHeader
title={<Breadcrumb links={breadcrumbLinks} />}
hasClosePageButton={!isMobile}
onClosePage={handleExitFullScreen}
/>
<StyledFullScreenContent data-globally-prevent-click-outside="true">
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledFullScreenCodeEditorContainer>
<CodeEditor
height="100%"
value={formValues.code?.[INDEX_FILE_PATH]}
language="typescript"
onChange={handleCodeChange}
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
scrollBeyondLastLine: false,
padding: { top: 4, bottom: 4 },
}}
/>
</StyledFullScreenCodeEditorContainer>
</StyledFullScreenContent>
</StyledFullScreenOverlay>,
document.body,
)
: null;
const { overlayRef: fullScreenOverlayRef, renderFullScreenModal } =
useFullScreenModal({
links: breadcrumbLinks,
onClose: () => setIsFullScreen(false),
hasClosePageButton: !isMobile,
});
useListenClickOutside({
refs: [fullScreenOverlayRef],
callback: () => {
if (isFullScreen) {
handleExitFullScreen();
}
},
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
enabled: isFullScreen,
});
const handleEnterFullScreen = () => {
setIsFullScreen(true);
setTimeout(() => {
if (isDefined(fullScreenOverlayRef.current)) {
fullScreenOverlayRef.current.focus();
}
}, 0);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
const fullScreenOverlay = renderFullScreenModal(
<div data-globally-prevent-click-outside="true">
<WorkflowEditActionServerlessFunctionFields
functionInput={functionInput}
VariablePicker={WorkflowVariablePicker}
onInputChange={handleInputChange}
readonly={actionOptions.readonly}
/>
<StyledFullScreenCodeEditorContainer>
<CodeEditor
height="100%"
value={formValues.code?.[INDEX_FILE_PATH]}
language="typescript"
onChange={handleCodeChange}
onMount={handleEditorDidMount}
setMarkers={getWrongExportedFunctionMarkers}
options={{
readOnly: actionOptions.readonly,
domReadOnly: actionOptions.readonly,
scrollBeyondLastLine: false,
padding: { top: 4, bottom: 4 },
}}
/>
</StyledFullScreenCodeEditorContainer>
</div>,
isFullScreen,
);
return (
!loading && (
@@ -0,0 +1,222 @@
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
import { WorkflowEditActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction';
import { type Meta, type StoryObj } from '@storybook/react';
import { fn } 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: WorkflowCodeAction = {
id: getWorkflowNodeIdMock(),
name: 'Code',
type: 'CODE',
valid: false,
settings: {
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: {
value: false,
},
continueOnFailure: {
value: false,
},
},
},
};
const CONFIGURED_ACTION: WorkflowCodeAction = {
id: getWorkflowNodeIdMock(),
name: 'Process Data',
type: 'CODE',
valid: true,
settings: {
input: {
serverlessFunctionId: 'test-function-id',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {
name: 'John Doe',
email: 'john@example.com',
score: 95,
},
},
outputSchema: {
result: {
type: 'TEXT',
label: 'Result',
value: 'Processing completed',
isLeaf: true,
},
status: {
type: 'TEXT',
label: 'Status',
value: 'success',
isLeaf: true,
},
},
errorHandlingOptions: {
retryOnFailure: {
value: true,
},
continueOnFailure: {
value: false,
},
},
},
};
const meta: Meta<typeof WorkflowEditActionServerlessFunction> = {
title: 'Modules/Workflow/Actions/Code/EditAction',
component: WorkflowEditActionServerlessFunction,
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('FindManyServerlessFunctions', () => {
return HttpResponse.json({
data: {
findManyServerlessFunctions: [
{
id: 'test-function-id',
name: 'Test Function',
description: 'A test serverless function',
runtime: 'nodejs22.x',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
],
},
});
}),
graphql.query('FindOneServerlessFunction', () => {
return HttpResponse.json({
data: {
findOneServerlessFunction: {
id: 'test-function-id',
name: 'Test Function',
description: 'A test serverless function',
runtime: 'nodejs22.x',
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
},
},
});
}),
graphql.query('FindManyAvailablePackages', () => {
return HttpResponse.json({
data: {
getAvailablePackages: ['axios', 'lodash', 'moment'],
},
});
}),
],
},
},
args: {
action: DEFAULT_ACTION,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof WorkflowEditActionServerlessFunction>;
export const Default: Story = {
args: {
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const Configured: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const ReadOnly: Story = {
args: {
action: CONFIGURED_ACTION,
actionOptions: {
readonly: true,
},
},
};
export const WithTestResults: Story = {
args: {
action: {
...CONFIGURED_ACTION,
settings: {
...CONFIGURED_ACTION.settings,
outputSchema: {
result: {
type: 'TEXT',
label: 'Processing Result',
value: 'Successfully processed 150 records',
isLeaf: true,
},
executionTime: {
type: 'NUMBER',
label: 'Execution Time (ms)',
value: 245,
isLeaf: true,
},
errors: {
type: 'ARRAY',
label: 'Errors',
value: [],
isLeaf: true,
},
},
},
},
actionOptions: {
onActionUpdate: fn(),
},
},
};
export const EmptyFunction: Story = {
args: {
action: {
...DEFAULT_ACTION,
settings: {
...DEFAULT_ACTION.settings,
input: {
serverlessFunctionId: '',
serverlessFunctionVersion: 'draft',
serverlessFunctionInput: {},
},
},
},
actionOptions: {
onActionUpdate: fn(),
},
},
};
@@ -14,6 +14,7 @@ import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
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';
@@ -220,6 +221,7 @@ export const WorkflowEditActionSendEmail = ({
const navigate = useNavigateSettings();
const { closeCommandMenu } = useCommandMenu();
return (
!loading && (
<>
@@ -283,7 +285,8 @@ export const WorkflowEditActionSendEmail = ({
}}
VariablePicker={WorkflowVariablePicker}
/>
<FormTextFieldInput
<WorkflowSendEmailBody
action={action}
label="Body"
placeholder="Enter email body"
readonly={actionOptions.readonly}
@@ -292,7 +295,6 @@ export const WorkflowEditActionSendEmail = ({
handleFieldChange('body', body);
}}
VariablePicker={WorkflowVariablePicker}
multiline
/>
</WorkflowStepBody>
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
@@ -0,0 +1,98 @@
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;
}>`
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
.editor-content {
flex-grow: 1;
width: 100%;
height: 100%;
min-height: ${EMAIL_EDITOR_MIN_HEIGHT}px;
max-width: ${EMAIL_EDITOR_MAX_WIDTH}px;
margin: 0 auto;
}
.tiptap {
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
box-sizing: border-box;
height: 100%;
color: ${({ theme, readonly }) =>
readonly ? theme.font.color.light : theme.font.color.primary};
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
border: none !important;
p.is-editor-empty:first-of-type::before {
${FORM_FIELD_PLACEHOLDER_STYLES}
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
p {
line-height: 1.5;
margin: 0;
}
.variable-tag {
background-color: ${({ theme }) => theme.color.blue10};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ theme }) => theme.color.blue};
padding: ${({ theme }) => theme.spacing(1)};
}
h1 {
font-size: 36px;
}
h2 {
font-size: 30px;
}
h3 {
font-size: 24px;
}
}
.ProseMirror-focused {
outline: none;
}
.ProseMirror-hideselection * {
caret-color: transparent;
}
`;
type WorkflowEmailEditorProps = {
readonly: boolean | undefined;
editor: Editor;
};
export const WorkflowEmailEditor = ({
readonly,
editor,
}: WorkflowEmailEditorProps) => {
return (
<StyledEditorContainer readonly={readonly}>
<EditorContent className="editor-content" editor={editor} />
<ImageBubbleMenu editor={editor} />
<TextBubbleMenu editor={editor} />
<LinkBubbleMenu editor={editor} />
</StyledEditorContainer>
);
};
@@ -0,0 +1,261 @@
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
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';
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
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';
import { useId, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconMaximize } from 'twenty-ui/display';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledWorkflowSendEmailBodyContainer = styled(FormFieldInputContainer)`
flex-grow: 1;
`;
const StyledWorkflowSendEmailFieldContainer = styled.div`
position: relative;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
flex-grow: 1;
`;
const StyledWorkflowSendEmailBodyInnerContainer = styled.div`
flex-grow: 1;
background-color: ${({ theme }) => theme.background.transparent.lighter};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
box-sizing: border-box;
display: flex;
overflow: auto;
width: 100%;
`;
const StyledEmailEditorActionButtonContainer = styled.div`
position: absolute;
top: ${({ theme }) => theme.spacing(0)};
right: ${({ theme }) => theme.spacing(7.5)};
z-index: 1;
`;
const StyledFullScreenEmailEditorContainer = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.sm};
flex: 1;
min-height: 0;
padding: ${({ theme }) => theme.spacing(2)};
overflow-y: auto;
`;
const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)`
background-color: 'transparent';
color: ${({ theme }) => theme.font.color.tertiary};
padding: ${({ theme }) => theme.spacing(2)};
:hover {
cursor: pointer;
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
type WorkflowSendEmailBodyProps = {
action: WorkflowSendEmailAction;
label?: string;
error?: string;
hint?: string;
defaultValue: string | undefined | null;
onChange: (value: string) => void;
readonly?: boolean;
placeholder?: string;
VariablePicker?: VariablePickerComponent;
};
export const WorkflowSendEmailBody = ({
action,
label,
error,
hint,
defaultValue,
placeholder,
onChange,
readonly,
VariablePicker,
}: WorkflowSendEmailBodyProps) => {
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(
{
placeholder: placeholder ?? 'Enter email',
readonly,
defaultValue,
onUpdate: (editor) => {
const jsonContent = editor.getJSON();
onChange(JSON.stringify(jsonContent));
},
onFocus: () => {
pushFocusItemToFocusStack({
focusId: instanceId,
component: {
type: FocusComponentType.FORM_FIELD_INPUT,
instanceId: instanceId,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
},
onBlur: () => {
removeFocusItemFromFocusStackById({ focusId: instanceId });
},
onImageUpload: handleUploadAttachment,
onImageUploadError: handleImageUploadError,
},
[isFullScreen],
);
const handleEnterFullScreen = () => {
setIsFullScreen(true);
};
const handleExitFullScreen = () => {
setIsFullScreen(false);
};
const handleVariableTagInsert = (variableName: string) => {
if (!isDefined(editor)) {
throw new Error(
'Expected the editor to be defined when a variable is selected',
);
}
editor.commands.insertVariableTag(variableName);
};
const breadcrumbLinks: BreadcrumbProps['links'] = [
{
children: workflow?.name?.trim() || t`Untitled Workflow`,
href: '#',
},
{
children: headerTitle,
href: '#',
},
{
children: t`Email Editor`,
},
];
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,
);
if (!isDefined(editor)) {
return null;
}
return (
<>
<StyledWorkflowSendEmailBodyContainer>
{label ? <InputLabel>{label}</InputLabel> : null}
<StyledWorkflowSendEmailFieldContainer>
<StyledWorkflowSendEmailBodyInnerContainer>
{!isFullScreen && (
<WorkflowEmailEditor editor={editor} readonly={readonly} />
)}
<StyledEmailEditorActionButtonContainer>
{!readonly && !isFullScreen && (
<StyledFullScreenButtonContainer
isUnfolded={false}
transparentBackground
onClick={handleEnterFullScreen}
>
<IconMaximize size={theme.icon.size.md} />
</StyledFullScreenButtonContainer>
)}
</StyledEmailEditorActionButtonContainer>
{VariablePicker && !readonly ? (
<VariablePicker
instanceId={instanceId}
multiline={true}
onVariableSelect={handleVariableTagInsert}
/>
) : null}
</StyledWorkflowSendEmailBodyInnerContainer>
</StyledWorkflowSendEmailFieldContainer>
{hint && <InputHint>{hint}</InputHint>}
{error && <InputErrorHelper>{error}</InputErrorHelper>}
</StyledWorkflowSendEmailBodyContainer>
{fullScreenOverlay}
</>
);
};
@@ -0,0 +1,276 @@
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 { type Meta, type StoryObj } from '@storybook/react';
import { fn } from '@storybook/test';
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';
const EditorWrapper = ({
readonly = false,
placeholder = 'Enter email content...',
defaultValue = null,
onUpdate = fn(),
}: {
readonly?: boolean;
placeholder?: string;
defaultValue?: string | null;
onUpdate?: (content: string) => void;
}) => {
const editor = useEmailEditor({
placeholder,
readonly,
defaultValue,
onUpdate: (editor) => {
const jsonContent = editor.getJSON();
onUpdate(JSON.stringify(jsonContent));
},
onImageUpload: async (file: File) => {
await new Promise((resolve) => setTimeout(resolve, 1000));
return `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`;
},
onImageUploadError: (_error: Error, _file: File) => {
// Handle image upload error
},
});
if (!editor) {
return <div>Loading editor...</div>;
}
return <WorkflowEmailEditor editor={editor} readonly={readonly} />;
};
const meta: Meta<typeof EditorWrapper> = {
title: 'Modules/Workflow/Actions/SendEmail/EmailEditor',
component: EditorWrapper,
parameters: {
msw: graphqlMocks,
},
decorators: [
WorkflowStepActionDrawerDecorator,
WorkflowStepDecorator,
ComponentDecorator,
ObjectMetadataItemsDecorator,
SnackBarDecorator,
RouterDecorator,
WorkspaceDecorator,
I18nFrontDecorator,
],
};
export default meta;
type Story = StoryObj<typeof EditorWrapper>;
export const Default: Story = {
args: {},
};
export const WithContent: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Hello ',
},
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'John',
},
{
type: 'text',
text: ',',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This is a sample email with ',
},
{
type: 'text',
marks: [{ type: 'italic' }],
text: 'italic',
},
{
type: 'text',
text: ' and ',
},
{
type: 'text',
marks: [{ type: 'underline' }],
text: 'underlined',
},
{
type: 'text',
text: ' text.',
},
],
},
],
}),
},
};
export const WithHeadings: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Welcome Email' }],
},
{
type: 'heading',
attrs: { level: 2 },
content: [{ type: 'text', text: 'Getting Started' }],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Thank you for joining us! Here are some next steps:',
},
],
},
{
type: 'heading',
attrs: { level: 3 },
content: [{ type: 'text', text: 'Next Steps' }],
},
{
type: 'paragraph',
content: [{ type: 'text', text: '1. Complete your profile' }],
},
],
}),
},
};
export const WithLinks: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Visit our ' },
{
type: 'text',
marks: [{ type: 'link', attrs: { href: 'https://twenty.com' } }],
text: 'website',
},
{ type: 'text', text: ' for more information.' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Contact us at ' },
{
type: 'text',
marks: [
{ type: 'link', attrs: { href: 'mailto:support@twenty.com' } },
],
text: 'support@twenty.com',
},
],
},
],
}),
},
};
export const WithVariableTags: Story = {
args: {
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Dear ' },
{
type: 'variableTag',
attrs: { variable: '{{firstName}}' },
},
{ type: 'text', text: ',' },
],
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'Your order ' },
{
type: 'variableTag',
attrs: { variable: '{{orderNumber}}' },
},
{ type: 'text', text: ' has been shipped!' },
],
},
],
}),
},
};
export const ReadOnly: Story = {
args: {
readonly: true,
defaultValue: JSON.stringify({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
marks: [{ type: 'bold' }],
text: 'Read-only mode',
},
],
},
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'This editor is in read-only mode and cannot be edited.',
},
],
},
],
}),
},
};
export const Empty: Story = {
args: {
placeholder: 'Start typing your email content...',
},
};
export const Interactive: Story = {
args: {
onUpdate: fn(),
placeholder: 'Try typing, formatting text, or uploading images...',
},
};
@@ -0,0 +1,320 @@
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,
},
};
@@ -0,0 +1,96 @@
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 { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import {
IconAlignCenter,
IconAlignLeft,
IconAlignRight,
IconTrash,
} from 'twenty-ui/display';
type ImageBubbleMenuProps = {
editor: Editor;
};
export const ImageBubbleMenu = ({ editor }: ImageBubbleMenuProps) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
align: ctx.editor.getAttributes('image').align || 'left',
};
},
});
const handleDelete = () => {
editor.chain().focus().deleteSelection().run();
};
const alignmentActions = [
{
align: 'left',
Icon: IconAlignLeft,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'left' })
.run(),
isActive: state.align === 'left',
},
{
align: 'center',
Icon: IconAlignCenter,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'center' })
.run(),
isActive: state.align === 'center',
},
{
align: 'right',
Icon: IconAlignRight,
onClick: () =>
editor
.chain()
.focus()
.updateAttributes('image', { align: 'right' })
.run(),
isActive: state.align === 'right',
},
];
const handleShouldShow = () => {
return editor.isActive('image');
};
return (
<BubbleMenu
pluginKey="image-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
{alignmentActions.map(({ align, Icon, onClick, isActive }) => (
<BubbleMenuIconButton
key={`image-align-${align}`}
Icon={Icon}
onClick={onClick}
isActive={isActive}
/>
))}
<BubbleMenuIconButton
key="image-delete"
Icon={IconTrash}
onClick={handleDelete}
isActive={false}
/>
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};
@@ -0,0 +1,62 @@
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 { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
import { BubbleMenu } from '@tiptap/react/menus';
import { IconExternalLink, IconLinkOff } from 'twenty-ui/display';
type LinkBubbleMenuProps = {
editor: Editor;
};
export const LinkBubbleMenu = ({ editor }: LinkBubbleMenuProps) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
linkHref: ctx.editor.getAttributes('link').href || '',
};
},
});
const handleShouldShow = () => {
return editor.isActive('link');
};
const menuActions = [
{
Icon: IconExternalLink,
onClick: () => {
window.open(state.linkHref, '_blank');
},
},
{
Icon: IconLinkOff,
onClick: () =>
editor.chain().focus().extendMarkRange('link').unsetLink().run(),
},
];
return (
<BubbleMenu
pluginKey="link-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
{menuActions.map(({ Icon, onClick }) => {
return (
<BubbleMenuIconButton
key={Icon.name || Icon.displayName || 'unknown'}
Icon={Icon}
onClick={onClick}
/>
);
})}
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};
@@ -0,0 +1,44 @@
import styled from '@emotion/styled';
import React from 'react';
import type { IconComponent } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
type BubbleMenuIconButtonProps = {
className?: string;
Icon?: IconComponent;
disabled?: boolean;
focus?: boolean;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
isActive?: boolean;
};
const StyledBubbleMenuIconButton = styled(FloatingIconButton)`
border: none;
border-radius: ${({ theme }) => theme.spacing(1.5)};
width: ${({ theme }) => theme.spacing(6)};
height: ${({ theme }) => theme.spacing(6)};
`;
export const BubbleMenuIconButton = ({
className,
Icon,
disabled = false,
focus = false,
onClick,
isActive,
}: BubbleMenuIconButtonProps) => {
return (
<StyledBubbleMenuIconButton
className={className}
Icon={Icon}
disabled={disabled}
focus={focus}
onClick={onClick}
isActive={isActive}
applyShadow={false}
applyBlur={false}
size="medium"
position="standalone"
/>
);
};
@@ -0,0 +1,79 @@
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';
import { useId, useState, type FocusEvent, type FormEvent } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconLink, IconPencil } from 'twenty-ui/display';
type EditLinkPopoverProps = {
defaultValue: string | undefined;
editor: Editor;
};
export const EditLinkPopover = ({
defaultValue = '',
editor,
}: EditLinkPopoverProps) => {
const instanceId = useId();
const dropdownId = `edit-link-popover-${instanceId}`;
const isActive = isNonEmptyString(defaultValue);
const { t } = useLingui();
const [value, setValue] = useState(defaultValue);
const { toggleDropdown } = useToggleDropdown();
const handleSubmit = (
event: FormEvent<HTMLFormElement> | FocusEvent<HTMLInputElement>,
) => {
event.preventDefault();
if (!isDefined(value)) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
} else {
editor
.chain()
.focus()
.extendMarkRange('link')
.setLink({ href: value })
.run();
}
toggleDropdown({ dropdownComponentInstanceIdFromProps: dropdownId });
};
return (
<Dropdown
onOpen={() => {
setValue(defaultValue);
}}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
<form onSubmit={handleSubmit}>
<TextInput
placeholder={t`Enter link`}
value={value}
onChange={setValue}
onBlur={handleSubmit}
/>
</form>
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={dropdownId}
isDropdownInModal={true}
clickableComponent={
<BubbleMenuIconButton
isActive={isActive}
Icon={isActive ? IconPencil : IconLink}
/>
}
/>
);
};
@@ -0,0 +1,87 @@
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 styled from '@emotion/styled';
import { type Editor } from '@tiptap/core';
import { BubbleMenu } from '@tiptap/react/menus';
import {
IconBold,
IconItalic,
IconStrikethrough,
IconUnderline,
} from 'twenty-ui/display';
export const StyledBubbleMenuContainer = styled.div`
backdrop-filter: blur(20px);
background-color: ${({ theme }) => theme.background.primary};
border-radius: ${({ theme }) => theme.border.radius.md};
box-shadow: ${({ theme }) =>
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
display: inline-flex;
gap: 2px;
padding: 2px;
`;
type TextBubbleMenuProps = {
editor: Editor;
};
export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
const state = useTextBubbleState(editor);
const menuActions = [
{
Icon: IconBold,
onClick: () => editor.chain().focus().toggleBold().run(),
isActive: state.isBold,
},
{
Icon: IconItalic,
onClick: () => editor.chain().focus().toggleItalic().run(),
isActive: state.isItalic,
},
{
Icon: IconUnderline,
onClick: () => editor.chain().focus().toggleUnderline().run(),
isActive: state.isUnderline,
},
{
Icon: IconStrikethrough,
onClick: () => editor.chain().focus().toggleStrike().run(),
isActive: state.isStrike,
},
];
const handleShouldShow = () => {
if (editor.isActive('image')) {
return false;
}
return isTextSelected({ editor });
};
return (
<BubbleMenu
pluginKey="text-bubble-menu"
editor={editor}
shouldShow={handleShouldShow}
updateDelay={0}
>
<StyledBubbleMenuContainer>
<TurnIntoBlockDropdown editor={editor} />
{menuActions.map(({ Icon, onClick, isActive }) => {
return (
<BubbleMenuIconButton
key={Icon.name || Icon.displayName || 'unknown'}
Icon={Icon}
onClick={onClick}
isActive={isActive}
/>
);
})}
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
</StyledBubbleMenuContainer>
</BubbleMenu>
);
};
@@ -0,0 +1,89 @@
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';
import { useId } from 'react';
import { IconPilcrow } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
const StyledMenuItem = styled.button`
align-items: center;
background: none;
border: none;
color: ${({ theme }) => theme.font.color.tertiary};
cursor: pointer;
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.regular};
gap: 4px;
height: ${({ theme }) => theme.spacing(6)};
padding: 0;
width: 100%;
padding: 0 ${({ theme }) => theme.spacing(1.5)};
border-radius: ${({ theme }) => theme.spacing(1.5)};
:hover {
background: ${({ theme }) => theme.background.transparent.medium};
}
:focus {
outline: none;
}
`;
type TurnIntoBlockDropdownProps = {
editor: Editor;
};
export const TurnIntoBlockDropdown = ({
editor,
}: TurnIntoBlockDropdownProps) => {
const theme = useTheme();
const instanceId = useId();
const dropdownId = `turn-into-block-dropdown-${instanceId}`;
const { toggleDropdown } = useToggleDropdown();
const options = useTurnIntoBlockOptions(editor);
const activeItem = options.find((option) => option.isActive());
const { icon: ActiveIcon = IconPilcrow, title: activeTitle = 'Paragraph' } =
activeItem ?? {};
return (
<Dropdown
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{options.map(({ id, title, icon, onClick }) => (
<MenuItem
key={id}
text={title}
LeftIcon={icon}
onClick={() => {
onClick();
toggleDropdown({
dropdownComponentInstanceIdFromProps: dropdownId,
});
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={dropdownId}
clickableComponent={
<StyledMenuItem>
<ActiveIcon size={theme.icon.size.md} />
{activeTitle}
</StyledMenuItem>
}
dropdownOffset={{
y: parseInt(theme.spacing(1), 10),
}}
/>
);
};
@@ -0,0 +1,21 @@
import { ResizableImageView } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView';
import {
type ImageOptions,
Image as TiptapImage,
} from '@tiptap/extension-image';
import { ReactNodeViewRenderer } from '@tiptap/react';
export const ResizableImage = TiptapImage.extend<ImageOptions>({
addAttributes() {
return {
...this.parent?.(),
align: {
default: 'left',
},
};
},
addNodeView: () => {
return ReactNodeViewRenderer(ResizableImageView);
},
});
@@ -0,0 +1,215 @@
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import React, { useCallback, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const IMAGE_MIN_WIDTH = 32;
const IMAGE_MAX_WIDTH = 600;
const StyledNodeViewWrapper = styled(NodeViewWrapper)`
height: 100%;
${({ align }) => {
switch (align) {
case 'left':
return css`
margin-left: 0;
`;
case 'right':
return css`
margin-right: 0;
`;
case 'center':
return css`
margin-left: auto;
margin-right: auto;
`;
}
}}
`;
const StyledImageWrapper = styled.div<{ width?: number }>`
height: 100%;
`;
const StyledImageContainer = styled.div`
max-width: 100%;
position: relative;
`;
const StyledImage = styled.img`
height: auto;
max-width: 100%;
`;
const StyledImageHandle = styled.div<{ handle: 'left' | 'right' }>`
border-radius: ${({ theme }) => theme.border.radius.md};
background-color: ${({ theme }) => theme.background.primaryInverted};
border: 1px solid ${({ theme }) => theme.background.primary};
cursor: col-resize;
height: ${({ theme }) => theme.spacing(8)};
position: absolute;
top: 50%;
transform: translateY(-50%);
width: ${({ theme }) => theme.spacing(2)};
z-index: 1;
${({ handle, theme }) => {
if (handle === 'left') {
return css`
left: ${theme.spacing(1)};
`;
}
return css`
right: ${theme.spacing(1)};
`;
}}
`;
type ResizeParams = {
initialWidth: number;
initialClientX: number;
handleUsed: 'left' | 'right';
};
type ResizableImageViewProps = NodeViewProps;
export const ResizableImageView = (props: ResizableImageViewProps) => {
const { editor, node, updateAttributes } = props;
const { width: initialWidth, align = 'left', src, alt } = node.attrs;
const imageWrapperRef = useRef<HTMLDivElement>(null);
// Controls visibility of resize handles when hovering over the image
const [isHovering, setIsHovering] = useState(false);
const [width, setWidth] = useState(initialWidth || 0);
// Controls actual resize operation state (null = not resizing, object = actively resizing)
const [resizeParams, setResizeParams] = useState<ResizeParams | null>(null);
// Create stable event handlers using a closure approach
const createMouseHandlers = useCallback(() => {
let currentResizeParams: ResizeParams | null = null;
const handleMouseMove = (event: MouseEvent) => {
const imageWrapper = imageWrapperRef.current;
if (!isDefined(currentResizeParams) || !isDefined(imageWrapper)) {
return;
}
const deltaX = event.clientX - currentResizeParams.initialClientX;
const { initialWidth, handleUsed } = currentResizeParams;
let newWidth =
align === 'center'
? initialWidth + (handleUsed === 'left' ? -deltaX * 2 : deltaX * 2)
: initialWidth + (handleUsed === 'left' ? -deltaX : deltaX);
const maxWidth =
editor.view.dom.firstElementChild?.clientWidth || IMAGE_MAX_WIDTH;
newWidth = Math.min(Math.max(newWidth, IMAGE_MIN_WIDTH), maxWidth);
setWidth(newWidth);
imageWrapper.style.width = `${newWidth}px`;
};
const handleMouseUp = (event: MouseEvent) => {
const imageWrapper = imageWrapperRef.current;
if (!isDefined(imageWrapper) || !isDefined(currentResizeParams)) {
return;
}
if (
(!event.target || !imageWrapper.contains(event.target as Node)) &&
isHovering
) {
setIsHovering(false);
return;
}
const finalWidth = imageWrapper.clientWidth;
currentResizeParams = null;
setResizeParams(null);
updateAttributes({ width: finalWidth });
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
const startResize = (resizeParams: ResizeParams) => {
currentResizeParams = resizeParams;
setResizeParams(resizeParams);
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
};
return { startResize };
}, [editor, align, isHovering, updateAttributes]);
const { startResize } = createMouseHandlers();
const handleImageHandleMouseDown = useCallback(
(handle: 'left' | 'right', event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
const resizeParams = {
initialWidth: imageWrapperRef.current?.clientWidth ?? IMAGE_MAX_WIDTH,
initialClientX: event.clientX,
handleUsed: handle,
};
startResize(resizeParams);
},
[startResize],
);
const handleImageHover = useCallback(() => {
if (!editor.isEditable) {
return;
}
setIsHovering(true);
}, [editor.isEditable]);
const handleImageHoverEnd = useCallback(() => {
setIsHovering(false);
}, []);
return (
<StyledNodeViewWrapper
onMouseEnter={handleImageHover}
onMouseLeave={handleImageHoverEnd}
align={align}
>
<StyledImageWrapper
ref={imageWrapperRef}
style={{ width: width ? `${width}px` : 'fit-content' }}
>
<StyledImageContainer>
<StyledImage
src={src}
alt={alt}
draggable={false}
contentEditable={false}
/>
{/* Show resize handles when hovering over image OR actively resizing */}
{(isHovering || isDefined(resizeParams)) && (
<>
<StyledImageHandle
handle="left"
onMouseDown={(e) => handleImageHandleMouseDown('left', e)}
/>
<StyledImageHandle
handle="right"
onMouseDown={(e) => handleImageHandleMouseDown('right', e)}
/>
</>
)}
</StyledImageContainer>
</StyledImageWrapper>
</StyledNodeViewWrapper>
);
};
@@ -0,0 +1,61 @@
import {
UploadImagePlugin,
type UploadImagePluginProps,
} from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin';
import { Extension } from '@tiptap/core';
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'> & {};
type UploadImageStorage = {
placeholderImages: Set<string>;
};
declare module '@tiptap/core' {
interface Storage {
uploadImage: UploadImageStorage;
}
}
export const UploadImageExtension = Extension.create<
UploadImageOptions,
UploadImageStorage
>({
name: 'uploadImage',
addOptions: () => {
return {
allowedMimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
],
onImageUpload: undefined,
onImageUploadError: undefined,
};
},
addStorage: () => {
return {
placeholderImages: new Set(),
};
},
addProseMirrorPlugins() {
const { onImageUpload } = this.options;
if (!onImageUpload) {
return [];
}
return [
UploadImagePlugin({
editor: this.editor,
allowedMimeTypes: this.options.allowedMimeTypes,
onImageUpload: this.options.onImageUpload,
onImageUploadError: this.options.onImageUploadError,
}),
];
},
});
@@ -0,0 +1,128 @@
import { type Editor } from '@tiptap/core';
import { type Node } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { type EditorView } from '@tiptap/pm/view';
export type UploadImagePluginProps = {
editor: Editor;
allowedMimeTypes?: string[];
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
};
export const UploadImagePlugin = (options: UploadImagePluginProps) => {
const { editor, onImageUpload, allowedMimeTypes, onImageUploadError } =
options;
const handleImageUpload = (view: EditorView, file: File, pos?: number) => {
const placeholderSrc = URL.createObjectURL(file);
const { tr, schema } = view.state;
const imageNode = schema.nodes.image.create({
src: placeholderSrc,
alt: file.name,
});
editor.extensionStorage.uploadImage.placeholderImages.add(placeholderSrc);
const resolvedPos =
pos !== undefined
? view.state.doc.resolve(pos)
: view.state.selection.$head;
const transaction = tr.insert(resolvedPos.pos, imageNode);
view.dispatch(transaction);
onImageUpload?.(file)
.then((uploadedSrc) => {
const updateTr = view.state.tr;
const predicate = (node: Node) =>
node.type.name === 'image' && node.attrs.src === placeholderSrc;
view.state.doc.descendants((node, pos) => {
if (predicate(node)) {
updateTr.setNodeMarkup(pos, undefined, {
...node.attrs,
src: uploadedSrc,
});
return false;
}
});
view.dispatch(updateTr);
})
.catch((error: Error) => {
const removeTr = view.state.tr;
const predicate = (node: Node) =>
node.type.name === 'image' && node.attrs.src === placeholderSrc;
view.state.doc.descendants((node, pos) => {
if (predicate(node)) {
removeTr.delete(pos, pos + node.nodeSize);
return false; // Stop traversal after finding the target
}
});
view.dispatch(removeTr);
onImageUploadError?.(error, file);
})
.finally(() => {
editor.extensionStorage.uploadImage.placeholderImages.delete(
placeholderSrc,
);
URL.revokeObjectURL(placeholderSrc);
});
};
return new Plugin({
key: new PluginKey('uploadImage'),
props: {
handleDrop: (view, event) => {
if (!onImageUpload || !event.dataTransfer?.files?.length) {
return false;
}
const images = Array.from(event.dataTransfer.files).filter((file) =>
allowedMimeTypes?.includes(file.type),
);
if (images.length === 0) {
return false;
}
const pos = view.posAtCoords({
left: event.clientX,
top: event.clientY,
});
if (!pos) {
return false;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => handleImageUpload(view, file, pos.pos));
return true;
},
handlePaste: (view, event) => {
if (!onImageUpload || !event.clipboardData?.files?.length) {
return false;
}
const images = Array.from(event.clipboardData.files).filter((file) =>
allowedMimeTypes?.includes(file.type),
);
if (images.length === 0) {
return false;
}
event.preventDefault();
event.stopPropagation();
images.forEach((file) => handleImageUpload(view, file));
return true;
},
},
});
};
@@ -0,0 +1,103 @@
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 { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
import { Bold } from '@tiptap/extension-bold';
import { Document } from '@tiptap/extension-document';
import { HardBreak } from '@tiptap/extension-hard-break';
import { Heading } from '@tiptap/extension-heading';
import { Italic } from '@tiptap/extension-italic';
import { Link } from '@tiptap/extension-link';
import { Paragraph } from '@tiptap/extension-paragraph';
import { Strike } from '@tiptap/extension-strike';
import { Text } from '@tiptap/extension-text';
import { Underline } from '@tiptap/extension-underline';
import { Dropcursor, Placeholder, UndoRedo } from '@tiptap/extensions';
import { type Editor, useEditor } from '@tiptap/react';
import { type DependencyList, useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseEmailEditorProps = {
placeholder: string | undefined;
readonly: boolean | undefined;
defaultValue: string | undefined | null;
onUpdate: (editor: Editor) => void;
onFocus?: (editor: Editor) => void;
onBlur?: (editor: Editor) => void;
onImageUpload?: (file: File) => Promise<string>;
onImageUploadError?: (error: Error, file: File) => void;
};
export const useEmailEditor = (
{
placeholder,
readonly,
defaultValue,
onUpdate,
onFocus,
onBlur,
onImageUpload,
onImageUploadError,
}: UseEmailEditorProps,
dependencies?: DependencyList,
) => {
const extensions = useMemo(
() => [
Document,
Paragraph,
Text,
Placeholder.configure({
placeholder,
}),
VariableTag,
HardBreak.configure({
keepMarks: false,
}),
UndoRedo,
Bold,
Italic,
Strike,
Underline,
Heading.configure({
levels: [1, 2, 3],
}),
Link.configure({
openOnClick: false,
}),
ResizableImage,
Dropcursor,
UploadImageExtension.configure({
onImageUpload,
onImageUploadError,
}),
],
[placeholder, onImageUpload, onImageUploadError],
);
const editor = useEditor(
{
extensions,
content: isDefined(defaultValue)
? getInitialEmailEditorContent(defaultValue)
: undefined,
editable: !readonly,
onUpdate: ({ editor }) => {
onUpdate(editor);
},
onFocus: ({ editor }) => {
onFocus?.(editor);
},
onBlur: ({ editor }) => {
onBlur?.(editor);
},
editorProps: {
scrollThreshold: 60,
scrollMargin: 60,
},
injectCSS: false,
},
dependencies,
);
return editor;
};
@@ -0,0 +1,20 @@
import { type Editor } from '@tiptap/core';
import { useEditorState } from '@tiptap/react';
export const useTextBubbleState = (editor: Editor) => {
const state = useEditorState({
editor,
selector: (ctx) => {
return {
isBold: ctx.editor.isActive('bold'),
isItalic: ctx.editor.isActive('italic'),
isStrike: ctx.editor.isActive('strike'),
isUnderline: ctx.editor.isActive('underline'),
isLink: ctx.editor.isActive('link'),
linkHref: ctx.editor.getAttributes('link').href || '',
};
},
});
return state;
};
@@ -0,0 +1,81 @@
import { type Editor, useEditorState } from '@tiptap/react';
import {
type IconComponent,
IconH1,
IconH2,
IconH3,
IconPilcrow,
} from 'twenty-ui/display';
export type TurnIntoBlockOptions = {
title: string;
id: string;
disabled: () => boolean;
isActive: () => boolean;
onClick: () => void;
icon: IconComponent;
};
export const useTurnIntoBlockOptions = (editor: Editor) => {
return useEditorState({
editor,
selector: ({ editor }): TurnIntoBlockOptions[] => [
{
id: 'paragraph',
title: 'Paragraph',
icon: IconPilcrow,
onClick: () => {
return editor.chain().focus().setParagraph().run();
},
disabled: () => {
return !editor.can().setParagraph();
},
isActive: () => {
return editor.isActive('paragraph');
},
},
{
id: 'heading1',
title: 'Heading 1',
icon: IconH1,
onClick: () => {
return editor.chain().focus().setHeading({ level: 1 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 1 });
},
isActive: () => {
return editor.isActive('heading', { level: 1 });
},
},
{
id: 'heading2',
title: 'Heading 2',
icon: IconH2,
onClick: () => {
return editor.chain().focus().setHeading({ level: 2 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 2 });
},
isActive: () => {
return editor.isActive('heading', { level: 2 });
},
},
{
id: 'heading3',
title: 'Heading 3',
icon: IconH3,
onClick: () => {
return editor.chain().focus().setHeading({ level: 3 }).run();
},
disabled: () => {
return !editor.can().setHeading({ level: 3 });
},
isActive: () => {
return editor.isActive('heading', { level: 3 });
},
},
],
});
};
@@ -0,0 +1,27 @@
import { type Editor, isTextSelection } from '@tiptap/core';
type IsTextSelectedProps = {
editor: Editor;
};
export const isTextSelected = ({ editor }: IsTextSelectedProps) => {
const {
state: {
doc,
selection,
selection: { empty, from, to },
},
} = editor;
// Sometime check for `empty` is not enough.
// Doubleclick an empty paragraph returns a node size of 2.
// So we check also for an empty text size.
const isEmptyTextBlock =
!doc.textBetween(from, to).length && isTextSelection(selection);
if (empty || isEmptyTextBlock || !editor.isEditable) {
return false;
}
return true;
};