mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
a89e5d0f27
commit
9a2766feae
66 changed files with 3312 additions and 276 deletions
|
|
@ -7,8 +7,8 @@
|
|||
"@floating-ui/react": "^0.24.3",
|
||||
"@linaria/core": "^6.2.0",
|
||||
"@linaria/react": "^6.2.1",
|
||||
"@react-email/components": "0.0.35",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/components": "^0.5.3",
|
||||
"@react-email/render": "^1.2.3",
|
||||
"@sentry/profiling-node": "^9.26.0",
|
||||
"@sentry/react": "^9.26.0",
|
||||
"@sniptt/guards": "^0.2.0",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@
|
|||
"@lingui/cli": "^5.1.2",
|
||||
"@lingui/swc-plugin": "^5.6.0",
|
||||
"@lingui/vite-plugin": "^5.1.2",
|
||||
"@tiptap/core": "^3.4.2",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"react-email": "4.0.3"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
export type { JSONContent } from '@tiptap/core';
|
||||
export * from './emails/clean-suspended-workspace.email';
|
||||
export * from './emails/password-reset-link.email';
|
||||
export * from './emails/password-update-notify.email';
|
||||
export * from './emails/send-email-verification-link.email';
|
||||
export * from './emails/send-invite-link.email';
|
||||
export * from './emails/warn-suspended-workspace.email';
|
||||
export * from './emails/validate-approved-access-domain.email';
|
||||
export * from './emails/warn-suspended-workspace.email';
|
||||
export * from './utils/email-renderer/email-renderer';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
import { Body, Container, Head, Html } from '@react-email/components';
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||
|
||||
export const reactMarkupFromJSON = (json: JSONContent | string) => {
|
||||
if (typeof json === 'string') {
|
||||
return json;
|
||||
}
|
||||
|
||||
const jsxNodes = mappedNodeContent(json);
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `blockquote,h1,h2,h3,img,li,ol,p,ul{margin-top:0;margin-bottom:0}`,
|
||||
}}
|
||||
/>
|
||||
</Head>
|
||||
<Body>
|
||||
<Container
|
||||
style={{
|
||||
width: '100%',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
borderStyle: 'solid',
|
||||
}}
|
||||
>
|
||||
{jsxNodes}
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type TipTapMark } from 'twenty-shared/utils';
|
||||
|
||||
export const bold = (_: TipTapMark, children: ReactNode): ReactNode => {
|
||||
return <strong>{children}</strong>;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type TipTapMark } from 'twenty-shared/utils';
|
||||
|
||||
export const italic = (_: TipTapMark, children: ReactNode): ReactNode => {
|
||||
return <em>{children}</em>;
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type LinkMarkAttributes, type TipTapMark } from 'twenty-shared/utils';
|
||||
|
||||
export const link = (mark: TipTapMark, children: ReactNode): ReactNode => {
|
||||
const {
|
||||
href,
|
||||
target = '_blank',
|
||||
rel = 'noopener noreferrer',
|
||||
} = (mark.attrs as LinkMarkAttributes) || {};
|
||||
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={target}
|
||||
rel={rel}
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type TipTapMark } from 'twenty-shared/utils';
|
||||
|
||||
export const strike = (_: TipTapMark, children: ReactNode): ReactNode => {
|
||||
return <span style={{ textDecoration: 'line-through' }}>{children}</span>;
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ReactNode } from 'react';
|
||||
import { type TipTapMark } from 'twenty-shared/utils';
|
||||
|
||||
export const underline = (_: TipTapMark, children: ReactNode): ReactNode => {
|
||||
return <span style={{ textDecoration: 'underline' }}>{children}</span>;
|
||||
};
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import { Heading } from '@react-email/components';
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type HeadingLevel = 1 | 2 | 3;
|
||||
|
||||
type HeadingStyle = {
|
||||
element: 'h1' | 'h2' | 'h3';
|
||||
fontSize: string;
|
||||
};
|
||||
|
||||
const HEADING_STYLES: Record<HeadingLevel, HeadingStyle> = {
|
||||
1: { element: 'h1', fontSize: '36px' },
|
||||
2: { element: 'h2', fontSize: '30px' },
|
||||
3: { element: 'h3', fontSize: '24px' },
|
||||
};
|
||||
|
||||
export const heading = (node: JSONContent): ReactNode => {
|
||||
const { level } = node?.attrs || {};
|
||||
|
||||
if (!isDefined(level) || !HEADING_STYLES[level as HeadingLevel]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = mappedNodeContent(node);
|
||||
const { element, fontSize } = HEADING_STYLES[level as HeadingLevel];
|
||||
|
||||
return (
|
||||
<Heading as={element} style={{ fontSize }}>
|
||||
{content}
|
||||
</Heading>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { Column, Row } from '@react-email/components';
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const image = (node: JSONContent): ReactNode => {
|
||||
const { src, alt, align = 'left', width } = node?.attrs || {};
|
||||
if (!isDefined(src)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Row>
|
||||
<Column align={align}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
style={{
|
||||
width: isDefined(width) ? width : 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: '100%',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
textDecoration: 'none',
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
</Column>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Text } from '@react-email/components';
|
||||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import { mappedNodeContent } from 'src/utils/email-renderer/renderers/render-node';
|
||||
|
||||
export const paragraph = (node: JSONContent): ReactNode => {
|
||||
return <Text>{mappedNodeContent(node)}</Text>;
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import { renderMark } from 'src/utils/email-renderer/renderers/render-mark';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const text = (node: JSONContent): ReactNode => {
|
||||
if (isDefined(node?.marks)) {
|
||||
return renderMark(node);
|
||||
}
|
||||
|
||||
const { text } = node;
|
||||
if (!isDefined(text)) {
|
||||
return <> </>;
|
||||
}
|
||||
|
||||
return <>{text}</>;
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const variableTag = (node: JSONContent): ReactNode => {
|
||||
const { variable } = node?.attrs || {};
|
||||
if (!isDefined(variable)) {
|
||||
return <> </>;
|
||||
}
|
||||
|
||||
return <>{variable}</>;
|
||||
};
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { type JSONContent } from '@tiptap/core';
|
||||
import { type ReactNode } from 'react';
|
||||
import {
|
||||
type TipTapMark,
|
||||
type TipTapMarkType,
|
||||
TIPTAP_MARKS_RENDER_ORDER,
|
||||
TIPTAP_MARK_TYPES,
|
||||
} from 'twenty-shared/utils';
|
||||
import { bold } from '../marks/bold';
|
||||
import { italic } from '../marks/italic';
|
||||
import { link } from '../marks/link';
|
||||
import { strike } from '../marks/strike';
|
||||
import { underline } from '../marks/underline';
|
||||
|
||||
const MARK_RENDERERS = {
|
||||
[TIPTAP_MARK_TYPES.BOLD]: bold,
|
||||
[TIPTAP_MARK_TYPES.ITALIC]: italic,
|
||||
[TIPTAP_MARK_TYPES.UNDERLINE]: underline,
|
||||
[TIPTAP_MARK_TYPES.STRIKE]: strike,
|
||||
[TIPTAP_MARK_TYPES.LINK]: link,
|
||||
} as const;
|
||||
|
||||
export const renderMark = (node: JSONContent): ReactNode => {
|
||||
const text = node?.text || <> </>;
|
||||
const marks = (node?.marks as TipTapMark[]) || [];
|
||||
|
||||
// Sort marks according to the defined render order
|
||||
marks.sort((a, b) => {
|
||||
return (
|
||||
TIPTAP_MARKS_RENDER_ORDER.indexOf(a.type) -
|
||||
TIPTAP_MARKS_RENDER_ORDER.indexOf(b.type)
|
||||
);
|
||||
});
|
||||
|
||||
// Apply marks from innermost to outermost
|
||||
return marks.reduce((children: ReactNode, mark: TipTapMark) => {
|
||||
const renderer = MARK_RENDERERS[mark.type as TipTapMarkType];
|
||||
|
||||
if (!renderer) {
|
||||
// Fallback for unknown mark types - skip unknown marks
|
||||
return children;
|
||||
}
|
||||
|
||||
return renderer(mark, children);
|
||||
}, text);
|
||||
};
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { type JSONContent } from '@tiptap/core';
|
||||
import { Fragment, type ReactNode } from 'react';
|
||||
import { TIPTAP_NODE_TYPES, type TipTapNodeType } from 'twenty-shared/utils';
|
||||
import { heading } from '../nodes/heading';
|
||||
import { image } from '../nodes/image';
|
||||
import { paragraph } from '../nodes/paragraph';
|
||||
import { text } from '../nodes/text';
|
||||
import { variableTag } from '../nodes/variable-tag';
|
||||
|
||||
const NODE_RENDERERS = {
|
||||
[TIPTAP_NODE_TYPES.PARAGRAPH]: paragraph,
|
||||
[TIPTAP_NODE_TYPES.TEXT]: text,
|
||||
[TIPTAP_NODE_TYPES.HEADING]: heading,
|
||||
[TIPTAP_NODE_TYPES.VARIABLE_TAG]: variableTag,
|
||||
[TIPTAP_NODE_TYPES.IMAGE]: image,
|
||||
};
|
||||
|
||||
const renderNode = (node: JSONContent): ReactNode => {
|
||||
const renderer = NODE_RENDERERS[node.type as TipTapNodeType];
|
||||
|
||||
if (!renderer) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return renderer(node);
|
||||
};
|
||||
|
||||
export const mappedNodeContent = (node: JSONContent): JSX.Element[] => {
|
||||
const allNodes = node.content || [];
|
||||
return allNodes
|
||||
.map((childNode, index) => {
|
||||
const component = renderNode(childNode);
|
||||
if (!component) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Fragment key={index}>{component}</Fragment>;
|
||||
})
|
||||
.filter((n) => n !== null);
|
||||
};
|
||||
|
|
@ -53,10 +53,17 @@
|
|||
"@react-pdf/renderer": "^4.1.6",
|
||||
"@scalar/api-reference-react": "^0.4.36",
|
||||
"@tiptap/core": "^3.4.2",
|
||||
"@tiptap/extension-bold": "^3.4.2",
|
||||
"@tiptap/extension-document": "^3.4.2",
|
||||
"@tiptap/extension-hard-break": "^3.4.2",
|
||||
"@tiptap/extension-heading": "^3.4.2",
|
||||
"@tiptap/extension-image": "^3.4.4",
|
||||
"@tiptap/extension-italic": "^3.4.2",
|
||||
"@tiptap/extension-link": "^3.4.2",
|
||||
"@tiptap/extension-paragraph": "^3.4.2",
|
||||
"@tiptap/extension-strike": "^3.4.2",
|
||||
"@tiptap/extension-text": "^3.4.2",
|
||||
"@tiptap/extension-underline": "^3.4.2",
|
||||
"@tiptap/extensions": "^3.4.2",
|
||||
"@tiptap/react": "^3.4.2",
|
||||
"@xyflow/react": "^12.4.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
|
||||
import {
|
||||
Breadcrumb,
|
||||
type BreadcrumbProps,
|
||||
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import styled from '@emotion/styled';
|
||||
import { useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
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.RootModal};
|
||||
`;
|
||||
|
||||
const StyledFullScreenHeader = styled(PageHeader)`
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledFullScreenContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: ${({ theme }) =>
|
||||
`0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}`};
|
||||
|
||||
// Make the immediate child a flex column that grows, so nested components
|
||||
// with height="100%" (e.g., editors) can size correctly.
|
||||
> * {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
row-gap: ${({ theme }) => theme.spacing(5)};
|
||||
}
|
||||
`;
|
||||
|
||||
type UseFullScreenModalProps = {
|
||||
links: BreadcrumbProps['links'];
|
||||
onClose: () => void;
|
||||
hasClosePageButton?: boolean;
|
||||
};
|
||||
|
||||
export const useFullScreenModal = ({
|
||||
links,
|
||||
onClose,
|
||||
hasClosePageButton = true,
|
||||
}: UseFullScreenModalProps) => {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const renderFullScreenModal = (
|
||||
children: React.ReactNode,
|
||||
isOpen: boolean,
|
||||
) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<StyledFullScreenOverlay
|
||||
ref={overlayRef}
|
||||
data-globally-prevent-click-outside="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<StyledFullScreenHeader
|
||||
title={<Breadcrumb links={links} />}
|
||||
hasClosePageButton={hasClosePageButton}
|
||||
onClosePage={onClose}
|
||||
/>
|
||||
<StyledFullScreenContent>{children}</StyledFullScreenContent>
|
||||
</StyledFullScreenOverlay>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
overlayRef,
|
||||
renderFullScreenModal,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
|
||||
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
|
||||
import { useUpdateOneServerlessFunction } from '@/settings/serverless-functions/hooks/useUpdateOneServerlessFunction';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { PageHeader } from '@/ui/layout/page/components/PageHeader';
|
||||
import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight';
|
||||
import {
|
||||
Breadcrumb,
|
||||
type BreadcrumbProps,
|
||||
} from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
|
||||
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
|
|
@ -47,8 +42,7 @@ import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/Workf
|
|||
import { type Monaco } from '@monaco-editor/react';
|
||||
import { type editor } from 'monaco-editor';
|
||||
import { AutoTypings } from 'monaco-editor-auto-typings';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
|
@ -68,35 +62,6 @@ const StyledCodeEditorContainer = styled.div`
|
|||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const StyledFullScreenOverlay = styled.div`
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: ${({ theme }) => theme.background.noisy};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
z-index: ${RootStackingContextZIndices.Dialog};
|
||||
`;
|
||||
|
||||
const StyledFullScreenHeader = styled(PageHeader)`
|
||||
padding-left: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledFullScreenContent = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(3)};
|
||||
height: calc(
|
||||
100% - ${PAGE_BAR_MIN_HEIGHT}px - ${({ theme }) => theme.spacing(2 * 2 + 5)}
|
||||
);
|
||||
padding: ${({ theme }) =>
|
||||
`0 ${theme.spacing(3)} ${theme.spacing(3)} ${theme.spacing(3)}`};
|
||||
`;
|
||||
|
||||
const StyledTabList = styled(TabList)`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
padding-left: ${({ theme }) => theme.spacing(2)};
|
||||
|
|
@ -131,7 +96,6 @@ export const WorkflowEditActionServerlessFunction = ({
|
|||
const { t } = useLingui();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const fullScreenOverlayRef = useRef<HTMLDivElement>(null);
|
||||
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
|
||||
const fullScreenFocusId = `code-editor-fullscreen-${serverlessFunctionId}`;
|
||||
const activeTabId = useRecoilComponentValue(
|
||||
|
|
@ -352,17 +316,6 @@ export const WorkflowEditActionServerlessFunction = ({
|
|||
dependencies: [isFullScreen],
|
||||
});
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [fullScreenOverlayRef],
|
||||
callback: () => {
|
||||
if (isFullScreen) {
|
||||
handleExitFullScreen();
|
||||
}
|
||||
},
|
||||
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
|
||||
enabled: isFullScreen,
|
||||
});
|
||||
|
||||
const headerTitle = isDefined(action.name)
|
||||
? action.name
|
||||
: 'Code - Serverless Function';
|
||||
|
|
@ -372,19 +325,6 @@ export const WorkflowEditActionServerlessFunction = ({
|
|||
|
||||
const testLogsTextAreaId = `${serverlessFunctionId}-test-logs`;
|
||||
|
||||
const handleEnterFullScreen = () => {
|
||||
setIsFullScreen(true);
|
||||
setTimeout(() => {
|
||||
if (isDefined(fullScreenOverlayRef.current)) {
|
||||
fullScreenOverlayRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
setIsFullScreen(false);
|
||||
};
|
||||
|
||||
const breadcrumbLinks: BreadcrumbProps['links'] = [
|
||||
{
|
||||
children: workflow?.name?.trim() || t`Untitled Workflow`,
|
||||
|
|
@ -399,46 +339,64 @@ export const WorkflowEditActionServerlessFunction = ({
|
|||
},
|
||||
];
|
||||
|
||||
const fullScreenOverlay = isFullScreen
|
||||
? createPortal(
|
||||
<StyledFullScreenOverlay
|
||||
ref={fullScreenOverlayRef}
|
||||
data-globally-prevent-click-outside="true"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<StyledFullScreenHeader
|
||||
title={<Breadcrumb links={breadcrumbLinks} />}
|
||||
hasClosePageButton={!isMobile}
|
||||
onClosePage={handleExitFullScreen}
|
||||
/>
|
||||
<StyledFullScreenContent data-globally-prevent-click-outside="true">
|
||||
<WorkflowEditActionServerlessFunctionFields
|
||||
functionInput={functionInput}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
onInputChange={handleInputChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
<StyledFullScreenCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
language="typescript"
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
setMarkers={getWrongExportedFunctionMarkers}
|
||||
options={{
|
||||
readOnly: actionOptions.readonly,
|
||||
domReadOnly: actionOptions.readonly,
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 4, bottom: 4 },
|
||||
}}
|
||||
/>
|
||||
</StyledFullScreenCodeEditorContainer>
|
||||
</StyledFullScreenContent>
|
||||
</StyledFullScreenOverlay>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
const { overlayRef: fullScreenOverlayRef, renderFullScreenModal } =
|
||||
useFullScreenModal({
|
||||
links: breadcrumbLinks,
|
||||
onClose: () => setIsFullScreen(false),
|
||||
hasClosePageButton: !isMobile,
|
||||
});
|
||||
|
||||
useListenClickOutside({
|
||||
refs: [fullScreenOverlayRef],
|
||||
callback: () => {
|
||||
if (isFullScreen) {
|
||||
handleExitFullScreen();
|
||||
}
|
||||
},
|
||||
listenerId: `full-screen-overlay-${serverlessFunctionId}`,
|
||||
enabled: isFullScreen,
|
||||
});
|
||||
|
||||
const handleEnterFullScreen = () => {
|
||||
setIsFullScreen(true);
|
||||
setTimeout(() => {
|
||||
if (isDefined(fullScreenOverlayRef.current)) {
|
||||
fullScreenOverlayRef.current.focus();
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
setIsFullScreen(false);
|
||||
};
|
||||
|
||||
const fullScreenOverlay = renderFullScreenModal(
|
||||
<div data-globally-prevent-click-outside="true">
|
||||
<WorkflowEditActionServerlessFunctionFields
|
||||
functionInput={functionInput}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
onInputChange={handleInputChange}
|
||||
readonly={actionOptions.readonly}
|
||||
/>
|
||||
<StyledFullScreenCodeEditorContainer>
|
||||
<CodeEditor
|
||||
height="100%"
|
||||
value={formValues.code?.[INDEX_FILE_PATH]}
|
||||
language="typescript"
|
||||
onChange={handleCodeChange}
|
||||
onMount={handleEditorDidMount}
|
||||
setMarkers={getWrongExportedFunctionMarkers}
|
||||
options={{
|
||||
readOnly: actionOptions.readonly,
|
||||
domReadOnly: actionOptions.readonly,
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 4, bottom: 4 },
|
||||
}}
|
||||
/>
|
||||
</StyledFullScreenCodeEditorContainer>
|
||||
</div>,
|
||||
isFullScreen,
|
||||
);
|
||||
|
||||
return (
|
||||
!loading && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,222 @@
|
|||
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowEditActionServerlessFunction } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunction';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { graphql, HttpResponse } from 'msw';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
|
||||
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||
|
||||
const DEFAULT_ACTION: WorkflowCodeAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Code',
|
||||
type: 'CODE',
|
||||
valid: false,
|
||||
settings: {
|
||||
input: {
|
||||
serverlessFunctionId: '',
|
||||
serverlessFunctionVersion: 'draft',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CONFIGURED_ACTION: WorkflowCodeAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Process Data',
|
||||
type: 'CODE',
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
serverlessFunctionId: 'test-function-id',
|
||||
serverlessFunctionVersion: 'draft',
|
||||
serverlessFunctionInput: {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
score: 95,
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
result: {
|
||||
type: 'TEXT',
|
||||
label: 'Result',
|
||||
value: 'Processing completed',
|
||||
isLeaf: true,
|
||||
},
|
||||
status: {
|
||||
type: 'TEXT',
|
||||
label: 'Status',
|
||||
value: 'success',
|
||||
isLeaf: true,
|
||||
},
|
||||
},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: {
|
||||
value: true,
|
||||
},
|
||||
continueOnFailure: {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkflowEditActionServerlessFunction> = {
|
||||
title: 'Modules/Workflow/Actions/Code/EditAction',
|
||||
component: WorkflowEditActionServerlessFunction,
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
...graphqlMocks.handlers,
|
||||
graphql.query('FindManyServerlessFunctions', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
findManyServerlessFunctions: [
|
||||
{
|
||||
id: 'test-function-id',
|
||||
name: 'Test Function',
|
||||
description: 'A test serverless function',
|
||||
runtime: 'nodejs22.x',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query('FindOneServerlessFunction', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
findOneServerlessFunction: {
|
||||
id: 'test-function-id',
|
||||
name: 'Test Function',
|
||||
description: 'A test serverless function',
|
||||
runtime: 'nodejs22.x',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
graphql.query('FindManyAvailablePackages', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
getAvailablePackages: ['axios', 'lodash', 'moment'],
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
},
|
||||
decorators: [
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
RouterDecorator,
|
||||
WorkspaceDecorator,
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof WorkflowEditActionServerlessFunction>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Configured: Story = {
|
||||
args: {
|
||||
action: CONFIGURED_ACTION,
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
action: CONFIGURED_ACTION,
|
||||
actionOptions: {
|
||||
readonly: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTestResults: Story = {
|
||||
args: {
|
||||
action: {
|
||||
...CONFIGURED_ACTION,
|
||||
settings: {
|
||||
...CONFIGURED_ACTION.settings,
|
||||
outputSchema: {
|
||||
result: {
|
||||
type: 'TEXT',
|
||||
label: 'Processing Result',
|
||||
value: 'Successfully processed 150 records',
|
||||
isLeaf: true,
|
||||
},
|
||||
executionTime: {
|
||||
type: 'NUMBER',
|
||||
label: 'Execution Time (ms)',
|
||||
value: 245,
|
||||
isLeaf: true,
|
||||
},
|
||||
errors: {
|
||||
type: 'ARRAY',
|
||||
label: 'Errors',
|
||||
value: [],
|
||||
isLeaf: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const EmptyFunction: Story = {
|
||||
args: {
|
||||
action: {
|
||||
...DEFAULT_ACTION,
|
||||
settings: {
|
||||
...DEFAULT_ACTION.settings,
|
||||
input: {
|
||||
serverlessFunctionId: '',
|
||||
serverlessFunctionVersion: 'draft',
|
||||
serverlessFunctionInput: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
actionOptions: {
|
||||
onActionUpdate: fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -14,6 +14,7 @@ import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
|||
import { WorkflowActionFooter } from '@/workflow/workflow-steps/components/WorkflowActionFooter';
|
||||
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
|
||||
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
|
||||
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
|
||||
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { useTheme } from '@emotion/react';
|
||||
|
|
@ -220,6 +221,7 @@ export const WorkflowEditActionSendEmail = ({
|
|||
const navigate = useNavigateSettings();
|
||||
|
||||
const { closeCommandMenu } = useCommandMenu();
|
||||
|
||||
return (
|
||||
!loading && (
|
||||
<>
|
||||
|
|
@ -283,7 +285,8 @@ export const WorkflowEditActionSendEmail = ({
|
|||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
/>
|
||||
<FormTextFieldInput
|
||||
<WorkflowSendEmailBody
|
||||
action={action}
|
||||
label="Body"
|
||||
placeholder="Enter email body"
|
||||
readonly={actionOptions.readonly}
|
||||
|
|
@ -292,7 +295,6 @@ export const WorkflowEditActionSendEmail = ({
|
|||
handleFieldChange('body', body);
|
||||
}}
|
||||
VariablePicker={WorkflowVariablePicker}
|
||||
multiline
|
||||
/>
|
||||
</WorkflowStepBody>
|
||||
{!actionOptions.readonly && <WorkflowActionFooter stepId={action.id} />}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { FORM_FIELD_PLACEHOLDER_STYLES } from '@/object-record/record-field/ui/form-types/constants/FormFieldPlaceholderStyles';
|
||||
import { ImageBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/image-bubble-menu/ImageBubbleMenu';
|
||||
import { LinkBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/link-bubble-menu/LinkBubbleMenu';
|
||||
import { TextBubbleMenu } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import styled from '@emotion/styled';
|
||||
import { EditorContent, type Editor } from '@tiptap/react';
|
||||
|
||||
const EMAIL_EDITOR_MIN_HEIGHT = 340;
|
||||
const EMAIL_EDITOR_MAX_WIDTH = 600;
|
||||
|
||||
const StyledEditorContainer = styled.div<{
|
||||
readonly?: boolean;
|
||||
}>`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
.editor-content {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: ${EMAIL_EDITOR_MIN_HEIGHT}px;
|
||||
max-width: ${EMAIL_EDITOR_MAX_WIDTH}px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tiptap {
|
||||
padding: ${({ theme }) => `${theme.spacing(1)} ${theme.spacing(2)}`};
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
color: ${({ theme, readonly }) =>
|
||||
readonly ? theme.font.color.light : theme.font.color.primary};
|
||||
font-family: ${({ theme }) => theme.font.family};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
border: none !important;
|
||||
|
||||
p.is-editor-empty:first-of-type::before {
|
||||
${FORM_FIELD_PLACEHOLDER_STYLES}
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.variable-tag {
|
||||
background-color: ${({ theme }) => theme.color.blue10};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
color: ${({ theme }) => theme.color.blue};
|
||||
padding: ${({ theme }) => theme.spacing(1)};
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.ProseMirror-focused {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ProseMirror-hideselection * {
|
||||
caret-color: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkflowEmailEditorProps = {
|
||||
readonly: boolean | undefined;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const WorkflowEmailEditor = ({
|
||||
readonly,
|
||||
editor,
|
||||
}: WorkflowEmailEditorProps) => {
|
||||
return (
|
||||
<StyledEditorContainer readonly={readonly}>
|
||||
<EditorContent className="editor-content" editor={editor} />
|
||||
<ImageBubbleMenu editor={editor} />
|
||||
<TextBubbleMenu editor={editor} />
|
||||
<LinkBubbleMenu editor={editor} />
|
||||
</StyledEditorContainer>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
|
||||
import { type VariablePickerComponent } from '@/object-record/record-field/ui/form-types/types/VariablePickerComponent';
|
||||
import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
|
||||
import { InputErrorHelper } from '@/ui/input/components/InputErrorHelper';
|
||||
import { InputHint } from '@/ui/input/components/InputHint';
|
||||
import { InputLabel } from '@/ui/input/components/InputLabel';
|
||||
import { StyledDropdownButtonContainer } from '@/ui/layout/dropdown/components/StyledDropdownButtonContainer';
|
||||
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
|
||||
import { type BreadcrumbProps } from '@/ui/navigation/bread-crumb/components/Breadcrumb';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
|
||||
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
|
||||
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
|
||||
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
|
||||
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useId, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconMaximize } from 'twenty-ui/display';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
|
||||
const StyledWorkflowSendEmailBodyContainer = styled(FormFieldInputContainer)`
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledWorkflowSendEmailFieldContainer = styled.div`
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
const StyledWorkflowSendEmailBodyInnerContainer = styled.div`
|
||||
flex-grow: 1;
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
overflow: auto;
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledEmailEditorActionButtonContainer = styled.div`
|
||||
position: absolute;
|
||||
top: ${({ theme }) => theme.spacing(0)};
|
||||
right: ${({ theme }) => theme.spacing(7.5)};
|
||||
z-index: 1;
|
||||
`;
|
||||
|
||||
const StyledFullScreenEmailEditorContainer = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
const StyledFullScreenButtonContainer = styled(StyledDropdownButtonContainer)`
|
||||
background-color: 'transparent';
|
||||
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
:hover {
|
||||
cursor: pointer;
|
||||
background-color: ${({ theme }) => theme.background.transparent.light};
|
||||
}
|
||||
`;
|
||||
|
||||
type WorkflowSendEmailBodyProps = {
|
||||
action: WorkflowSendEmailAction;
|
||||
label?: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
defaultValue: string | undefined | null;
|
||||
onChange: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
VariablePicker?: VariablePickerComponent;
|
||||
};
|
||||
|
||||
export const WorkflowSendEmailBody = ({
|
||||
action,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
defaultValue,
|
||||
placeholder,
|
||||
onChange,
|
||||
readonly,
|
||||
VariablePicker,
|
||||
}: WorkflowSendEmailBodyProps) => {
|
||||
const instanceId = useId();
|
||||
const isMobile = useIsMobile();
|
||||
const [isFullScreen, setIsFullScreen] = useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const { uploadAttachmentFile } = useUploadAttachmentFile();
|
||||
const { enqueueErrorSnackBar } = useSnackBar();
|
||||
const { t } = useLingui();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
const workflowVisualizerWorkflowId = useRecoilComponentValue(
|
||||
workflowVisualizerWorkflowIdComponentState,
|
||||
);
|
||||
const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId);
|
||||
|
||||
const headerTitle = isDefined(action.name) ? action.name : 'Send Email';
|
||||
|
||||
const handleUploadAttachment = async (file: File) => {
|
||||
if (!isDefined(workflowVisualizerWorkflowId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { attachmentAbsoluteURL } = await uploadAttachmentFile(file, {
|
||||
id: workflowVisualizerWorkflowId,
|
||||
targetObjectNameSingular: CoreObjectNameSingular.Workflow,
|
||||
});
|
||||
|
||||
return attachmentAbsoluteURL;
|
||||
};
|
||||
|
||||
const handleImageUploadError = (error: Error, file: File) => {
|
||||
enqueueErrorSnackBar({
|
||||
message: t`Failed to upload image: `.concat(file.name),
|
||||
});
|
||||
};
|
||||
|
||||
const editor = useEmailEditor(
|
||||
{
|
||||
placeholder: placeholder ?? 'Enter email',
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate: (editor) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
onChange(JSON.stringify(jsonContent));
|
||||
},
|
||||
onFocus: () => {
|
||||
pushFocusItemToFocusStack({
|
||||
focusId: instanceId,
|
||||
component: {
|
||||
type: FocusComponentType.FORM_FIELD_INPUT,
|
||||
instanceId: instanceId,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
onBlur: () => {
|
||||
removeFocusItemFromFocusStackById({ focusId: instanceId });
|
||||
},
|
||||
onImageUpload: handleUploadAttachment,
|
||||
onImageUploadError: handleImageUploadError,
|
||||
},
|
||||
[isFullScreen],
|
||||
);
|
||||
|
||||
const handleEnterFullScreen = () => {
|
||||
setIsFullScreen(true);
|
||||
};
|
||||
|
||||
const handleExitFullScreen = () => {
|
||||
setIsFullScreen(false);
|
||||
};
|
||||
|
||||
const handleVariableTagInsert = (variableName: string) => {
|
||||
if (!isDefined(editor)) {
|
||||
throw new Error(
|
||||
'Expected the editor to be defined when a variable is selected',
|
||||
);
|
||||
}
|
||||
|
||||
editor.commands.insertVariableTag(variableName);
|
||||
};
|
||||
|
||||
const breadcrumbLinks: BreadcrumbProps['links'] = [
|
||||
{
|
||||
children: workflow?.name?.trim() || t`Untitled Workflow`,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: headerTitle,
|
||||
href: '#',
|
||||
},
|
||||
{
|
||||
children: t`Email Editor`,
|
||||
},
|
||||
];
|
||||
|
||||
const { renderFullScreenModal } = useFullScreenModal({
|
||||
links: breadcrumbLinks,
|
||||
onClose: handleExitFullScreen,
|
||||
hasClosePageButton: !isMobile,
|
||||
});
|
||||
|
||||
const fullScreenOverlay = renderFullScreenModal(
|
||||
<div data-globally-prevent-click-outside="true">
|
||||
<StyledFullScreenEmailEditorContainer>
|
||||
<WorkflowEmailEditor editor={editor} readonly={readonly} />
|
||||
</StyledFullScreenEmailEditorContainer>
|
||||
</div>,
|
||||
isFullScreen,
|
||||
);
|
||||
|
||||
if (!isDefined(editor)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledWorkflowSendEmailBodyContainer>
|
||||
{label ? <InputLabel>{label}</InputLabel> : null}
|
||||
|
||||
<StyledWorkflowSendEmailFieldContainer>
|
||||
<StyledWorkflowSendEmailBodyInnerContainer>
|
||||
{!isFullScreen && (
|
||||
<WorkflowEmailEditor editor={editor} readonly={readonly} />
|
||||
)}
|
||||
|
||||
<StyledEmailEditorActionButtonContainer>
|
||||
{!readonly && !isFullScreen && (
|
||||
<StyledFullScreenButtonContainer
|
||||
isUnfolded={false}
|
||||
transparentBackground
|
||||
onClick={handleEnterFullScreen}
|
||||
>
|
||||
<IconMaximize size={theme.icon.size.md} />
|
||||
</StyledFullScreenButtonContainer>
|
||||
)}
|
||||
</StyledEmailEditorActionButtonContainer>
|
||||
|
||||
{VariablePicker && !readonly ? (
|
||||
<VariablePicker
|
||||
instanceId={instanceId}
|
||||
multiline={true}
|
||||
onVariableSelect={handleVariableTagInsert}
|
||||
/>
|
||||
) : null}
|
||||
</StyledWorkflowSendEmailBodyInnerContainer>
|
||||
</StyledWorkflowSendEmailFieldContainer>
|
||||
{hint && <InputHint>{hint}</InputHint>}
|
||||
{error && <InputErrorHelper>{error}</InputErrorHelper>}
|
||||
</StyledWorkflowSendEmailBodyContainer>
|
||||
|
||||
{fullScreenOverlay}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import { WorkflowEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowEmailEditor';
|
||||
import { useEmailEditor } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useEmailEditor';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
|
||||
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
|
||||
const EditorWrapper = ({
|
||||
readonly = false,
|
||||
placeholder = 'Enter email content...',
|
||||
defaultValue = null,
|
||||
onUpdate = fn(),
|
||||
}: {
|
||||
readonly?: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string | null;
|
||||
onUpdate?: (content: string) => void;
|
||||
}) => {
|
||||
const editor = useEmailEditor({
|
||||
placeholder,
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate: (editor) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
onUpdate(JSON.stringify(jsonContent));
|
||||
},
|
||||
onImageUpload: async (file: File) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
return `https://via.placeholder.com/400x200?text=${encodeURIComponent(file.name)}`;
|
||||
},
|
||||
onImageUploadError: (_error: Error, _file: File) => {
|
||||
// Handle image upload error
|
||||
},
|
||||
});
|
||||
|
||||
if (!editor) {
|
||||
return <div>Loading editor...</div>;
|
||||
}
|
||||
|
||||
return <WorkflowEmailEditor editor={editor} readonly={readonly} />;
|
||||
};
|
||||
|
||||
const meta: Meta<typeof EditorWrapper> = {
|
||||
title: 'Modules/Workflow/Actions/SendEmail/EmailEditor',
|
||||
component: EditorWrapper,
|
||||
parameters: {
|
||||
msw: graphqlMocks,
|
||||
},
|
||||
decorators: [
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
RouterDecorator,
|
||||
WorkspaceDecorator,
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof EditorWrapper>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {},
|
||||
};
|
||||
|
||||
export const WithContent: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Hello ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'John',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ',',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This is a sample email with ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'italic' }],
|
||||
text: 'italic',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' and ',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'underline' }],
|
||||
text: 'underlined',
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: ' text.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeadings: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Welcome Email' }],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 2 },
|
||||
content: [{ type: 'text', text: 'Getting Started' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Thank you for joining us! Here are some next steps:',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 3 },
|
||||
content: [{ type: 'text', text: 'Next Steps' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: '1. Complete your profile' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLinks: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Visit our ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'link', attrs: { href: 'https://twenty.com' } }],
|
||||
text: 'website',
|
||||
},
|
||||
{ type: 'text', text: ' for more information.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Contact us at ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'mailto:support@twenty.com' } },
|
||||
],
|
||||
text: 'support@twenty.com',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithVariableTags: Story = {
|
||||
args: {
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Dear ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{firstName}}' },
|
||||
},
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Your order ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{orderNumber}}' },
|
||||
},
|
||||
{ type: 'text', text: ' has been shipped!' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
readonly: true,
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'Read-only mode',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'This editor is in read-only mode and cannot be edited.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
args: {
|
||||
placeholder: 'Start typing your email content...',
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
onUpdate: fn(),
|
||||
placeholder: 'Try typing, formatting text, or uploading images...',
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,320 @@
|
|||
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
|
||||
import { WorkflowSendEmailBody } from '@/workflow/workflow-steps/workflow-actions/email-action/components/WorkflowSendEmailBody';
|
||||
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
|
||||
import { type Meta, type StoryObj } from '@storybook/react';
|
||||
import { expect, fn, userEvent, within } from '@storybook/test';
|
||||
import { graphql, HttpResponse } from 'msw';
|
||||
import { ComponentDecorator, RouterDecorator } from 'twenty-ui/testing';
|
||||
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
|
||||
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
|
||||
import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator';
|
||||
import { WorkflowStepActionDrawerDecorator } from '~/testing/decorators/WorkflowStepActionDrawerDecorator';
|
||||
import { WorkflowStepDecorator } from '~/testing/decorators/WorkflowStepDecorator';
|
||||
import { WorkspaceDecorator } from '~/testing/decorators/WorkspaceDecorator';
|
||||
import { graphqlMocks } from '~/testing/graphqlMocks';
|
||||
import { getWorkflowNodeIdMock } from '~/testing/mock-data/workflow';
|
||||
|
||||
const DEFAULT_ACTION: WorkflowSendEmailAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Send Email',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: false,
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
body: '',
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: false },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const RICH_CONTENT_ACTION: WorkflowSendEmailAction = {
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Welcome Email',
|
||||
type: 'SEND_EMAIL',
|
||||
valid: true,
|
||||
settings: {
|
||||
input: {
|
||||
connectedAccountId: 'test-account-id',
|
||||
email: 'user@example.com',
|
||||
subject: 'Welcome to Twenty!',
|
||||
body: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'heading',
|
||||
attrs: { level: 1 },
|
||||
content: [{ type: 'text', text: 'Welcome to Twenty!' }],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Dear ' },
|
||||
{
|
||||
type: 'variableTag',
|
||||
attrs: { variable: '{{firstName}}' },
|
||||
},
|
||||
{ type: 'text', text: ',' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Thank you for joining us! We are ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [{ type: 'bold' }],
|
||||
text: 'excited',
|
||||
},
|
||||
{ type: 'text', text: ' to have you on board.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Visit our ' },
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{ type: 'link', attrs: { href: 'https://twenty.com' } },
|
||||
],
|
||||
text: 'website',
|
||||
},
|
||||
{ type: 'text', text: ' to get started.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
outputSchema: {},
|
||||
errorHandlingOptions: {
|
||||
retryOnFailure: { value: true },
|
||||
continueOnFailure: { value: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const meta: Meta<typeof WorkflowSendEmailBody> = {
|
||||
title: 'Modules/Workflow/Actions/SendEmail/EmailBody',
|
||||
component: WorkflowSendEmailBody,
|
||||
parameters: {
|
||||
msw: {
|
||||
handlers: [
|
||||
...graphqlMocks.handlers,
|
||||
graphql.query('FindManyWorkflows', () => {
|
||||
return HttpResponse.json({
|
||||
data: {
|
||||
workflows: [
|
||||
{
|
||||
id: getWorkflowNodeIdMock(),
|
||||
name: 'Test Workflow',
|
||||
__typename: 'Workflow',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}),
|
||||
],
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
WorkflowStepActionDrawerDecorator,
|
||||
WorkflowStepDecorator,
|
||||
ComponentDecorator,
|
||||
ObjectMetadataItemsDecorator,
|
||||
SnackBarDecorator,
|
||||
RouterDecorator,
|
||||
WorkspaceDecorator,
|
||||
I18nFrontDecorator,
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByRole('textbox')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRichContent: Story = {
|
||||
args: {
|
||||
action: RICH_CONTENT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
|
||||
expect(await canvas.findByText('excited')).toBeVisible();
|
||||
expect(await canvas.findByText('website')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ReadOnly: Story = {
|
||||
args: {
|
||||
action: RICH_CONTENT_ACTION,
|
||||
label: 'Email Body (Read Only)',
|
||||
defaultValue: RICH_CONTENT_ACTION.settings.input.body,
|
||||
onChange: fn(),
|
||||
readonly: true,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body (Read Only)')).toBeVisible();
|
||||
expect(await canvas.findByText('Welcome to Twenty!')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
error: 'Email body is required',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByText('Email body is required')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHint: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
hint: 'You can use variables, format text, and add images to your email content.',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(
|
||||
await canvas.findByText(
|
||||
'You can use variables, format text, and add images to your email content.',
|
||||
),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Start typing to see the editor in action...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
|
||||
const editor = await canvas.findByRole('textbox');
|
||||
expect(editor).toBeVisible();
|
||||
|
||||
await userEvent.click(editor);
|
||||
await userEvent.type(editor, 'Hello World!');
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutVariablePicker: Story = {
|
||||
args: {
|
||||
action: DEFAULT_ACTION,
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: '',
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
expect(await canvas.findByText('Email Body')).toBeVisible();
|
||||
expect(await canvas.findByRole('textbox')).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLongContent: Story = {
|
||||
args: {
|
||||
action: {
|
||||
...DEFAULT_ACTION,
|
||||
settings: {
|
||||
...DEFAULT_ACTION.settings,
|
||||
input: {
|
||||
...DEFAULT_ACTION.settings.input,
|
||||
body: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
label: 'Email Body',
|
||||
placeholder: 'Enter your email content...',
|
||||
defaultValue: JSON.stringify({
|
||||
type: 'doc',
|
||||
content: Array.from({ length: 20 }, (_, i) => ({
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: `This is paragraph ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.`,
|
||||
},
|
||||
],
|
||||
})),
|
||||
}),
|
||||
onChange: fn(),
|
||||
readonly: false,
|
||||
VariablePicker: WorkflowVariablePicker,
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import {
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconTrash,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
type ImageBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const ImageBubbleMenu = ({ editor }: ImageBubbleMenuProps) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
align: ctx.editor.getAttributes('image').align || 'left',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
editor.chain().focus().deleteSelection().run();
|
||||
};
|
||||
|
||||
const alignmentActions = [
|
||||
{
|
||||
align: 'left',
|
||||
Icon: IconAlignLeft,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'left' })
|
||||
.run(),
|
||||
isActive: state.align === 'left',
|
||||
},
|
||||
{
|
||||
align: 'center',
|
||||
Icon: IconAlignCenter,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'center' })
|
||||
.run(),
|
||||
isActive: state.align === 'center',
|
||||
},
|
||||
{
|
||||
align: 'right',
|
||||
Icon: IconAlignRight,
|
||||
onClick: () =>
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.updateAttributes('image', { align: 'right' })
|
||||
.run(),
|
||||
isActive: state.align === 'right',
|
||||
},
|
||||
];
|
||||
|
||||
const handleShouldShow = () => {
|
||||
return editor.isActive('image');
|
||||
};
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="image-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
{alignmentActions.map(({ align, Icon, onClick, isActive }) => (
|
||||
<BubbleMenuIconButton
|
||||
key={`image-align-${align}`}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
/>
|
||||
))}
|
||||
<BubbleMenuIconButton
|
||||
key="image-delete"
|
||||
Icon={IconTrash}
|
||||
onClick={handleDelete}
|
||||
isActive={false}
|
||||
/>
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
|
||||
import { StyledBubbleMenuContainer } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TextBubbleMenu';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import { IconExternalLink, IconLinkOff } from 'twenty-ui/display';
|
||||
|
||||
type LinkBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const LinkBubbleMenu = ({ editor }: LinkBubbleMenuProps) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
linkHref: ctx.editor.getAttributes('link').href || '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const handleShouldShow = () => {
|
||||
return editor.isActive('link');
|
||||
};
|
||||
|
||||
const menuActions = [
|
||||
{
|
||||
Icon: IconExternalLink,
|
||||
onClick: () => {
|
||||
window.open(state.linkHref, '_blank');
|
||||
},
|
||||
},
|
||||
{
|
||||
Icon: IconLinkOff,
|
||||
onClick: () =>
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run(),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="link-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
|
||||
{menuActions.map(({ Icon, onClick }) => {
|
||||
return (
|
||||
<BubbleMenuIconButton
|
||||
key={Icon.name || Icon.displayName || 'unknown'}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import styled from '@emotion/styled';
|
||||
import React from 'react';
|
||||
import type { IconComponent } from 'twenty-ui/display';
|
||||
import { FloatingIconButton } from 'twenty-ui/input';
|
||||
|
||||
type BubbleMenuIconButtonProps = {
|
||||
className?: string;
|
||||
Icon?: IconComponent;
|
||||
disabled?: boolean;
|
||||
focus?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
isActive?: boolean;
|
||||
};
|
||||
|
||||
const StyledBubbleMenuIconButton = styled(FloatingIconButton)`
|
||||
border: none;
|
||||
border-radius: ${({ theme }) => theme.spacing(1.5)};
|
||||
width: ${({ theme }) => theme.spacing(6)};
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
`;
|
||||
|
||||
export const BubbleMenuIconButton = ({
|
||||
className,
|
||||
Icon,
|
||||
disabled = false,
|
||||
focus = false,
|
||||
onClick,
|
||||
isActive,
|
||||
}: BubbleMenuIconButtonProps) => {
|
||||
return (
|
||||
<StyledBubbleMenuIconButton
|
||||
className={className}
|
||||
Icon={Icon}
|
||||
disabled={disabled}
|
||||
focus={focus}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
applyShadow={false}
|
||||
applyBlur={false}
|
||||
size="medium"
|
||||
position="standalone"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { TextInput } from '@/ui/input/components/TextInput';
|
||||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
|
||||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { useId, useState, type FocusEvent, type FormEvent } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { IconLink, IconPencil } from 'twenty-ui/display';
|
||||
|
||||
type EditLinkPopoverProps = {
|
||||
defaultValue: string | undefined;
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const EditLinkPopover = ({
|
||||
defaultValue = '',
|
||||
editor,
|
||||
}: EditLinkPopoverProps) => {
|
||||
const instanceId = useId();
|
||||
const dropdownId = `edit-link-popover-${instanceId}`;
|
||||
const isActive = isNonEmptyString(defaultValue);
|
||||
const { t } = useLingui();
|
||||
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const { toggleDropdown } = useToggleDropdown();
|
||||
|
||||
const handleSubmit = (
|
||||
event: FormEvent<HTMLFormElement> | FocusEvent<HTMLInputElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!isDefined(value)) {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange('link')
|
||||
.setLink({ href: value })
|
||||
.run();
|
||||
}
|
||||
|
||||
toggleDropdown({ dropdownComponentInstanceIdFromProps: dropdownId });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
onOpen={() => {
|
||||
setValue(defaultValue);
|
||||
}}
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<TextInput
|
||||
placeholder={t`Enter link`}
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onBlur={handleSubmit}
|
||||
/>
|
||||
</form>
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownId={dropdownId}
|
||||
isDropdownInModal={true}
|
||||
clickableComponent={
|
||||
<BubbleMenuIconButton
|
||||
isActive={isActive}
|
||||
Icon={isActive ? IconPencil : IconLink}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { BubbleMenuIconButton } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/BubbleMenuIconButton';
|
||||
import { EditLinkPopover } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/EditLinkPopover';
|
||||
import { TurnIntoBlockDropdown } from '@/workflow/workflow-steps/workflow-actions/email-action/components/text-bubble-menu/TurnIntoBlockDropdown';
|
||||
import { useTextBubbleState } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTextBubbleState';
|
||||
import { isTextSelected } from '@/workflow/workflow-steps/workflow-actions/email-action/utils/isTextSelected';
|
||||
import styled from '@emotion/styled';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import {
|
||||
IconBold,
|
||||
IconItalic,
|
||||
IconStrikethrough,
|
||||
IconUnderline,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export const StyledBubbleMenuContainer = styled.div`
|
||||
backdrop-filter: blur(20px);
|
||||
background-color: ${({ theme }) => theme.background.primary};
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
box-shadow: ${({ theme }) =>
|
||||
`0px 2px 4px 0px ${theme.background.transparent.light}, 0px 0px 4px 0px ${theme.background.transparent.medium}`};
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
padding: 2px;
|
||||
`;
|
||||
|
||||
type TextBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const TextBubbleMenu = ({ editor }: TextBubbleMenuProps) => {
|
||||
const state = useTextBubbleState(editor);
|
||||
const menuActions = [
|
||||
{
|
||||
Icon: IconBold,
|
||||
onClick: () => editor.chain().focus().toggleBold().run(),
|
||||
isActive: state.isBold,
|
||||
},
|
||||
{
|
||||
Icon: IconItalic,
|
||||
onClick: () => editor.chain().focus().toggleItalic().run(),
|
||||
isActive: state.isItalic,
|
||||
},
|
||||
{
|
||||
Icon: IconUnderline,
|
||||
onClick: () => editor.chain().focus().toggleUnderline().run(),
|
||||
isActive: state.isUnderline,
|
||||
},
|
||||
{
|
||||
Icon: IconStrikethrough,
|
||||
onClick: () => editor.chain().focus().toggleStrike().run(),
|
||||
isActive: state.isStrike,
|
||||
},
|
||||
];
|
||||
|
||||
const handleShouldShow = () => {
|
||||
if (editor.isActive('image')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isTextSelected({ editor });
|
||||
};
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
pluginKey="text-bubble-menu"
|
||||
editor={editor}
|
||||
shouldShow={handleShouldShow}
|
||||
updateDelay={0}
|
||||
>
|
||||
<StyledBubbleMenuContainer>
|
||||
<TurnIntoBlockDropdown editor={editor} />
|
||||
{menuActions.map(({ Icon, onClick, isActive }) => {
|
||||
return (
|
||||
<BubbleMenuIconButton
|
||||
key={Icon.name || Icon.displayName || 'unknown'}
|
||||
Icon={Icon}
|
||||
onClick={onClick}
|
||||
isActive={isActive}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<EditLinkPopover defaultValue={state.linkHref} editor={editor} />
|
||||
</StyledBubbleMenuContainer>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
|
||||
import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown';
|
||||
import { useTurnIntoBlockOptions } from '@/workflow/workflow-steps/workflow-actions/email-action/hooks/useTurnIntoBlockOptions';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type Editor } from '@tiptap/react';
|
||||
import { useId } from 'react';
|
||||
import { IconPilcrow } from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
const StyledMenuItem = styled.button`
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${({ theme }) => theme.font.color.tertiary};
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
gap: 4px;
|
||||
height: ${({ theme }) => theme.spacing(6)};
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
padding: 0 ${({ theme }) => theme.spacing(1.5)};
|
||||
border-radius: ${({ theme }) => theme.spacing(1.5)};
|
||||
|
||||
:hover {
|
||||
background: ${({ theme }) => theme.background.transparent.medium};
|
||||
}
|
||||
|
||||
:focus {
|
||||
outline: none;
|
||||
}
|
||||
`;
|
||||
|
||||
type TurnIntoBlockDropdownProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const TurnIntoBlockDropdown = ({
|
||||
editor,
|
||||
}: TurnIntoBlockDropdownProps) => {
|
||||
const theme = useTheme();
|
||||
|
||||
const instanceId = useId();
|
||||
const dropdownId = `turn-into-block-dropdown-${instanceId}`;
|
||||
const { toggleDropdown } = useToggleDropdown();
|
||||
|
||||
const options = useTurnIntoBlockOptions(editor);
|
||||
const activeItem = options.find((option) => option.isActive());
|
||||
const { icon: ActiveIcon = IconPilcrow, title: activeTitle = 'Paragraph' } =
|
||||
activeItem ?? {};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
dropdownComponents={
|
||||
<DropdownContent>
|
||||
<DropdownMenuItemsContainer>
|
||||
{options.map(({ id, title, icon, onClick }) => (
|
||||
<MenuItem
|
||||
key={id}
|
||||
text={title}
|
||||
LeftIcon={icon}
|
||||
onClick={() => {
|
||||
onClick();
|
||||
toggleDropdown({
|
||||
dropdownComponentInstanceIdFromProps: dropdownId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
}
|
||||
dropdownId={dropdownId}
|
||||
clickableComponent={
|
||||
<StyledMenuItem>
|
||||
<ActiveIcon size={theme.icon.size.md} />
|
||||
{activeTitle}
|
||||
</StyledMenuItem>
|
||||
}
|
||||
dropdownOffset={{
|
||||
y: parseInt(theme.spacing(1), 10),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { ResizableImageView } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImageView';
|
||||
import {
|
||||
type ImageOptions,
|
||||
Image as TiptapImage,
|
||||
} from '@tiptap/extension-image';
|
||||
import { ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
export const ResizableImage = TiptapImage.extend<ImageOptions>({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
align: {
|
||||
default: 'left',
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return ReactNodeViewRenderer(ResizableImageView);
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
import { css } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { type NodeViewProps, NodeViewWrapper } from '@tiptap/react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
const IMAGE_MIN_WIDTH = 32;
|
||||
const IMAGE_MAX_WIDTH = 600;
|
||||
|
||||
const StyledNodeViewWrapper = styled(NodeViewWrapper)`
|
||||
height: 100%;
|
||||
${({ align }) => {
|
||||
switch (align) {
|
||||
case 'left':
|
||||
return css`
|
||||
margin-left: 0;
|
||||
`;
|
||||
case 'right':
|
||||
return css`
|
||||
margin-right: 0;
|
||||
`;
|
||||
case 'center':
|
||||
return css`
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
`;
|
||||
}
|
||||
}}
|
||||
`;
|
||||
|
||||
const StyledImageWrapper = styled.div<{ width?: number }>`
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledImageContainer = styled.div`
|
||||
max-width: 100%;
|
||||
position: relative;
|
||||
`;
|
||||
|
||||
const StyledImage = styled.img`
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
`;
|
||||
|
||||
const StyledImageHandle = styled.div<{ handle: 'left' | 'right' }>`
|
||||
border-radius: ${({ theme }) => theme.border.radius.md};
|
||||
background-color: ${({ theme }) => theme.background.primaryInverted};
|
||||
border: 1px solid ${({ theme }) => theme.background.primary};
|
||||
cursor: col-resize;
|
||||
height: ${({ theme }) => theme.spacing(8)};
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: ${({ theme }) => theme.spacing(2)};
|
||||
z-index: 1;
|
||||
|
||||
${({ handle, theme }) => {
|
||||
if (handle === 'left') {
|
||||
return css`
|
||||
left: ${theme.spacing(1)};
|
||||
`;
|
||||
}
|
||||
|
||||
return css`
|
||||
right: ${theme.spacing(1)};
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
type ResizeParams = {
|
||||
initialWidth: number;
|
||||
initialClientX: number;
|
||||
handleUsed: 'left' | 'right';
|
||||
};
|
||||
|
||||
type ResizableImageViewProps = NodeViewProps;
|
||||
|
||||
export const ResizableImageView = (props: ResizableImageViewProps) => {
|
||||
const { editor, node, updateAttributes } = props;
|
||||
const { width: initialWidth, align = 'left', src, alt } = node.attrs;
|
||||
|
||||
const imageWrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Controls visibility of resize handles when hovering over the image
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [width, setWidth] = useState(initialWidth || 0);
|
||||
// Controls actual resize operation state (null = not resizing, object = actively resizing)
|
||||
const [resizeParams, setResizeParams] = useState<ResizeParams | null>(null);
|
||||
|
||||
// Create stable event handlers using a closure approach
|
||||
const createMouseHandlers = useCallback(() => {
|
||||
let currentResizeParams: ResizeParams | null = null;
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
const imageWrapper = imageWrapperRef.current;
|
||||
|
||||
if (!isDefined(currentResizeParams) || !isDefined(imageWrapper)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = event.clientX - currentResizeParams.initialClientX;
|
||||
const { initialWidth, handleUsed } = currentResizeParams;
|
||||
|
||||
let newWidth =
|
||||
align === 'center'
|
||||
? initialWidth + (handleUsed === 'left' ? -deltaX * 2 : deltaX * 2)
|
||||
: initialWidth + (handleUsed === 'left' ? -deltaX : deltaX);
|
||||
|
||||
const maxWidth =
|
||||
editor.view.dom.firstElementChild?.clientWidth || IMAGE_MAX_WIDTH;
|
||||
newWidth = Math.min(Math.max(newWidth, IMAGE_MIN_WIDTH), maxWidth);
|
||||
|
||||
setWidth(newWidth);
|
||||
imageWrapper.style.width = `${newWidth}px`;
|
||||
};
|
||||
|
||||
const handleMouseUp = (event: MouseEvent) => {
|
||||
const imageWrapper = imageWrapperRef.current;
|
||||
|
||||
if (!isDefined(imageWrapper) || !isDefined(currentResizeParams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(!event.target || !imageWrapper.contains(event.target as Node)) &&
|
||||
isHovering
|
||||
) {
|
||||
setIsHovering(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalWidth = imageWrapper.clientWidth;
|
||||
currentResizeParams = null;
|
||||
setResizeParams(null);
|
||||
updateAttributes({ width: finalWidth });
|
||||
|
||||
window.removeEventListener('mousemove', handleMouseMove);
|
||||
window.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
const startResize = (resizeParams: ResizeParams) => {
|
||||
currentResizeParams = resizeParams;
|
||||
setResizeParams(resizeParams);
|
||||
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
window.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return { startResize };
|
||||
}, [editor, align, isHovering, updateAttributes]);
|
||||
|
||||
const { startResize } = createMouseHandlers();
|
||||
|
||||
const handleImageHandleMouseDown = useCallback(
|
||||
(handle: 'left' | 'right', event: React.MouseEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const resizeParams = {
|
||||
initialWidth: imageWrapperRef.current?.clientWidth ?? IMAGE_MAX_WIDTH,
|
||||
initialClientX: event.clientX,
|
||||
handleUsed: handle,
|
||||
};
|
||||
|
||||
startResize(resizeParams);
|
||||
},
|
||||
[startResize],
|
||||
);
|
||||
|
||||
const handleImageHover = useCallback(() => {
|
||||
if (!editor.isEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsHovering(true);
|
||||
}, [editor.isEditable]);
|
||||
|
||||
const handleImageHoverEnd = useCallback(() => {
|
||||
setIsHovering(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<StyledNodeViewWrapper
|
||||
onMouseEnter={handleImageHover}
|
||||
onMouseLeave={handleImageHoverEnd}
|
||||
align={align}
|
||||
>
|
||||
<StyledImageWrapper
|
||||
ref={imageWrapperRef}
|
||||
style={{ width: width ? `${width}px` : 'fit-content' }}
|
||||
>
|
||||
<StyledImageContainer>
|
||||
<StyledImage
|
||||
src={src}
|
||||
alt={alt}
|
||||
draggable={false}
|
||||
contentEditable={false}
|
||||
/>
|
||||
{/* Show resize handles when hovering over image OR actively resizing */}
|
||||
{(isHovering || isDefined(resizeParams)) && (
|
||||
<>
|
||||
<StyledImageHandle
|
||||
handle="left"
|
||||
onMouseDown={(e) => handleImageHandleMouseDown('left', e)}
|
||||
/>
|
||||
<StyledImageHandle
|
||||
handle="right"
|
||||
onMouseDown={(e) => handleImageHandleMouseDown('right', e)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</StyledImageContainer>
|
||||
</StyledImageWrapper>
|
||||
</StyledNodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import {
|
||||
UploadImagePlugin,
|
||||
type UploadImagePluginProps,
|
||||
} from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImagePlugin';
|
||||
import { Extension } from '@tiptap/core';
|
||||
|
||||
type UploadImageOptions = Omit<UploadImagePluginProps, 'editor'> & {};
|
||||
|
||||
type UploadImageStorage = {
|
||||
placeholderImages: Set<string>;
|
||||
};
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Storage {
|
||||
uploadImage: UploadImageStorage;
|
||||
}
|
||||
}
|
||||
|
||||
export const UploadImageExtension = Extension.create<
|
||||
UploadImageOptions,
|
||||
UploadImageStorage
|
||||
>({
|
||||
name: 'uploadImage',
|
||||
|
||||
addOptions: () => {
|
||||
return {
|
||||
allowedMimeTypes: [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/svg+xml',
|
||||
],
|
||||
onImageUpload: undefined,
|
||||
onImageUploadError: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
addStorage: () => {
|
||||
return {
|
||||
placeholderImages: new Set(),
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { onImageUpload } = this.options;
|
||||
|
||||
if (!onImageUpload) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
UploadImagePlugin({
|
||||
editor: this.editor,
|
||||
allowedMimeTypes: this.options.allowedMimeTypes,
|
||||
onImageUpload: this.options.onImageUpload,
|
||||
onImageUploadError: this.options.onImageUploadError,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { type Editor } from '@tiptap/core';
|
||||
import { type Node } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { type EditorView } from '@tiptap/pm/view';
|
||||
|
||||
export type UploadImagePluginProps = {
|
||||
editor: Editor;
|
||||
allowedMimeTypes?: string[];
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onImageUploadError?: (error: Error, file: File) => void;
|
||||
};
|
||||
|
||||
export const UploadImagePlugin = (options: UploadImagePluginProps) => {
|
||||
const { editor, onImageUpload, allowedMimeTypes, onImageUploadError } =
|
||||
options;
|
||||
|
||||
const handleImageUpload = (view: EditorView, file: File, pos?: number) => {
|
||||
const placeholderSrc = URL.createObjectURL(file);
|
||||
|
||||
const { tr, schema } = view.state;
|
||||
const imageNode = schema.nodes.image.create({
|
||||
src: placeholderSrc,
|
||||
alt: file.name,
|
||||
});
|
||||
|
||||
editor.extensionStorage.uploadImage.placeholderImages.add(placeholderSrc);
|
||||
|
||||
const resolvedPos =
|
||||
pos !== undefined
|
||||
? view.state.doc.resolve(pos)
|
||||
: view.state.selection.$head;
|
||||
|
||||
const transaction = tr.insert(resolvedPos.pos, imageNode);
|
||||
view.dispatch(transaction);
|
||||
|
||||
onImageUpload?.(file)
|
||||
.then((uploadedSrc) => {
|
||||
const updateTr = view.state.tr;
|
||||
|
||||
const predicate = (node: Node) =>
|
||||
node.type.name === 'image' && node.attrs.src === placeholderSrc;
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (predicate(node)) {
|
||||
updateTr.setNodeMarkup(pos, undefined, {
|
||||
...node.attrs,
|
||||
src: uploadedSrc,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
view.dispatch(updateTr);
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
const removeTr = view.state.tr;
|
||||
const predicate = (node: Node) =>
|
||||
node.type.name === 'image' && node.attrs.src === placeholderSrc;
|
||||
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (predicate(node)) {
|
||||
removeTr.delete(pos, pos + node.nodeSize);
|
||||
return false; // Stop traversal after finding the target
|
||||
}
|
||||
});
|
||||
|
||||
view.dispatch(removeTr);
|
||||
|
||||
onImageUploadError?.(error, file);
|
||||
})
|
||||
.finally(() => {
|
||||
editor.extensionStorage.uploadImage.placeholderImages.delete(
|
||||
placeholderSrc,
|
||||
);
|
||||
URL.revokeObjectURL(placeholderSrc);
|
||||
});
|
||||
};
|
||||
|
||||
return new Plugin({
|
||||
key: new PluginKey('uploadImage'),
|
||||
props: {
|
||||
handleDrop: (view, event) => {
|
||||
if (!onImageUpload || !event.dataTransfer?.files?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const images = Array.from(event.dataTransfer.files).filter((file) =>
|
||||
allowedMimeTypes?.includes(file.type),
|
||||
);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const pos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
if (!pos) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
images.forEach((file) => handleImageUpload(view, file, pos.pos));
|
||||
return true;
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
if (!onImageUpload || !event.clipboardData?.files?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const images = Array.from(event.clipboardData.files).filter((file) =>
|
||||
allowedMimeTypes?.includes(file.type),
|
||||
);
|
||||
if (images.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
images.forEach((file) => handleImageUpload(view, file));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { ResizableImage } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/ResizableImage';
|
||||
import { UploadImageExtension } from '@/workflow/workflow-steps/workflow-actions/email-action/extensions/resizable-image/UploadImageExtension';
|
||||
import { getInitialEmailEditorContent } from '@/workflow/workflow-variables/utils/getInitialEmailEditorContent';
|
||||
import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag';
|
||||
import { Bold } from '@tiptap/extension-bold';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { HardBreak } from '@tiptap/extension-hard-break';
|
||||
import { Heading } from '@tiptap/extension-heading';
|
||||
import { Italic } from '@tiptap/extension-italic';
|
||||
import { Link } from '@tiptap/extension-link';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Strike } from '@tiptap/extension-strike';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Underline } from '@tiptap/extension-underline';
|
||||
import { Dropcursor, Placeholder, UndoRedo } from '@tiptap/extensions';
|
||||
import { type Editor, useEditor } from '@tiptap/react';
|
||||
import { type DependencyList, useMemo } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
type UseEmailEditorProps = {
|
||||
placeholder: string | undefined;
|
||||
readonly: boolean | undefined;
|
||||
defaultValue: string | undefined | null;
|
||||
onUpdate: (editor: Editor) => void;
|
||||
onFocus?: (editor: Editor) => void;
|
||||
onBlur?: (editor: Editor) => void;
|
||||
onImageUpload?: (file: File) => Promise<string>;
|
||||
onImageUploadError?: (error: Error, file: File) => void;
|
||||
};
|
||||
|
||||
export const useEmailEditor = (
|
||||
{
|
||||
placeholder,
|
||||
readonly,
|
||||
defaultValue,
|
||||
onUpdate,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onImageUpload,
|
||||
onImageUploadError,
|
||||
}: UseEmailEditorProps,
|
||||
dependencies?: DependencyList,
|
||||
) => {
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder,
|
||||
}),
|
||||
VariableTag,
|
||||
HardBreak.configure({
|
||||
keepMarks: false,
|
||||
}),
|
||||
UndoRedo,
|
||||
Bold,
|
||||
Italic,
|
||||
Strike,
|
||||
Underline,
|
||||
Heading.configure({
|
||||
levels: [1, 2, 3],
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
ResizableImage,
|
||||
Dropcursor,
|
||||
UploadImageExtension.configure({
|
||||
onImageUpload,
|
||||
onImageUploadError,
|
||||
}),
|
||||
],
|
||||
[placeholder, onImageUpload, onImageUploadError],
|
||||
);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: isDefined(defaultValue)
|
||||
? getInitialEmailEditorContent(defaultValue)
|
||||
: undefined,
|
||||
editable: !readonly,
|
||||
onUpdate: ({ editor }) => {
|
||||
onUpdate(editor);
|
||||
},
|
||||
onFocus: ({ editor }) => {
|
||||
onFocus?.(editor);
|
||||
},
|
||||
onBlur: ({ editor }) => {
|
||||
onBlur?.(editor);
|
||||
},
|
||||
editorProps: {
|
||||
scrollThreshold: 60,
|
||||
scrollMargin: 60,
|
||||
},
|
||||
injectCSS: false,
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { type Editor } from '@tiptap/core';
|
||||
import { useEditorState } from '@tiptap/react';
|
||||
|
||||
export const useTextBubbleState = (editor: Editor) => {
|
||||
const state = useEditorState({
|
||||
editor,
|
||||
selector: (ctx) => {
|
||||
return {
|
||||
isBold: ctx.editor.isActive('bold'),
|
||||
isItalic: ctx.editor.isActive('italic'),
|
||||
isStrike: ctx.editor.isActive('strike'),
|
||||
isUnderline: ctx.editor.isActive('underline'),
|
||||
isLink: ctx.editor.isActive('link'),
|
||||
linkHref: ctx.editor.getAttributes('link').href || '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return state;
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { type Editor, useEditorState } from '@tiptap/react';
|
||||
import {
|
||||
type IconComponent,
|
||||
IconH1,
|
||||
IconH2,
|
||||
IconH3,
|
||||
IconPilcrow,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export type TurnIntoBlockOptions = {
|
||||
title: string;
|
||||
id: string;
|
||||
disabled: () => boolean;
|
||||
isActive: () => boolean;
|
||||
onClick: () => void;
|
||||
icon: IconComponent;
|
||||
};
|
||||
|
||||
export const useTurnIntoBlockOptions = (editor: Editor) => {
|
||||
return useEditorState({
|
||||
editor,
|
||||
selector: ({ editor }): TurnIntoBlockOptions[] => [
|
||||
{
|
||||
id: 'paragraph',
|
||||
title: 'Paragraph',
|
||||
icon: IconPilcrow,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setParagraph().run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setParagraph();
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('paragraph');
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading1',
|
||||
title: 'Heading 1',
|
||||
icon: IconH1,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 1 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 1 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 1 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading2',
|
||||
title: 'Heading 2',
|
||||
icon: IconH2,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 2 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 2 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 2 });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'heading3',
|
||||
title: 'Heading 3',
|
||||
icon: IconH3,
|
||||
onClick: () => {
|
||||
return editor.chain().focus().setHeading({ level: 3 }).run();
|
||||
},
|
||||
disabled: () => {
|
||||
return !editor.can().setHeading({ level: 3 });
|
||||
},
|
||||
isActive: () => {
|
||||
return editor.isActive('heading', { level: 3 });
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { type Editor, isTextSelection } from '@tiptap/core';
|
||||
|
||||
type IsTextSelectedProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const isTextSelected = ({ editor }: IsTextSelectedProps) => {
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to },
|
||||
},
|
||||
} = editor;
|
||||
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock =
|
||||
!doc.textBetween(from, to).length && isTextSelection(selection);
|
||||
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
|
@ -109,6 +109,7 @@ export const WorkflowVariablesDropdown = ({
|
|||
return (
|
||||
<Dropdown
|
||||
dropdownId={dropdownId}
|
||||
isDropdownInModal={true}
|
||||
clickableComponent={
|
||||
clickableComponent ?? (
|
||||
<StyledDropdownVariableButtonContainer
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
import { getInitialEditorContent } from '@/workflow/workflow-variables/utils/getInitialEditorContent';
|
||||
import type { JSONContent } from '@tiptap/react';
|
||||
import { logError } from '~/utils/logError';
|
||||
|
||||
// Previous format of the email body was plain text,
|
||||
// but from now on we will save it as JSON.
|
||||
// So it will fail to parse the content, that's why we have this fallback.
|
||||
export const getInitialEmailEditorContent = (
|
||||
rawContent: string,
|
||||
): JSONContent => {
|
||||
// Handle empty or null content
|
||||
if (!rawContent || rawContent.trim() === '') {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(rawContent);
|
||||
return json;
|
||||
} catch (error) {
|
||||
logError(error);
|
||||
return getInitialEditorContent(rawContent);
|
||||
}
|
||||
};
|
||||
|
|
@ -69,8 +69,8 @@
|
|||
"@ptc-org/nestjs-query-core": "4.4.0",
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch",
|
||||
"@ptc-org/nestjs-query-typeorm": "4.2.1-alpha.2",
|
||||
"@react-email/components": "0.0.35",
|
||||
"@react-email/render": "0.0.17",
|
||||
"@react-email/components": "^0.5.3",
|
||||
"@react-email/render": "^1.2.3",
|
||||
"@revertdotdev/revert-react": "^0.0.21",
|
||||
"@sentry/nestjs": "^8.55.0",
|
||||
"@sentry/node": "9.26.0",
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ export class ApprovedAccessDomainService {
|
|||
serverUrl: this.twentyConfigService.get('SERVER_URL'),
|
||||
locale: sender.locale,
|
||||
});
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-membe
|
|||
|
||||
import { ApprovedAccessDomainService } from './approved-access-domain.service';
|
||||
|
||||
// To avoid dynamic import issues in Jest
|
||||
jest.mock('@react-email/render', () => ({
|
||||
render: jest.fn().mockImplementation(async (template, options) => {
|
||||
if (options?.plainText) {
|
||||
return 'Plain Text Email';
|
||||
}
|
||||
|
||||
return '<html><body>HTML email content</body></html>';
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ApprovedAccessDomainService', () => {
|
||||
let service: ApprovedAccessDomainService;
|
||||
let approvedAccessDomainRepository: Repository<ApprovedAccessDomain>;
|
||||
|
|
|
|||
|
|
@ -482,8 +482,8 @@ export class AuthService {
|
|||
locale: firstUserWorkspace.locale,
|
||||
});
|
||||
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
|
||||
const passwordChangedMsg = msg`Your Password Has Been Successfully Changed`;
|
||||
const i18n = this.i18nService.getI18nInstance(firstUserWorkspace.locale);
|
||||
|
|
|
|||
|
|
@ -18,6 +18,17 @@ import { I18nService } from 'src/engine/core-modules/i18n/i18n.service';
|
|||
|
||||
import { ResetPasswordService } from './reset-password.service';
|
||||
|
||||
// To avoid dynamic import issues in Jest
|
||||
jest.mock('@react-email/render', () => ({
|
||||
render: jest.fn().mockImplementation(async (template, options) => {
|
||||
if (options?.plainText) {
|
||||
return 'Plain Text Email';
|
||||
}
|
||||
|
||||
return '<html><body>HTML email content</body></html>';
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ResetPasswordService', () => {
|
||||
let service: ResetPasswordService;
|
||||
let userRepository: Repository<User>;
|
||||
|
|
|
|||
|
|
@ -163,8 +163,8 @@ export class ResetPasswordService {
|
|||
|
||||
const emailTemplate = PasswordResetLinkEmail(emailData);
|
||||
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
|
||||
const resetPasswordMsg = msg`Action Needed to Reset Password`;
|
||||
const i18n = this.i18nService.getI18nInstance(locale);
|
||||
|
|
|
|||
|
|
@ -80,8 +80,8 @@ export class EmailVerificationService {
|
|||
|
||||
const emailTemplate = SendEmailVerificationLinkEmail(emailData);
|
||||
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { render, toPlainText } from '@react-email/render';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { reactMarkupFromJSON } from 'twenty-emails';
|
||||
import { isDefined, isValidUuid } from 'twenty-shared/utils';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
|
@ -16,6 +18,7 @@ import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/s
|
|||
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
|
||||
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
import { parseEmailBody } from 'src/utils/parse-email-body';
|
||||
|
||||
@Injectable()
|
||||
export class SendEmailTool implements Tool {
|
||||
|
|
@ -115,17 +118,23 @@ export class SendEmailTool implements Tool {
|
|||
workspaceId,
|
||||
);
|
||||
|
||||
const parsedBody = parseEmailBody(body);
|
||||
const reactMarkup = reactMarkupFromJSON(parsedBody);
|
||||
const htmlBody = await render(reactMarkup);
|
||||
const textBody = toPlainText(htmlBody);
|
||||
|
||||
const { JSDOM } = await import('jsdom');
|
||||
const window = new JSDOM('').window;
|
||||
const purify = DOMPurify(window);
|
||||
const safeBody = purify.sanitize(body || '');
|
||||
const safeHtmlBody = purify.sanitize(htmlBody || '');
|
||||
const safeSubject = purify.sanitize(subject || '');
|
||||
|
||||
await this.sendMessageService.sendMessage(
|
||||
{
|
||||
to: email,
|
||||
subject: safeSubject,
|
||||
body: safeBody,
|
||||
body: textBody,
|
||||
html: safeHtmlBody,
|
||||
},
|
||||
connectedAccount,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,6 +23,17 @@ import { WorkspaceInvitationService } from './workspace-invitation.service';
|
|||
// To fix a circular dependency issue
|
||||
jest.mock('src/engine/core-modules/workspace/services/workspace.service');
|
||||
|
||||
// To avoid dynamic import issues in Jest
|
||||
jest.mock('@react-email/render', () => ({
|
||||
render: jest.fn().mockImplementation(async (template, options) => {
|
||||
if (options?.plainText) {
|
||||
return 'Plain Text Email';
|
||||
}
|
||||
|
||||
return '<html><body>HTML email content</body></html>';
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('WorkspaceInvitationService', () => {
|
||||
let service: WorkspaceInvitationService;
|
||||
let appTokenRepository: Repository<AppToken>;
|
||||
|
|
|
|||
|
|
@ -305,8 +305,8 @@ export class WorkspaceInvitationService {
|
|||
};
|
||||
|
||||
const emailTemplate = SendInviteLinkEmail(emailData);
|
||||
const html = render(emailTemplate);
|
||||
const text = render(emailTemplate, {
|
||||
const html = await render(emailTemplate);
|
||||
const text = await render(emailTemplate, {
|
||||
plainText: true,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -123,8 +123,8 @@ export class CleanerWorkspaceService {
|
|||
locale: workspaceMember.locale,
|
||||
};
|
||||
const emailTemplate = WarnSuspendedWorkspaceEmail(emailData);
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
|
||||
const workspaceDeletionMsg = msg`Action needed to prevent workspace deletion`;
|
||||
const i18n = this.i18nService.getI18nInstance(workspaceMember.locale);
|
||||
|
|
@ -201,8 +201,8 @@ export class CleanerWorkspaceService {
|
|||
locale: workspaceMember.locale,
|
||||
};
|
||||
const emailTemplate = CleanSuspendedWorkspaceEmail(emailData);
|
||||
const html = render(emailTemplate, { pretty: true });
|
||||
const text = render(emailTemplate, { plainText: true });
|
||||
const html = await render(emailTemplate, { pretty: true });
|
||||
const text = await render(emailTemplate, { plainText: true });
|
||||
|
||||
this.emailService.send({
|
||||
to: workspaceMember.userEmail,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export const ATTACHMENT_STANDARD_FIELD_IDS = {
|
|||
company: '20202020-ceab-4a28-b546-73b06b4c08d5',
|
||||
opportunity: '20202020-7374-499d-bea3-9354890755b5',
|
||||
dashboard: '20202020-5324-43f3-9dbf-1a33e7de0ce6',
|
||||
workflow: '20202020-f1e8-4c9d-8a7b-3f5e1d2c9a8b',
|
||||
custom: '20202020-302d-43b3-9aea-aa4f89282a9f',
|
||||
} as const;
|
||||
|
||||
|
|
@ -464,6 +465,7 @@ export const WORKFLOW_STANDARD_FIELD_IDS = {
|
|||
automatedTriggers: '20202020-3319-4234-a34c-117ecad2b8a9',
|
||||
favorites: '20202020-c554-4c41-be7a-cf9cd4b0d512',
|
||||
timelineActivities: '20202020-906e-486a-a798-131a5f081faf',
|
||||
attachments: '20202020-4a8c-4e2d-9b1c-7e5f3a2b4c6d',
|
||||
createdBy: '20202020-6007-401a-8aa5-e6f48581a6f3',
|
||||
searchVector: '20202020-535d-4ffa-b7f3-4fa0d5da1b7a',
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { NoteWorkspaceEntity } from 'src/modules/note/standard-objects/note.work
|
|||
import { OpportunityWorkspaceEntity } from 'src/modules/opportunity/standard-objects/opportunity.workspace-entity';
|
||||
import { PersonWorkspaceEntity } from 'src/modules/person/standard-objects/person.workspace-entity';
|
||||
import { TaskWorkspaceEntity } from 'src/modules/task/standard-objects/task.workspace-entity';
|
||||
import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@WorkspaceEntity({
|
||||
|
|
@ -183,6 +184,22 @@ export class AttachmentWorkspaceEntity extends BaseWorkspaceEntity {
|
|||
})
|
||||
dashboardId: string | null;
|
||||
|
||||
@WorkspaceRelation({
|
||||
standardId: ATTACHMENT_STANDARD_FIELD_IDS.workflow,
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
label: msg`Workflow`,
|
||||
description: msg`Attachment workflow`,
|
||||
icon: 'IconSettingsAutomation',
|
||||
inverseSideTarget: () => WorkflowWorkspaceEntity,
|
||||
inverseSideFieldKey: 'attachments',
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@WorkspaceIsNullable()
|
||||
workflow: Relation<WorkflowWorkspaceEntity> | null;
|
||||
|
||||
@WorkspaceJoinColumn('workflow')
|
||||
workflowId: string | null;
|
||||
|
||||
@WorkspaceDynamicRelation({
|
||||
type: RelationType.MANY_TO_ONE,
|
||||
argsFactory: (oppositeObjectMetadata) => ({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
|
||||
import { ConnectedAccountProvider } from 'twenty-shared/types';
|
||||
|
||||
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
|
||||
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
|
||||
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { MessagingSendMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-send-message.service';
|
||||
|
||||
describe('MessagingSendMessageService - Gmail HTML Support', () => {
|
||||
let service: MessagingSendMessageService;
|
||||
let gmailClientProvider: jest.Mocked<GmailClientProvider>;
|
||||
let oAuth2ClientProvider: jest.Mocked<OAuth2ClientProvider>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockGmailClient = {
|
||||
users: {
|
||||
messages: {
|
||||
send: jest.fn().mockResolvedValue({ data: { id: 'message-id' } }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockOAuth2Client = {
|
||||
userinfo: {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
data: { email: 'test@example.com', name: 'Test User' },
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagingSendMessageService,
|
||||
{
|
||||
provide: GmailClientProvider,
|
||||
useValue: {
|
||||
getGmailClient: jest.fn().mockResolvedValue(mockGmailClient),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: OAuth2ClientProvider,
|
||||
useValue: {
|
||||
getOAuth2Client: jest.fn().mockResolvedValue(mockOAuth2Client),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: MicrosoftClientProvider,
|
||||
useValue: {},
|
||||
},
|
||||
{
|
||||
provide: SmtpClientProvider,
|
||||
useValue: {},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<MessagingSendMessageService>(
|
||||
MessagingSendMessageService,
|
||||
);
|
||||
gmailClientProvider = module.get(GmailClientProvider);
|
||||
oAuth2ClientProvider = module.get(OAuth2ClientProvider);
|
||||
});
|
||||
|
||||
it('should send multipart/alternative email with both text and HTML parts via Gmail', async () => {
|
||||
const sendMessageInput = {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test HTML Email',
|
||||
body: 'This is plain text content',
|
||||
html: '<p>This is <strong>HTML</strong> content</p>',
|
||||
};
|
||||
|
||||
const connectedAccount = {
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
} as any;
|
||||
|
||||
await service.sendMessage(sendMessageInput, connectedAccount);
|
||||
|
||||
const gmailClient =
|
||||
await gmailClientProvider.getGmailClient(connectedAccount);
|
||||
const sendCall = gmailClient.users.messages.send as jest.Mock;
|
||||
|
||||
expect(sendCall).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sentMessage = sendCall.mock.calls[0][0];
|
||||
const rawMessage = Buffer.from(
|
||||
sentMessage.requestBody.raw,
|
||||
'base64',
|
||||
).toString();
|
||||
|
||||
expect(rawMessage).toContain('MIME-Version: 1.0');
|
||||
expect(rawMessage).toContain(
|
||||
'Content-Type: multipart/alternative; boundary=',
|
||||
);
|
||||
expect(rawMessage).toContain('Content-Type: text/plain; charset="UTF-8"');
|
||||
expect(rawMessage).toContain('Content-Type: text/html; charset="UTF-8"');
|
||||
expect(rawMessage).toContain('This is plain text content');
|
||||
expect(rawMessage).toContain(
|
||||
'<p>This is <strong>HTML</strong> content</p>',
|
||||
);
|
||||
expect(rawMessage).toContain('To: recipient@example.com');
|
||||
expect(rawMessage).toContain('Subject:');
|
||||
});
|
||||
|
||||
it('should handle missing fromName gracefully', async () => {
|
||||
const mockOAuth2ClientNoName = {
|
||||
userinfo: {
|
||||
get: jest.fn().mockResolvedValue({
|
||||
data: { email: 'test@example.com' }, // No name field
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
(oAuth2ClientProvider.getOAuth2Client as jest.Mock).mockResolvedValueOnce(
|
||||
mockOAuth2ClientNoName,
|
||||
);
|
||||
|
||||
const sendMessageInput = {
|
||||
to: 'recipient@example.com',
|
||||
subject: 'Test Email',
|
||||
body: 'Plain text',
|
||||
html: '<p>HTML content</p>',
|
||||
};
|
||||
|
||||
const connectedAccount = {
|
||||
provider: ConnectedAccountProvider.GOOGLE,
|
||||
accessToken: 'access-token',
|
||||
refreshToken: 'refresh-token',
|
||||
} as any;
|
||||
|
||||
await service.sendMessage(sendMessageInput, connectedAccount);
|
||||
|
||||
const gmailClient =
|
||||
await gmailClientProvider.getGmailClient(connectedAccount);
|
||||
const sendCall = gmailClient.users.messages.send as jest.Mock;
|
||||
const rawMessage = Buffer.from(
|
||||
sendCall.mock.calls[0][0].requestBody.raw,
|
||||
'base64',
|
||||
).toString();
|
||||
|
||||
expect(rawMessage).toContain('From: test@example.com');
|
||||
expect(rawMessage).not.toContain('""');
|
||||
});
|
||||
});
|
||||
|
|
@ -12,14 +12,15 @@ import {
|
|||
import { GmailClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/gmail-client.provider';
|
||||
import { OAuth2ClientProvider } from 'src/modules/messaging/message-import-manager/drivers/gmail/providers/oauth2-client.provider';
|
||||
import { MicrosoftClientProvider } from 'src/modules/messaging/message-import-manager/drivers/microsoft/providers/microsoft-client.provider';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { isAccessTokenRefreshingError } from 'src/modules/messaging/message-import-manager/drivers/microsoft/utils/is-access-token-refreshing-error.utils';
|
||||
import { SmtpClientProvider } from 'src/modules/messaging/message-import-manager/drivers/smtp/providers/smtp-client.provider';
|
||||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||
|
||||
interface SendMessageInput {
|
||||
body: string;
|
||||
subject: string;
|
||||
to: string;
|
||||
html: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -46,26 +47,37 @@ export class MessagingSendMessageService {
|
|||
const { data } = await oAuth2Client.userinfo.get();
|
||||
|
||||
const fromEmail = data.email;
|
||||
|
||||
const fromName = data.name;
|
||||
const boundary = `boundary_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const headers: string[] = [];
|
||||
|
||||
if (isDefined(fromName)) {
|
||||
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
|
||||
} else {
|
||||
headers.push(`From: ${fromEmail}`);
|
||||
}
|
||||
|
||||
headers.push(
|
||||
`To: ${sendMessageInput.to}`,
|
||||
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
sendMessageInput.body,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
sendMessageInput.html,
|
||||
'',
|
||||
`--${boundary}--`,
|
||||
);
|
||||
|
||||
const message = headers.join('\n');
|
||||
|
||||
const encodedMessage = Buffer.from(message).toString('base64');
|
||||
|
||||
await gmailClient.users.messages.send({
|
||||
|
|
@ -85,8 +97,8 @@ export class MessagingSendMessageService {
|
|||
const message = {
|
||||
subject: sendMessageInput.subject,
|
||||
body: {
|
||||
contentType: 'Text',
|
||||
content: sendMessageInput.body,
|
||||
contentType: 'HTML',
|
||||
content: sendMessageInput.html,
|
||||
},
|
||||
toRecipients: [{ emailAddress: { address: sendMessageInput.to } }],
|
||||
};
|
||||
|
|
@ -130,6 +142,7 @@ export class MessagingSendMessageService {
|
|||
to: sendMessageInput.to,
|
||||
subject: sendMessageInput.subject,
|
||||
text: sendMessageInput.body,
|
||||
html: sendMessageInput.html,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { mimeEncode } from 'src/modules/messaging/message-import-manager/utils/mime-encode.util';
|
||||
|
||||
describe('Gmail MIME Message Format', () => {
|
||||
it('should create valid multipart/alternative MIME structure', () => {
|
||||
const sendMessageInput = {
|
||||
to: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
body: 'Plain text content',
|
||||
html: '<p>HTML content</p>',
|
||||
};
|
||||
|
||||
const fromEmail = 'sender@example.com';
|
||||
const fromName = 'Test Sender';
|
||||
const boundary = 'boundary_test_123';
|
||||
|
||||
const headers: string[] = [];
|
||||
|
||||
headers.push(`From: "${mimeEncode(fromName)}" <${fromEmail}>`);
|
||||
headers.push(
|
||||
`To: ${sendMessageInput.to}`,
|
||||
`Subject: ${mimeEncode(sendMessageInput.subject)}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/alternative; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/plain; charset="UTF-8"',
|
||||
'',
|
||||
sendMessageInput.body,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
'Content-Type: text/html; charset="UTF-8"',
|
||||
'',
|
||||
sendMessageInput.html,
|
||||
'',
|
||||
`--${boundary}--`,
|
||||
);
|
||||
|
||||
const message = headers.join('\n');
|
||||
|
||||
expect(message).toContain('MIME-Version: 1.0');
|
||||
expect(message).toContain('Content-Type: multipart/alternative');
|
||||
expect(message).toContain('Content-Type: text/plain; charset="UTF-8"');
|
||||
expect(message).toContain('Content-Type: text/html; charset="UTF-8"');
|
||||
expect(message).toContain('Plain text content');
|
||||
expect(message).toContain('<p>HTML content</p>');
|
||||
expect(message).toContain(`--${boundary}`);
|
||||
expect(message).toContain(`--${boundary}--`);
|
||||
});
|
||||
|
||||
it('should handle missing fromName gracefully', () => {
|
||||
const fromEmail = 'sender@example.com';
|
||||
const headers: string[] = [];
|
||||
|
||||
headers.push(`From: ${fromEmail}`);
|
||||
|
||||
const message = headers.join('\n');
|
||||
|
||||
expect(message).toContain(`From: ${fromEmail}`);
|
||||
expect(message).not.toContain('""');
|
||||
});
|
||||
});
|
||||
|
|
@ -24,6 +24,7 @@ import {
|
|||
type FieldTypeAndNameMetadata,
|
||||
getTsVectorColumnExpressionFromFields,
|
||||
} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util';
|
||||
import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity';
|
||||
import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
|
||||
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity';
|
||||
import { WorkflowAutomatedTriggerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity';
|
||||
|
|
@ -193,6 +194,18 @@ export class WorkflowWorkspaceEntity extends BaseWorkspaceEntity {
|
|||
@WorkspaceIsSystem()
|
||||
timelineActivities: Relation<TimelineActivityWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceRelation({
|
||||
standardId: WORKFLOW_STANDARD_FIELD_IDS.attachments,
|
||||
type: RelationType.ONE_TO_MANY,
|
||||
label: msg`Attachments`,
|
||||
description: msg`Attachments linked to the workflow`,
|
||||
icon: 'IconFileUpload',
|
||||
inverseSideTarget: () => AttachmentWorkspaceEntity,
|
||||
onDelete: RelationOnDeleteAction.CASCADE,
|
||||
})
|
||||
@WorkspaceIsSystem()
|
||||
attachments: Relation<AttachmentWorkspaceEntity[]>;
|
||||
|
||||
@WorkspaceField({
|
||||
standardId: WORKFLOW_STANDARD_FIELD_IDS.createdBy,
|
||||
type: FieldMetadataType.ACTOR,
|
||||
|
|
|
|||
11
packages/twenty-server/src/utils/parse-email-body.ts
Normal file
11
packages/twenty-server/src/utils/parse-email-body.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { type JSONContent } from 'twenty-emails';
|
||||
|
||||
export const parseEmailBody = (body: string): JSONContent | string => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
|
||||
return json;
|
||||
} catch {
|
||||
return body;
|
||||
}
|
||||
};
|
||||
|
|
@ -34,6 +34,17 @@ export { safeParseRelativeDateFilterValue } from './safeParseRelativeDateFilterV
|
|||
export { getGenericOperationName } from './sentry/getGenericOperationName';
|
||||
export { getHumanReadableNameFromCode } from './sentry/getHumanReadableNameFromCode';
|
||||
export { capitalize } from './strings/capitalize';
|
||||
export type {
|
||||
TipTapMarkType,
|
||||
TipTapNodeType,
|
||||
LinkMarkAttributes,
|
||||
TipTapMark,
|
||||
} from './tiptap/tiptap-marks';
|
||||
export {
|
||||
TIPTAP_MARK_TYPES,
|
||||
TIPTAP_NODE_TYPES,
|
||||
TIPTAP_MARKS_RENDER_ORDER,
|
||||
} from './tiptap/tiptap-marks';
|
||||
export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties';
|
||||
export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string';
|
||||
|
|
|
|||
1
packages/twenty-shared/src/utils/tiptap/index.ts
Normal file
1
packages/twenty-shared/src/utils/tiptap/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './tiptap-marks';
|
||||
42
packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
Normal file
42
packages/twenty-shared/src/utils/tiptap/tiptap-marks.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Shared TipTap types for consistency between frontend and email renderer
|
||||
|
||||
export const TIPTAP_MARK_TYPES = {
|
||||
BOLD: 'bold',
|
||||
ITALIC: 'italic',
|
||||
UNDERLINE: 'underline',
|
||||
STRIKE: 'strike',
|
||||
LINK: 'link',
|
||||
} as const;
|
||||
|
||||
export const TIPTAP_NODE_TYPES = {
|
||||
PARAGRAPH: 'paragraph',
|
||||
TEXT: 'text',
|
||||
HEADING: 'heading',
|
||||
VARIABLE_TAG: 'variableTag',
|
||||
IMAGE: 'image',
|
||||
} as const;
|
||||
|
||||
export type TipTapMarkType = typeof TIPTAP_MARK_TYPES[keyof typeof TIPTAP_MARK_TYPES];
|
||||
export type TipTapNodeType = typeof TIPTAP_NODE_TYPES[keyof typeof TIPTAP_NODE_TYPES];
|
||||
|
||||
// Order for mark rendering (inner to outer)
|
||||
export const TIPTAP_MARKS_RENDER_ORDER: readonly TipTapMarkType[] = [
|
||||
TIPTAP_MARK_TYPES.UNDERLINE,
|
||||
TIPTAP_MARK_TYPES.BOLD,
|
||||
TIPTAP_MARK_TYPES.ITALIC,
|
||||
TIPTAP_MARK_TYPES.STRIKE,
|
||||
TIPTAP_MARK_TYPES.LINK,
|
||||
] as const;
|
||||
|
||||
// Link mark attributes interface
|
||||
export interface LinkMarkAttributes {
|
||||
href?: string;
|
||||
target?: string;
|
||||
rel?: string;
|
||||
}
|
||||
|
||||
// Generic mark interface
|
||||
export interface TipTapMark {
|
||||
type: TipTapMarkType;
|
||||
attrs?: LinkMarkAttributes | Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -3,6 +3,9 @@ export {
|
|||
IconNumber123 as Icon123,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconAppWindow,
|
||||
|
|
@ -21,6 +24,7 @@ export {
|
|||
IconBaselineDensitySmall,
|
||||
IconBell,
|
||||
IconBlockquote,
|
||||
IconBold,
|
||||
IconBolt,
|
||||
IconBook2,
|
||||
IconBookmark,
|
||||
|
|
@ -42,11 +46,11 @@ export {
|
|||
IconCalendar,
|
||||
IconCalendarDue,
|
||||
IconCalendarEvent,
|
||||
IconCalendarMonth,
|
||||
IconCalendarRepeat,
|
||||
IconCalendarTime,
|
||||
IconCalendarX,
|
||||
IconCalendarMonth,
|
||||
IconCalendarWeek,
|
||||
IconCalendarX,
|
||||
IconChartBar,
|
||||
IconChartCandle,
|
||||
IconChartDots3,
|
||||
|
|
@ -190,6 +194,7 @@ export {
|
|||
IconId,
|
||||
IconInbox,
|
||||
IconInfoCircle,
|
||||
IconItalic,
|
||||
IconJson,
|
||||
IconKey,
|
||||
IconLanguage,
|
||||
|
|
@ -305,6 +310,7 @@ export {
|
|||
IconSquareRoundedX,
|
||||
IconStatusChange,
|
||||
IconStepInto,
|
||||
IconStrikethrough,
|
||||
IconSun,
|
||||
IconSunMoon,
|
||||
IconSwitchHorizontal,
|
||||
|
|
@ -326,6 +332,7 @@ export {
|
|||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconTypography,
|
||||
IconUnderline,
|
||||
IconUnlink,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
|
|
|
|||
|
|
@ -65,6 +65,9 @@ export {
|
|||
Icon123,
|
||||
IconAlertCircle,
|
||||
IconAlertTriangle,
|
||||
IconAlignCenter,
|
||||
IconAlignLeft,
|
||||
IconAlignRight,
|
||||
IconApi,
|
||||
IconApps,
|
||||
IconAppWindow,
|
||||
|
|
@ -83,6 +86,7 @@ export {
|
|||
IconBaselineDensitySmall,
|
||||
IconBell,
|
||||
IconBlockquote,
|
||||
IconBold,
|
||||
IconBolt,
|
||||
IconBook2,
|
||||
IconBookmark,
|
||||
|
|
@ -104,11 +108,11 @@ export {
|
|||
IconCalendar,
|
||||
IconCalendarDue,
|
||||
IconCalendarEvent,
|
||||
IconCalendarMonth,
|
||||
IconCalendarRepeat,
|
||||
IconCalendarTime,
|
||||
IconCalendarX,
|
||||
IconCalendarMonth,
|
||||
IconCalendarWeek,
|
||||
IconCalendarX,
|
||||
IconChartBar,
|
||||
IconChartCandle,
|
||||
IconChartDots3,
|
||||
|
|
@ -252,6 +256,7 @@ export {
|
|||
IconId,
|
||||
IconInbox,
|
||||
IconInfoCircle,
|
||||
IconItalic,
|
||||
IconJson,
|
||||
IconKey,
|
||||
IconLanguage,
|
||||
|
|
@ -367,6 +372,7 @@ export {
|
|||
IconSquareRoundedX,
|
||||
IconStatusChange,
|
||||
IconStepInto,
|
||||
IconStrikethrough,
|
||||
IconSun,
|
||||
IconSunMoon,
|
||||
IconSwitchHorizontal,
|
||||
|
|
@ -388,6 +394,7 @@ export {
|
|||
IconTrendingDown,
|
||||
IconTrendingUp,
|
||||
IconTypography,
|
||||
IconUnderline,
|
||||
IconUnlink,
|
||||
IconUpload,
|
||||
IconUser,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@ const StyledEditorLoader = styled.div<{
|
|||
}>`
|
||||
align-items: center;
|
||||
display: flex;
|
||||
height: ${({ height }) => height}px;
|
||||
height: ${({ height }) =>
|
||||
typeof height === 'number' ? `${height}px` : height};
|
||||
justify-content: center;
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
|
|
|
|||
292
yarn.lock
292
yarn.lock
|
|
@ -11209,13 +11209,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@one-ini/wasm@npm:0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "@one-ini/wasm@npm:0.1.1"
|
||||
checksum: 10c0/54700e055037f1a63bfcc86d24822203b25759598c2c3e295d1435130a449108aebc119c9c2e467744767dbe0b6ab47a182c61aa1071ba7368f5e20ab197ba65
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@open-draft/deferred-promise@npm:^2.1.0, @open-draft/deferred-promise@npm:^2.2.0":
|
||||
version: 2.2.0
|
||||
resolution: "@open-draft/deferred-promise@npm:2.2.0"
|
||||
|
|
@ -15022,32 +15015,32 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/body@npm:0.0.11":
|
||||
version: 0.0.11
|
||||
resolution: "@react-email/body@npm:0.0.11"
|
||||
"@react-email/body@npm:0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "@react-email/body@npm:0.1.0"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/4a86ea8041bf94992acde12efd79cd91f980b3c40a86792f98bd546e0b49f19fbdca1fc87d5d3e472a70535a3195f2738bbec1f9dec862d48c4bc625f599d3d6
|
||||
checksum: 10c0/deeee52e0326eed20b3267dfe970649705298efae9ff580009ee7f48c201cbca6cd200be163865178ccdab04fc8e44384c6dd7ab130557b93f421d3b263b33c8
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/button@npm:0.0.19":
|
||||
version: 0.0.19
|
||||
resolution: "@react-email/button@npm:0.0.19"
|
||||
"@react-email/button@npm:0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "@react-email/button@npm:0.2.0"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/e96fe15cb4307c36add3ebaf382cf5f408c29fbbc433ef09b347a4d6488eecdaa23424880f8bdad18f773c91e8c0b2d24ec74c9010608f55100c5625c9c0be56
|
||||
checksum: 10c0/1145d096ace8a459271f550faa941668122bd816adc6547390ede5becab5b0dc99a6f19c838e539fe53a73953bd4a4de790b1a7ad3d5981c44f5d34149045bfc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/code-block@npm:0.0.11":
|
||||
version: 0.0.11
|
||||
resolution: "@react-email/code-block@npm:0.0.11"
|
||||
"@react-email/code-block@npm:0.1.0":
|
||||
version: 0.1.0
|
||||
resolution: "@react-email/code-block@npm:0.1.0"
|
||||
dependencies:
|
||||
prismjs: "npm:1.29.0"
|
||||
prismjs: "npm:^1.30.0"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/98a45460620c887a8adf5c59a936dd9fa2df76101b00b246df90ffe9108158262715b00401018ba6ba22f10dfad46b7e9b71cefa3c672728deee0a18c1f13293
|
||||
checksum: 10c0/55d84654cbf593e49102a11d1615e3beefb6943a82842ab1e51f435c6130a62f4ea2ebc034b127ebcc96822789b58cf352df340823278c9759bb3f031d8a796e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -15069,13 +15062,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/components@npm:0.0.35":
|
||||
version: 0.0.35
|
||||
resolution: "@react-email/components@npm:0.0.35"
|
||||
"@react-email/components@npm:^0.5.3":
|
||||
version: 0.5.3
|
||||
resolution: "@react-email/components@npm:0.5.3"
|
||||
dependencies:
|
||||
"@react-email/body": "npm:0.0.11"
|
||||
"@react-email/button": "npm:0.0.19"
|
||||
"@react-email/code-block": "npm:0.0.11"
|
||||
"@react-email/body": "npm:0.1.0"
|
||||
"@react-email/button": "npm:0.2.0"
|
||||
"@react-email/code-block": "npm:0.1.0"
|
||||
"@react-email/code-inline": "npm:0.0.5"
|
||||
"@react-email/column": "npm:0.0.13"
|
||||
"@react-email/container": "npm:0.0.15"
|
||||
|
|
@ -15086,16 +15079,16 @@ __metadata:
|
|||
"@react-email/html": "npm:0.0.11"
|
||||
"@react-email/img": "npm:0.0.11"
|
||||
"@react-email/link": "npm:0.0.12"
|
||||
"@react-email/markdown": "npm:0.0.14"
|
||||
"@react-email/preview": "npm:0.0.12"
|
||||
"@react-email/render": "npm:1.0.5"
|
||||
"@react-email/markdown": "npm:0.0.15"
|
||||
"@react-email/preview": "npm:0.0.13"
|
||||
"@react-email/render": "npm:1.2.3"
|
||||
"@react-email/row": "npm:0.0.12"
|
||||
"@react-email/section": "npm:0.0.16"
|
||||
"@react-email/tailwind": "npm:1.0.4"
|
||||
"@react-email/text": "npm:0.1.1"
|
||||
"@react-email/tailwind": "npm:1.2.2"
|
||||
"@react-email/text": "npm:0.1.5"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/b227f9d06e414334bbe5fb86919f8c48f0440532a3c9dd3272899e1fb86595eead70ab79407d3325f55de3707f57f863b6bee253415b887012d2454d2016d552
|
||||
checksum: 10c0/a0839c635cde48d45d49d3ea19aa17a8e5616225e516d76de1c4d0915d88b903241033a55f4aa047d212672e48ff809840e0d05afd80bbaacb330cef7804971a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -15171,51 +15164,37 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/markdown@npm:0.0.14":
|
||||
version: 0.0.14
|
||||
resolution: "@react-email/markdown@npm:0.0.14"
|
||||
"@react-email/markdown@npm:0.0.15":
|
||||
version: 0.0.15
|
||||
resolution: "@react-email/markdown@npm:0.0.15"
|
||||
dependencies:
|
||||
md-to-react-email: "npm:5.0.5"
|
||||
md-to-react-email: "npm:^5.0.5"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/e124a1844704e3c48f189d2d2769ca9f1193499f1c7353a804161b1c5df373fa815255116e653f7228192bcd5ff826ff55eb15c972a8e7facb6c82721511c764
|
||||
checksum: 10c0/68dc46d929ad0daf86f7d25e159455bdfbf4c0f0ca8ad152caf407f2eb154f4edb88912728cd5bba0c01f4806217e533e66baa02138c8ddcb5f2049719da7137
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/preview@npm:0.0.12":
|
||||
version: 0.0.12
|
||||
resolution: "@react-email/preview@npm:0.0.12"
|
||||
"@react-email/preview@npm:0.0.13":
|
||||
version: 0.0.13
|
||||
resolution: "@react-email/preview@npm:0.0.13"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/54c09fb599263b7e01f290cd3ed37db31b7370cb036851496067470f4873dfe82102e0746e390e9b83e27d9a054044259412521ba65eac7366fb5e17580b7787
|
||||
checksum: 10c0/223cff5238f0bfc4f1c3cdd1ae4f8b92cde16a60594d4d5073607b7af0b3cfaf5ae10234ca531d9403c7143afcba2e71bc12c1aba75e51f0ae58fd9c0b8bf542
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/render@npm:0.0.17":
|
||||
version: 0.0.17
|
||||
resolution: "@react-email/render@npm:0.0.17"
|
||||
"@react-email/render@npm:1.2.3, @react-email/render@npm:^1.2.3":
|
||||
version: 1.2.3
|
||||
resolution: "@react-email/render@npm:1.2.3"
|
||||
dependencies:
|
||||
html-to-text: "npm:9.0.5"
|
||||
js-beautify: "npm:^1.14.11"
|
||||
react-promise-suspense: "npm:0.3.4"
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
react-dom: ^18.2.0
|
||||
checksum: 10c0/34a1c5a55586d7cd723638d8cd5328fddf30015e5065a2e9d5204772e0532e84d63af1b2b1b8ec02cef52a8f57d34871200c0075a9ed7b2861362d8138d3db5e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/render@npm:1.0.5":
|
||||
version: 1.0.5
|
||||
resolution: "@react-email/render@npm:1.0.5"
|
||||
dependencies:
|
||||
html-to-text: "npm:9.0.5"
|
||||
prettier: "npm:3.4.2"
|
||||
react-promise-suspense: "npm:0.3.4"
|
||||
html-to-text: "npm:^9.0.5"
|
||||
prettier: "npm:^3.5.3"
|
||||
react-promise-suspense: "npm:^0.3.4"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/ea3afc75d8b5e3035664d4f01cb19b5010bc69eac56d19e889f8ea86ed259537967a192d2f7becc92d5886bf1fe050bbd347e980d45915c05533754a699b50f7
|
||||
checksum: 10c0/7ddd786e808fae6dc9aa456965ec5a67dff5d143b027c70538158e182a99a59b784a092c44f61ace20368ab5e31c31875ae1513593efe153d029c9870928d07b
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -15237,21 +15216,21 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/tailwind@npm:1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "@react-email/tailwind@npm:1.0.4"
|
||||
"@react-email/tailwind@npm:1.2.2":
|
||||
version: 1.2.2
|
||||
resolution: "@react-email/tailwind@npm:1.2.2"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/47472ae00c1731ec9cce20e1083fcb405c049a378d28889fb5334d6d3fbd1526e13f6c775691f3fa5e6796a30b98b2f3e9e242a61454dfc097dc5f6f5dc5fc8d
|
||||
checksum: 10c0/fa7d0a4d518a77cf0ec26c57aeac8a24509344225a19d6f3c1320a4357d88b2d48e592f312286ec59ef17333d875e3f3437cd09102d793896229e3f5502d079e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@react-email/text@npm:0.1.1":
|
||||
version: 0.1.1
|
||||
resolution: "@react-email/text@npm:0.1.1"
|
||||
"@react-email/text@npm:0.1.5":
|
||||
version: 0.1.5
|
||||
resolution: "@react-email/text@npm:0.1.5"
|
||||
peerDependencies:
|
||||
react: ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
checksum: 10c0/e88e2792bef38b94f0908caeb4355e4412f472e6642daf9eecd58054eef4e729f97d397395116f4957ac087af1951cb70c312ee28f952c47343eda2629213deb
|
||||
checksum: 10c0/ad825332903917d1185ce7c5e4d19846496565996f82b71a873648dffc6342d382606a96a5a00a15a16abef5322540d56b481ad3ca21c8b34de9b598715ec149
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -19206,6 +19185,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-bold@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-bold@npm:3.4.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
checksum: 10c0/f37a5701432f47c09e979c9944edccfe5fe0009b51666bd0719096e8744465bf3c38c5a405510af57aa275208bde408f6c1cd0d685a265d1659cc89ddca47cb4
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-bubble-menu@npm:^2.12.0":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-bubble-menu@npm:2.12.0"
|
||||
|
|
@ -19290,6 +19278,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-heading@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-heading@npm:3.4.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
checksum: 10c0/90c61508b76469c9ae314539a363910f759e18a4090c34401d873a4e04d3b40aaf2d81f426b5403d9de035eee905fde86600ef8e6d3739318492abfa38d7e1d2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-history@npm:^2.11.5":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-history@npm:2.12.0"
|
||||
|
|
@ -19310,6 +19307,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-image@npm:^3.4.4":
|
||||
version: 3.4.4
|
||||
resolution: "@tiptap/extension-image@npm:3.4.4"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.4
|
||||
checksum: 10c0/d42667f3dab4dae8d1776b02fc4421b51d5cfd7dd80a4b871dc13000ed272b60dbc2e90f1209e715706810d92743d0c629687df1b2ec195bdaf424e1d308101f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-italic@npm:^2.11.5":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-italic@npm:2.12.0"
|
||||
|
|
@ -19319,6 +19325,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-italic@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-italic@npm:3.4.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
checksum: 10c0/ea18f40b4676d97bc85a8d29a9625239189567260a4f13ebde3aca9253c7514d9ad21cde9ecea97b6de2dabb54db392a0504d483385b185d112e702dc5f288f7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-link@npm:^2.11.5":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-link@npm:2.12.0"
|
||||
|
|
@ -19331,6 +19346,18 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-link@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-link@npm:3.4.2"
|
||||
dependencies:
|
||||
linkifyjs: "npm:^4.3.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
"@tiptap/pm": ^3.4.2
|
||||
checksum: 10c0/6680403c34505f37ceb067c9277ccebc0f9b1126be570ca7f3e0730586963af0c483c9abb2ab8a88c3cfddb5d243fb49dbf920dc3ac0fcbc897a582919083808
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-paragraph@npm:^2.11.5":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-paragraph@npm:2.12.0"
|
||||
|
|
@ -19358,6 +19385,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-strike@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-strike@npm:3.4.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
checksum: 10c0/d763200cdcd579461492d6960705d56e9d5af4fcce026b826adc559ab5dbb2abb371cc65ac9e08b3398265a71a0bba96c864ed7a55ea69fdaf39e54d49ba8a6a
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-table-cell@npm:^2.11.5":
|
||||
version: 2.12.0
|
||||
resolution: "@tiptap/extension-table-cell@npm:2.12.0"
|
||||
|
|
@ -19403,6 +19439,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extension-underline@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extension-underline@npm:3.4.2"
|
||||
peerDependencies:
|
||||
"@tiptap/core": ^3.4.2
|
||||
checksum: 10c0/f4b716e1dd10f3d56232fc6a1f658ca31733ea0c604ec8cf674cbe20b86c5ba84a99f32c60a85f99c87f10ee1e9967d4262a057ddfdfb513de8ff5ded7272e13
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@tiptap/extensions@npm:^3.4.2":
|
||||
version: 3.4.2
|
||||
resolution: "@tiptap/extensions@npm:3.4.2"
|
||||
|
|
@ -27599,16 +27644,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"config-chain@npm:^1.1.13":
|
||||
version: 1.1.13
|
||||
resolution: "config-chain@npm:1.1.13"
|
||||
dependencies:
|
||||
ini: "npm:^1.3.4"
|
||||
proto-list: "npm:~1.2.1"
|
||||
checksum: 10c0/39d1df18739d7088736cc75695e98d7087aea43646351b028dfabd5508d79cf6ef4c5bcd90471f52cd87ae470d1c5490c0a8c1a292fbe6ee9ff688061ea0963e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"configstore@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "configstore@npm:5.0.1"
|
||||
|
|
@ -29842,20 +29877,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"editorconfig@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "editorconfig@npm:1.0.4"
|
||||
dependencies:
|
||||
"@one-ini/wasm": "npm:0.1.1"
|
||||
commander: "npm:^10.0.0"
|
||||
minimatch: "npm:9.0.1"
|
||||
semver: "npm:^7.5.3"
|
||||
bin:
|
||||
editorconfig: bin/editorconfig
|
||||
checksum: 10c0/ed6985959d7b34a56e1c09bef118758c81c969489b768d152c93689fce8403b0452462e934f665febaba3478eebc0fd41c0a36100783eaadf6d926c4abc87a3d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"ee-first@npm:1.1.1":
|
||||
version: 1.1.1
|
||||
resolution: "ee-first@npm:1.1.1"
|
||||
|
|
@ -33485,7 +33506,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.4.2":
|
||||
"glob@npm:^10.0.0, glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7":
|
||||
version: 10.4.5
|
||||
resolution: "glob@npm:10.4.5"
|
||||
dependencies:
|
||||
|
|
@ -37913,23 +37934,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-beautify@npm:^1.14.11":
|
||||
version: 1.15.4
|
||||
resolution: "js-beautify@npm:1.15.4"
|
||||
dependencies:
|
||||
config-chain: "npm:^1.1.13"
|
||||
editorconfig: "npm:^1.0.4"
|
||||
glob: "npm:^10.4.2"
|
||||
js-cookie: "npm:^3.0.5"
|
||||
nopt: "npm:^7.2.1"
|
||||
bin:
|
||||
css-beautify: js/bin/css-beautify.js
|
||||
html-beautify: js/bin/html-beautify.js
|
||||
js-beautify: js/bin/js-beautify.js
|
||||
checksum: 10c0/300386f648579feacda98640742e8db50d4504bc896673af8bc784a5864585abf89ad8d1f257f2cfd4e3da951e0e4d1f027aa3c21537edb920bd498a0e27bd86
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"js-cookie@npm:^3.0.5":
|
||||
version: 3.0.5
|
||||
resolution: "js-cookie@npm:3.0.5"
|
||||
|
|
@ -38910,6 +38914,13 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"linkifyjs@npm:^4.3.2":
|
||||
version: 4.3.2
|
||||
resolution: "linkifyjs@npm:4.3.2"
|
||||
checksum: 10c0/1a85e6b368304a4417567fe5e38651681e3e82465590836942d1b4f3c834cc35532898eb1e2479f6337d9144b297d418eb708b6be8ed0b3dc3954a3588e07971
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"listr2@npm:^4.0.5":
|
||||
version: 4.0.5
|
||||
resolution: "listr2@npm:4.0.5"
|
||||
|
|
@ -39939,7 +39950,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"md-to-react-email@npm:5.0.5":
|
||||
"md-to-react-email@npm:^5.0.5":
|
||||
version: 5.0.5
|
||||
resolution: "md-to-react-email@npm:5.0.5"
|
||||
dependencies:
|
||||
|
|
@ -41694,15 +41705,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "minimatch@npm:9.0.1"
|
||||
dependencies:
|
||||
brace-expansion: "npm:^2.0.1"
|
||||
checksum: 10c0/aa043eb8822210b39888a5d0d28df0017b365af5add9bd522f180d2a6962de1cbbf1bdeacdb1b17f410dc3336bc8d76fb1d3e814cdc65d00c2f68e01f0010096
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"minimatch@npm:9.0.3":
|
||||
version: 9.0.3
|
||||
resolution: "minimatch@npm:9.0.3"
|
||||
|
|
@ -42860,7 +42862,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"nopt@npm:^7.0.0, nopt@npm:^7.2.1":
|
||||
"nopt@npm:^7.0.0":
|
||||
version: 7.2.1
|
||||
resolution: "nopt@npm:7.2.1"
|
||||
dependencies:
|
||||
|
|
@ -45254,7 +45256,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:3.4.2, prettier@npm:^3.1.1, prettier@npm:^3.2.5":
|
||||
"prettier@npm:^3.1.1, prettier@npm:^3.2.5":
|
||||
version: 3.4.2
|
||||
resolution: "prettier@npm:3.4.2"
|
||||
bin:
|
||||
|
|
@ -45263,6 +45265,15 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prettier@npm:^3.5.3":
|
||||
version: 3.6.2
|
||||
resolution: "prettier@npm:3.6.2"
|
||||
bin:
|
||||
prettier: bin/prettier.cjs
|
||||
checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"pretty-bytes@npm:^5.3.0":
|
||||
version: 5.6.0
|
||||
resolution: "pretty-bytes@npm:5.6.0"
|
||||
|
|
@ -45342,10 +45353,10 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"prismjs@npm:1.29.0":
|
||||
version: 1.29.0
|
||||
resolution: "prismjs@npm:1.29.0"
|
||||
checksum: 10c0/d906c4c4d01b446db549b4f57f72d5d7e6ccaca04ecc670fb85cea4d4b1acc1283e945a9cbc3d81819084a699b382f970e02f9d1378e14af9808d366d9ed7ec6
|
||||
"prismjs@npm:^1.30.0":
|
||||
version: 1.30.0
|
||||
resolution: "prismjs@npm:1.30.0"
|
||||
checksum: 10c0/f56205bfd58ef71ccfcbcb691fd0eb84adc96c6ff21b0b69fc6fdcf02be42d6ef972ba4aed60466310de3d67733f6a746f89f2fb79c00bf217406d465b3e8f23
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
@ -45736,13 +45747,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"proto-list@npm:~1.2.1":
|
||||
version: 1.2.4
|
||||
resolution: "proto-list@npm:1.2.4"
|
||||
checksum: 10c0/b9179f99394ec8a68b8afc817690185f3b03933f7b46ce2e22c1930dc84b60d09f5ad222beab4e59e58c6c039c7f7fcf620397235ef441a356f31f9744010e12
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"protobufjs@npm:^7.2.5, protobufjs@npm:^7.3.0":
|
||||
version: 7.5.3
|
||||
resolution: "protobufjs@npm:7.5.3"
|
||||
|
|
@ -46519,7 +46523,7 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-promise-suspense@npm:0.3.4":
|
||||
"react-promise-suspense@npm:^0.3.4":
|
||||
version: 0.3.4
|
||||
resolution: "react-promise-suspense@npm:0.3.4"
|
||||
dependencies:
|
||||
|
|
@ -51632,6 +51636,7 @@ __metadata:
|
|||
"@lingui/react": "npm:^5.1.2"
|
||||
"@lingui/swc-plugin": "npm:^5.6.0"
|
||||
"@lingui/vite-plugin": "npm:^5.1.2"
|
||||
"@tiptap/core": "npm:^3.4.2"
|
||||
"@types/react": "npm:^19"
|
||||
"@types/react-dom": "npm:^19"
|
||||
react-email: "npm:4.0.3"
|
||||
|
|
@ -51673,10 +51678,17 @@ __metadata:
|
|||
"@react-pdf/renderer": "npm:^4.1.6"
|
||||
"@scalar/api-reference-react": "npm:^0.4.36"
|
||||
"@tiptap/core": "npm:^3.4.2"
|
||||
"@tiptap/extension-bold": "npm:^3.4.2"
|
||||
"@tiptap/extension-document": "npm:^3.4.2"
|
||||
"@tiptap/extension-hard-break": "npm:^3.4.2"
|
||||
"@tiptap/extension-heading": "npm:^3.4.2"
|
||||
"@tiptap/extension-image": "npm:^3.4.4"
|
||||
"@tiptap/extension-italic": "npm:^3.4.2"
|
||||
"@tiptap/extension-link": "npm:^3.4.2"
|
||||
"@tiptap/extension-paragraph": "npm:^3.4.2"
|
||||
"@tiptap/extension-strike": "npm:^3.4.2"
|
||||
"@tiptap/extension-text": "npm:^3.4.2"
|
||||
"@tiptap/extension-underline": "npm:^3.4.2"
|
||||
"@tiptap/extensions": "npm:^3.4.2"
|
||||
"@tiptap/react": "npm:^3.4.2"
|
||||
"@types/apollo-upload-client": "npm:^17.0.2"
|
||||
|
|
@ -51806,8 +51818,8 @@ __metadata:
|
|||
"@ptc-org/nestjs-query-core": "npm:4.4.0"
|
||||
"@ptc-org/nestjs-query-graphql": "patch:@ptc-org/nestjs-query-graphql@4.2.0#./patches/@ptc-org+nestjs-query-graphql+4.2.0.patch"
|
||||
"@ptc-org/nestjs-query-typeorm": "npm:4.2.1-alpha.2"
|
||||
"@react-email/components": "npm:0.0.35"
|
||||
"@react-email/render": "npm:0.0.17"
|
||||
"@react-email/components": "npm:^0.5.3"
|
||||
"@react-email/render": "npm:^1.2.3"
|
||||
"@revertdotdev/revert-react": "npm:^0.0.21"
|
||||
"@sentry/nestjs": "npm:^8.55.0"
|
||||
"@sentry/node": "npm:9.26.0"
|
||||
|
|
@ -52103,8 +52115,8 @@ __metadata:
|
|||
"@nx/vite": "npm:21.3.11"
|
||||
"@nx/web": "npm:21.3.11"
|
||||
"@playwright/test": "npm:^1.46.0"
|
||||
"@react-email/components": "npm:0.0.35"
|
||||
"@react-email/render": "npm:0.0.17"
|
||||
"@react-email/components": "npm:^0.5.3"
|
||||
"@react-email/render": "npm:^1.2.3"
|
||||
"@sentry/profiling-node": "npm:^9.26.0"
|
||||
"@sentry/react": "npm:^9.26.0"
|
||||
"@sentry/types": "npm:^8"
|
||||
|
|
|
|||
Loading…
Reference in a new issue