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,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,98 +0,0 @@
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>
);
};
@@ -1,261 +0,0 @@
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}
</>
);
};
@@ -1,276 +0,0 @@
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...',
},
};
@@ -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,
},
};
@@ -1,96 +0,0 @@
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>
);
};
@@ -1,62 +0,0 @@
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>
);
};
@@ -1,44 +0,0 @@
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"
/>
);
};
@@ -1,79 +0,0 @@
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}
/>
}
/>
);
};
@@ -1,87 +0,0 @@
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>
);
};
@@ -1,89 +0,0 @@
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),
}}
/>
);
};
@@ -1,21 +0,0 @@
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);
},
});
@@ -1,215 +0,0 @@
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>
);
};
@@ -1,61 +0,0 @@
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,
}),
];
},
});
@@ -1,128 +0,0 @@
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;
},
},
});
};
@@ -1,103 +0,0 @@
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;
};
@@ -1,20 +0,0 @@
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;
};
@@ -1,81 +0,0 @@
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 });
},
},
],
});
};
@@ -1,27 +0,0 @@
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;
};