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:
+61
-103
@@ -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 && (
|
||||
|
||||
+222
@@ -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(),
|
||||
},
|
||||
},
|
||||
};
|
||||
+4
-2
@@ -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} />}
|
||||
|
||||
+98
@@ -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>
|
||||
);
|
||||
};
|
||||
+261
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
+276
@@ -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...',
|
||||
},
|
||||
};
|
||||
+320
@@ -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,
|
||||
},
|
||||
};
|
||||
+96
@@ -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>
|
||||
);
|
||||
};
|
||||
+62
@@ -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>
|
||||
);
|
||||
};
|
||||
+44
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
+79
@@ -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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+87
@@ -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>
|
||||
);
|
||||
};
|
||||
+89
@@ -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),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+21
@@ -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);
|
||||
},
|
||||
});
|
||||
+215
@@ -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>
|
||||
);
|
||||
};
|
||||
+61
@@ -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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
+128
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
+103
@@ -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;
|
||||
};
|
||||
+20
@@ -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;
|
||||
};
|
||||
+81
@@ -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 });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
+27
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user