Baptiste Devessier
2025-08-18 17:40:34 +02:00
committed by GitHub
parent abe6b7828b
commit cded145609
41 changed files with 1100 additions and 942 deletions
@@ -1,99 +0,0 @@
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, type HandleProps } from '@xyflow/react';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramBaseHandleProps = HandleProps & {
isVisible?: boolean;
disableHoverEffect?: boolean;
selected?: boolean;
};
const HANDLE_SCALE_ON_HOVER = 1.5;
const TRANSLATE_PERCENT = 33.33;
const StyledHandle = styled(Handle, {
shouldForwardProp: (prop) =>
!['disableHoverEffect', 'selected'].includes(prop),
})<WorkflowDiagramBaseHandleProps>`
// We need !important to avoid passing style with the Handle.style property
height: ${NODE_HANDLE_HEIGHT_PX}px !important;
width: ${NODE_HANDLE_WIDTH_PX}px !important;
border-color: ${({ theme, selected }) =>
selected ? theme.color.blue : theme.border.color.strong} !important;
background: ${({ theme, selected }) =>
selected
? theme.adaptiveColors.blue1
: theme.background.primary} !important;
transition:
transform 0.1s ease-out,
background 0.1s,
border-color 0.1s !important;
z-index: 1 !important;
${({ position }) => {
switch (position) {
case 'top':
return `top: 19px !important;`;
case 'bottom':
return `top: 47px !important;`;
default:
return '';
}
}}
${({ disableHoverEffect, theme, position }) => {
if (disableHoverEffect === true) {
return undefined;
}
let transform = css`
transform: scale(${HANDLE_SCALE_ON_HOVER}) !important;
`;
if (position === 'top') {
transform = css`
transform: scale(${HANDLE_SCALE_ON_HOVER})
translate(${-TRANSLATE_PERCENT}%, ${-TRANSLATE_PERCENT}%) !important;
`;
} else if (position === 'bottom') {
transform = css`
transform: scale(${HANDLE_SCALE_ON_HOVER})
translate(${-TRANSLATE_PERCENT}%, ${TRANSLATE_PERCENT * 2}%) !important;
`;
}
return css`
&:hover {
background: ${theme.adaptiveColors.blue1} !important;
border-color: ${theme.color.blue} !important;
${transform}
}
`;
}}
`;
export const WorkflowDiagramBaseHandle = ({
type,
position,
isVisible = true,
selected = false,
}: WorkflowDiagramBaseHandleProps) => {
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
return (
<StyledHandle
type={type}
position={position}
style={{ opacity: isVisible ? 1 : 0 }}
disableHoverEffect={!isWorkflowBranchEnabled}
selected={selected}
/>
);
};
@@ -72,20 +72,6 @@ const StyledResetReactflowStyles = styled.div`
white-space: nowrap;
}
.react-flow__handle {
min-height: 0;
min-width: 0;
}
.react-flow__handle-top {
transform: translate(-50%, -50%);
}
.react-flow__handle-bottom {
transform: translate(-50%, 100%);
}
.react-flow__handle.connectionindicator {
cursor: pointer;
}
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
@@ -6,10 +6,7 @@ import { WorkflowDiagramBlankEdge } from '@/workflow/workflow-diagram/components
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramDefaultEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeEditable';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramFilteringDisabledEdgeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditable';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowDiagramRightClickMenuPositionState } from '@/workflow/workflow-diagram/states/workflowDiagramRightClickMenuPositionState';
import {
@@ -17,6 +14,9 @@ import {
type WorkflowDiagramNode,
} from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { WorkflowDiagramEmptyTriggerEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramFilterEdgeEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramFilterEdgeEditable';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditable';
import { useCreateEdge } from '@/workflow/workflow-steps/hooks/useCreateEdge';
import { useDeleteEdge } from '@/workflow/workflow-steps/hooks/useDeleteEdge';
import { useUpdateStep } from '@/workflow/workflow-steps/hooks/useUpdateStep';
@@ -1,11 +1,11 @@
import { type WorkflowVersionStatus } from '@/workflow/types/Workflow';
import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramDefaultEdgeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeReadonly';
import { WorkflowDiagramEmptyTriggerReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramEmptyTriggerReadonly';
import { WorkflowDiagramFilterEdgeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeReadonly';
import { WorkflowDiagramFilteringDisabledEdgeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeReadonly';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeReadonly';
import { getWorkflowVersionStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowVersionStatusTagProps';
import { WorkflowDiagramEmptyTriggerReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramEmptyTriggerReadonly';
import { WorkflowDiagramFilterEdgeReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramFilterEdgeReadonly';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeReadonly';
import { ReactFlowProvider } from '@xyflow/react';
export const WorkflowDiagramCanvasReadonly = ({
@@ -1,8 +1,4 @@
import { EDGE_BRANCH_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeBranchArrowMarkerId';
import { EDGE_GREEN_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenCircleMarkedId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerWidthPx';
import { EDGE_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeRoundedArrowMarkerId';
import { useTheme } from '@emotion/react';
export const WorkflowDiagramCustomMarkers = () => {
@@ -11,32 +7,6 @@ export const WorkflowDiagramCustomMarkers = () => {
return (
<svg style={{ position: 'absolute', top: 0, left: 0 }}>
<defs>
<marker
id={EDGE_ROUNDED_ARROW_MARKER_ID}
markerHeight={5}
markerWidth={6}
refX={3}
refY={2.5}
>
<path
d="M0.31094 1.1168C0.178029 0.917434 0.320947 0.650391 0.560555 0.650391H5.43945C5.67905 0.650391 5.82197 0.917434 5.68906 1.1168L3.62404 4.21433C3.32717 4.65963 2.67283 4.65963 2.37596 4.21433L0.31094 1.1168Z"
fill={theme.border.color.strong}
/>
</marker>
<marker
id={EDGE_GREEN_ROUNDED_ARROW_MARKER_ID}
markerHeight={5}
markerWidth={EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX}
refX={EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX / 2}
refY={2.5}
>
<path
d="M0.31094 1.1168C0.178029 0.917434 0.320947 0.650391 0.560555 0.650391H5.43945C5.67905 0.650391 5.82197 0.917434 5.68906 1.1168L3.62404 4.21433C3.32717 4.65963 2.67283 4.65963 2.37596 4.21433L0.31094 1.1168Z"
fill={theme.tag.text.turquoise}
/>
</marker>
<marker
id={EDGE_BRANCH_ARROW_MARKER_ID}
markerHeight={8}
@@ -50,25 +20,6 @@ export const WorkflowDiagramCustomMarkers = () => {
fill={theme.background.primary}
/>
</marker>
<marker
markerHeight={5}
markerWidth={5}
refX={2}
refY={2.5}
id={EDGE_GREEN_CIRCLE_MARKED_ID}
>
<rect
x={0.5}
y={0.5}
height={3}
width={3}
rx="1.5"
fill="white"
stroke={theme.tag.text.turquoise}
strokeWidth={1}
/>
</marker>
</defs>
</svg>
);
@@ -9,7 +9,8 @@ export const WorkflowDiagramDefaultEdgeRun = ({
sourceY,
targetX,
targetY,
data,
markerStart,
markerEnd,
}: WorkflowDiagramDefaultEdgeRunProps) => {
const [edgePath] = getBezierPath({
sourceX,
@@ -20,8 +21,9 @@ export const WorkflowDiagramDefaultEdgeRun = ({
return (
<WorkflowRunDiagramBaseEdge
edgePath={edgePath}
edgeExecutionStatus={data?.edgeExecutionStatus}
path={edgePath}
markerStart={markerStart}
markerEnd={markerEnd}
/>
);
};
@@ -1,57 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(3)};
`;
export const WorkflowDiagramEmptyTriggerEditable = () => {
const { t } = useLingui();
const { openWorkflowTriggerTypeInCommandMenu } = useWorkflowCommandMenu();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
return (
<WorkflowDiagramStepNodeBase
id="trigger"
name={t`Add a Trigger`}
nodeType="trigger"
variant="empty"
Icon={<StyledStepNodeLabelIconContainer />}
onClick={() => {
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
if (!isDefined(workflowVisualizerWorkflowId)) {
return;
}
openWorkflowTriggerTypeInCommandMenu(workflowVisualizerWorkflowId);
}}
/>
);
};
@@ -1,82 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(3)};
`;
export const WorkflowDiagramEmptyTriggerReadonly = () => {
const { getIcon } = useIcons();
const { t } = useLingui();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowVersionId = useRecoilComponentValue(
workflowVisualizerWorkflowVersionIdComponentState,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const { openWorkflowViewStepInCommandMenu } = useWorkflowCommandMenu();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
return (
<WorkflowDiagramStepNodeBase
id="trigger"
name={t`Add a Trigger`}
nodeType="trigger"
variant="empty"
Icon={<StyledStepNodeLabelIconContainer />}
onClick={() => {
if (
!isDefined(workflowVisualizerWorkflowId) ||
!isDefined(workflowVisualizerWorkflowVersionId)
) {
throw new Error(
'Workflow ID and Version ID must be defined to open the command menu.',
);
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openWorkflowViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: t`Add a Trigger`,
icon: getIcon(null),
});
}}
/>
);
};
@@ -63,6 +63,8 @@ export const WorkflowDiagramFilterEdgeRun = ({
targetX,
targetY,
data,
markerStart,
markerEnd,
}: WorkflowDiagramFilterEdgeRunProps) => {
assertFilterEdgeDataOrThrow(data);
@@ -98,8 +100,9 @@ export const WorkflowDiagramFilterEdgeRun = ({
return (
<>
<WorkflowRunDiagramBaseEdge
edgePath={edgePath}
edgeExecutionStatus={data.edgeExecutionStatus}
path={edgePath}
markerStart={markerStart}
markerEnd={markerEnd}
/>
<EdgeLabelRenderer>
@@ -10,7 +10,8 @@ export const WorkflowDiagramFilteringDisabledEdgeRun = ({
sourceY,
targetX,
targetY,
data,
markerStart,
markerEnd,
}: WorkflowDiagramFilteringDisabledEdgeRunProps) => {
const [edgePath] = getBezierPath({
sourceX,
@@ -21,8 +22,9 @@ export const WorkflowDiagramFilteringDisabledEdgeRun = ({
return (
<WorkflowRunDiagramBaseEdge
edgePath={edgePath}
edgeExecutionStatus={data?.edgeExecutionStatus}
path={edgePath}
markerStart={markerStart}
markerEnd={markerEnd}
/>
);
};
@@ -1,216 +0,0 @@
import { NODE_BORDER_WIDTH } from '@/workflow/workflow-diagram/constants/NodeBorderWidth';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { type WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { getWorkflowDiagramNodeSelectedColors } from '@/workflow/workflow-diagram/utils/getWorkflowDiagramNodeSelectedColors';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import React, { type MouseEvent } from 'react';
import { capitalize } from 'twenty-shared/utils';
import { Label, OverflowingTextWithTooltip } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
import { WorkflowDiagramBaseHandle } from '@/workflow/workflow-diagram/components/WorkflowDiagramBaseHandle';
import { Position } from '@xyflow/react';
import { useEdgeSelected } from '@/workflow/workflow-diagram/hooks/useEdgeSelected';
const StyledStepNodeContainer = styled.div`
display: flex;
flex-direction: column;
`;
const StyledStepNodeType = styled.div<{
nodeVariant: WorkflowDiagramNodeVariant;
}>`
${({ nodeVariant, theme }) => {
switch (nodeVariant) {
case 'running': {
return css`
background-color: ${theme.tag.background.yellow};
color: ${theme.tag.text.yellow};
`;
}
case 'success': {
return css`
background-color: ${theme.tag.background.turquoise};
color: ${theme.tag.text.turquoise};
`;
}
case 'failure': {
return css`
background-color: ${theme.tag.background.red};
color: ${theme.color.red};
`;
}
default: {
return css`
background-color: ${theme.background.tertiary};
`;
}
}
}}
align-self: flex-start;
border-radius: ${({ theme }) =>
`${theme.border.radius.sm} ${theme.border.radius.sm} 0 0`};
margin-left: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)};
.selectable:is(.selected, :focus, :focus-visible) & {
${({ nodeVariant, theme }) => {
switch (nodeVariant) {
case 'empty':
case 'default':
case 'not-executed':
return css`
background-color: ${theme.color.blue};
color: ${theme.font.color.inverted};
`;
}
}}
}
`.withComponent(Label);
const StyledStepNodeInnerContainer = styled.div<{
variant: WorkflowDiagramNodeVariant;
}>`
background: ${({ theme }) => theme.background.secondary};
border-color: ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
border-style: solid;
border-width: ${NODE_BORDER_WIDTH}px;
box-shadow: ${({ variant, theme }) =>
variant === 'empty' ? 'none' : theme.boxShadow.strong};
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
padding: ${({ theme }) => theme.spacing(2)};
position: relative;
transition: background ${({ theme }) => theme.animation.duration.fast} ease;
.workflow-node-container:hover & {
${({ theme }) => {
return css`
background: linear-gradient(
0deg,
${theme.background.transparent.lighter} 0%,
${theme.background.transparent.lighter} 100%
),
${theme.background.secondary};
`;
}}
}
.selectable:is(.selected, :focus, :focus-visible)
:is(.workflow-node-container, .workflow-node-container:hover)
& {
${({ theme, variant }) => {
const colors = getWorkflowDiagramNodeSelectedColors(variant, theme);
return css`
background: ${colors.background};
border-color: ${colors.borderColor};
`;
}}
}
`;
const StyledStepNodeLabel = styled.div<{
variant: WorkflowDiagramNodeVariant;
}>`
box-sizing: border-box;
align-items: center;
display: flex;
font-size: 13px;
font-weight: ${({ theme }) => theme.font.weight.medium};
column-gap: ${({ theme }) => theme.spacing(2)};
color: ${({ variant, theme }) => {
switch (variant) {
case 'empty':
case 'not-executed':
return theme.font.color.light;
default:
return theme.font.color.primary;
}
}};
max-width: 200px;
height: 24px;
.selectable:is(.selected, :focus, :focus-visible) & {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const WorkflowDiagramStepNodeBase = ({
id,
nodeType,
name,
variant,
selected,
Icon,
RightFloatingElement,
BottomHoverFloatingElement,
displayHandle = true,
onClick,
onMouseEnter,
onMouseLeave,
}: {
id: string;
nodeType: WorkflowDiagramStepNodeData['nodeType'];
name: string;
variant: WorkflowDiagramNodeVariant;
selected?: boolean;
Icon?: React.ReactNode;
RightFloatingElement?: React.ReactNode;
BottomHoverFloatingElement?: React.ReactNode;
displayHandle?: boolean;
onClick?: () => void;
onMouseEnter?: (event: MouseEvent<HTMLDivElement>) => void;
onMouseLeave?: (event: MouseEvent<HTMLDivElement>) => void;
}) => {
const { getNodeHandlesSelectedState } = useEdgeSelected();
const handlesSelectedState = getNodeHandlesSelectedState(id);
return (
<StyledStepNodeContainer
className="workflow-node-container"
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={onClick}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{nodeType !== 'trigger' && (
<WorkflowDiagramBaseHandle
type="target"
position={Position.Top}
isVisible={displayHandle}
selected={handlesSelectedState.targetHandle}
/>
)}
<StyledStepNodeType variant="small" nodeVariant={variant}>
{capitalize(nodeType)}
</StyledStepNodeType>
<StyledStepNodeInnerContainer variant={variant}>
<StyledStepNodeLabel variant={variant}>
{variant === 'running' ? <Loader /> : Icon}
<OverflowingTextWithTooltip text={name} hideTooltip={true} />
</StyledStepNodeLabel>
{RightFloatingElement}
{BottomHoverFloatingElement}
</StyledStepNodeInnerContainer>
<WorkflowDiagramBaseHandle
type="source"
position={Position.Bottom}
isVisible={displayHandle}
selected={handlesSelectedState.sourceHandle || selected}
/>
</StyledStepNodeContainer>
);
};
@@ -1,100 +0,0 @@
import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { type WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import styled from '@emotion/styled';
import { useState } from 'react';
import { IconTrash } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
const StyledDeleteButtonContainer = styled.div`
display: flex;
align-items: center;
position: absolute;
right: ${({ theme }) => theme.spacing(-4)};
bottom: 0;
top: 0;
transform: translateX(100%);
`;
const StyledAddStepButtonContainer = styled.div<{
shouldDisplay: boolean;
}>`
display: flex;
align-items: center;
position: absolute;
justify-content: center;
flex-direction: column;
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
left: 50%;
bottom: 0;
transform: translateX(-50%) translateY(100%);
`;
export const WorkflowDiagramStepNodeEditableContent = ({
id,
data,
selected,
variant,
onDelete,
onClick,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
variant: WorkflowDiagramNodeVariant;
selected: boolean;
onDelete: () => void;
onClick?: () => void;
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => setIsHovered(true);
const handleMouseLeave = () => {
setIsHovered(false);
};
const { isNodeCreationStarted } = useStartNodeCreation();
return (
<WorkflowDiagramStepNodeBase
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onClick={onClick}
id={id}
name={data.name}
variant={variant}
selected={selected}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
RightFloatingElement={
selected && (
<StyledDeleteButtonContainer>
<FloatingIconButton
size="medium"
Icon={IconTrash}
onClick={onDelete}
/>
</StyledDeleteButtonContainer>
)
}
BottomHoverFloatingElement={
!data.hasNextStepIds && (
<StyledAddStepButtonContainer
shouldDisplay={
isHovered ||
selected ||
isNodeCreationStarted({ parentStepId: data.stepId })
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowDiagramCreateStepElement data={data} />
</StyledAddStepButtonContainer>
)
}
/>
);
};
@@ -1,88 +0,0 @@
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { assertUnreachable } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.spacing(1)};
display: flex;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramStepNodeIcon = ({
data,
}: {
data: WorkflowDiagramStepNodeData;
}) => {
const theme = useTheme();
const { getIcon } = useIcons();
const Icon = getIcon(getWorkflowNodeIconKey(data));
switch (data.nodeType) {
case 'trigger': {
switch (data.triggerType) {
case 'DATABASE_EVENT':
case 'MANUAL':
case 'CRON':
case 'WEBHOOK': {
return (
<StyledStepNodeLabelIconContainer>
<Icon
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
return assertUnreachable(data.triggerType);
}
case 'action': {
switch (data.actionType) {
case 'CODE':
case 'HTTP_REQUEST': {
return (
<StyledStepNodeLabelIconContainer>
<Icon
size={theme.icon.size.md}
color={theme.color.orange}
stroke={theme.icon.stroke.sm}
/>
</StyledStepNodeLabelIconContainer>
);
}
case 'SEND_EMAIL': {
return (
<StyledStepNodeLabelIconContainer>
<Icon size={theme.icon.size.md} color={theme.color.blue} />
</StyledStepNodeLabelIconContainer>
);
}
case 'AI_AGENT': {
return (
<StyledStepNodeLabelIconContainer>
<Icon size={theme.icon.size.md} color={theme.color.pink} />
</StyledStepNodeLabelIconContainer>
);
}
default: {
return (
<StyledStepNodeLabelIconContainer>
<Icon
size={theme.icon.size.md}
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
</StyledStepNodeLabelIconContainer>
);
}
}
}
}
};
@@ -1,78 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowDiagramStepNodeReadonly = ({
id,
data,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
}) => {
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowVersionId = useRecoilComponentValue(
workflowVisualizerWorkflowVersionIdComponentState,
);
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const { openWorkflowViewStepInCommandMenu } = useWorkflowCommandMenu();
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
return (
<WorkflowDiagramStepNodeBase
id={id}
name={data.name}
variant={getNodeVariantFromStepRunStatus(data.runStatus)}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
displayHandle
onClick={() => {
if (
!isDefined(workflowVisualizerWorkflowId) ||
!isDefined(workflowVisualizerWorkflowVersionId)
) {
throw new Error('Workflow ID and Version ID must be defined');
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(id);
openWorkflowViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: data.name,
icon: getIcon(getWorkflowNodeIconKey(data)),
});
}}
/>
);
};
@@ -1,67 +1,27 @@
import { EDGE_GREEN_CIRCLE_MARKED_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenCircleMarkedId';
import { EDGE_GREEN_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeGreenRoundedArrowMarkerId';
import { EDGE_ROUNDED_ARROW_MARKER_ID } from '@/workflow/workflow-diagram/constants/EdgeRoundedArrowMarkerId';
import { type Theme, useTheme } from '@emotion/react';
import { BaseEdge } from '@xyflow/react';
import { isDefined } from 'twenty-shared/utils';
import { StepStatus } from 'twenty-shared/workflow';
import { type WorkflowDiagramEdge } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { useTheme } from '@emotion/react';
import { BaseEdge, type EdgeProps } from '@xyflow/react';
const toMarkerId = (id: string) => `url(#${id})`;
const getMarkerStart = (edgeExecutionStatus: StepStatus | undefined) => {
if (edgeExecutionStatus === StepStatus.SUCCESS) {
return EDGE_GREEN_CIRCLE_MARKED_ID;
}
return undefined;
};
const getMarkerEnd = (edgeExecutionStatus: StepStatus | undefined) => {
if (edgeExecutionStatus === StepStatus.SUCCESS) {
return EDGE_GREEN_ROUNDED_ARROW_MARKER_ID;
}
return EDGE_ROUNDED_ARROW_MARKER_ID;
};
const getStrokeColor = ({
theme,
edgeExecutionStatus,
}: {
theme: Theme;
edgeExecutionStatus: StepStatus | undefined;
}) => {
if (edgeExecutionStatus === StepStatus.SUCCESS) {
return theme.tag.text.turquoise;
}
return theme.border.color.strong;
};
type WorkflowRunDiagramBaseEdgeProps = {
edgePath: string;
edgeExecutionStatus: StepStatus | undefined;
type WorkflowRunDiagramBaseEdgeProps = Pick<
EdgeProps<WorkflowDiagramEdge>,
'markerStart' | 'markerEnd'
> & {
path: string;
};
export const WorkflowRunDiagramBaseEdge = ({
edgePath,
edgeExecutionStatus,
path,
markerStart,
markerEnd,
}: WorkflowRunDiagramBaseEdgeProps) => {
const theme = useTheme();
const markerStart = getMarkerStart(edgeExecutionStatus);
return (
<BaseEdge
markerStart={isDefined(markerStart) ? toMarkerId(markerStart) : undefined}
markerEnd={toMarkerId(getMarkerEnd(edgeExecutionStatus))}
path={edgePath}
style={{
stroke: getStrokeColor({
theme,
edgeExecutionStatus,
}),
}}
markerStart={markerStart}
markerEnd={markerEnd}
path={path}
style={{ stroke: theme.border.color.strong }}
/>
);
};
@@ -3,7 +3,7 @@ import { WorkflowDiagramCanvasBase } from '@/workflow/workflow-diagram/component
import { WorkflowDiagramDefaultEdgeRun } from '@/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdgeRun';
import { WorkflowDiagramFilterEdgeRun } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilterEdgeRun';
import { WorkflowDiagramFilteringDisabledEdgeRun } from '@/workflow/workflow-diagram/components/WorkflowDiagramFilteringDisabledEdgeRun';
import { WorkflowRunDiagramStepNode } from '@/workflow/workflow-diagram/components/WorkflowRunDiagramStepNode';
import { WorkflowRunDiagramStepNode } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowRunDiagramStepNode';
import { getWorkflowRunStatusTagProps } from '@/workflow/workflow-diagram/utils/getWorkflowRunStatusTagProps';
import { ReactFlowProvider } from '@xyflow/react';
@@ -1,75 +0,0 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramStepNodeBase } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeIcon';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowRunDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getNodeVariantFromStepRunStatus } from '@/workflow/workflow-diagram/utils/getNodeVariantFromStepRunStatus';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowRunDiagramStepNode = ({
id,
data,
}: {
id: string;
data: WorkflowRunDiagramStepNodeData;
}) => {
const { getIcon } = useIcons();
const workflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowRunId = useWorkflowRunIdOrThrow();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
return (
<WorkflowDiagramStepNodeBase
id={id}
name={data.name}
variant={getNodeVariantFromStepRunStatus(data.runStatus)}
nodeType={data.nodeType}
Icon={<WorkflowDiagramStepNodeIcon data={data} />}
displayHandle={false}
onClick={() => {
if (!isDefined(workflowId)) {
throw new Error('Workflow ID must be defined');
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(id);
openWorkflowRunViewStepInCommandMenu({
workflowId,
workflowRunId,
title: data.name,
icon: getIcon(getWorkflowNodeIconKey(data)),
workflowSelectedNode: id,
stepExecutionStatus: data.runStatus,
});
}}
/>
);
};
@@ -6,16 +6,11 @@ import { RecoilRoot } from 'recoil';
import { ComponentDecorator } from 'twenty-ui/testing';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { WorkflowDiagramEmptyTriggerEditable } from '../WorkflowDiagramEmptyTriggerEditable';
import { WorkflowDiagramEmptyTriggerEditable } from '../../workflow-nodes/components/WorkflowDiagramEmptyTriggerEditable';
const meta: Meta<typeof WorkflowDiagramEmptyTriggerEditable> = {
title: 'Modules/Workflow/WorkflowDiagramEmptyTriggerEditable',
component: WorkflowDiagramEmptyTriggerEditable,
args: {
data: {
nodeType: 'empty-trigger',
},
},
decorators: [I18nFrontDecorator],
};
@@ -9,7 +9,7 @@ import { RecoilRoot } from 'recoil';
import { CatalogDecorator, type CatalogStory } from 'twenty-ui/testing';
import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator';
import { graphqlMocks } from '~/testing/graphqlMocks';
import { WorkflowDiagramStepNodeEditableContent } from '../WorkflowDiagramStepNodeEditableContent';
import { WorkflowDiagramStepNodeEditableContent } from '../../workflow-nodes/components/WorkflowDiagramStepNodeEditableContent';
const meta: Meta<typeof WorkflowDiagramStepNodeEditableContent> = {
title: 'Modules/Workflow/WorkflowDiagramStepNodeEditableContent',
@@ -1 +0,0 @@
export const EDGE_GREEN_CIRCLE_MARKED_ID = 'workflow-edge-green-circle';
@@ -1,2 +0,0 @@
export const EDGE_GREEN_ROUNDED_ARROW_MARKER_ID =
'workflow-edge-green-arrow-rounded';
@@ -1 +0,0 @@
export const EDGE_GREEN_ROUNDED_ARROW_MARKER_WIDTH_PX = 6;
@@ -1 +0,0 @@
export const EDGE_ROUNDED_ARROW_MARKER_ID = 'workflow-edge-arrow-rounded';
@@ -1 +0,0 @@
export const NODE_BORDER_WIDTH = 1;
@@ -0,0 +1,97 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
const StyledNodeContainer = styled(WorkflowNodeContainer)`
border-color: ${({ theme }) => theme.border.color.strong};
background: ${({ theme }) => theme.background.secondary};
&:hover {
background: linear-gradient(
0deg,
${({ theme }) => theme.background.transparent.lighter} 0%,
${({ theme }) => theme.background.transparent.lighter} 100%
),
${({ theme }) => theme.background.secondary};
}
.selected & {
border-color: ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.adaptiveColors.blue1};
}
`;
const StyledNodeLabel = styled(WorkflowNodeLabel)`
color: ${({ theme }) => theme.font.color.tertiary};
.selected & {
color: ${({ theme }) => theme.tag.text.blue};
}
`;
const StyledNodeTitle = styled(WorkflowNodeTitle)`
color: ${({ theme }) => theme.font.color.light};
.selected & {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const WorkflowDiagramEmptyTriggerEditable = () => {
const { t } = useLingui();
const { openWorkflowTriggerTypeInCommandMenu } = useWorkflowCommandMenu();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const handleClick = () => {
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
if (!isDefined(workflowVisualizerWorkflowId)) {
return;
}
openWorkflowTriggerTypeInCommandMenu(workflowVisualizerWorkflowId);
};
return (
<StyledNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={handleClick}
>
<WorkflowNodeIconContainer />
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<StyledNodeLabel>{t`Trigger`}</StyledNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<StyledNodeTitle>{t`Add a Trigger`}</StyledNodeTitle>
</WorkflowNodeRightPart>
</StyledNodeContainer>
);
};
@@ -0,0 +1,122 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
const StyledNodeContainer = styled(WorkflowNodeContainer)`
border-color: ${({ theme }) => theme.border.color.strong};
background: ${({ theme }) => theme.background.secondary};
&:hover {
background: linear-gradient(
0deg,
${({ theme }) => theme.background.transparent.lighter} 0%,
${({ theme }) => theme.background.transparent.lighter} 100%
),
${({ theme }) => theme.background.secondary};
}
.selected & {
border-color: ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.adaptiveColors.blue1};
}
`;
const StyledNodeLabel = styled(WorkflowNodeLabel)`
color: ${({ theme }) => theme.font.color.tertiary};
.selected & {
color: ${({ theme }) => theme.tag.text.blue};
}
`;
const StyledNodeTitle = styled(WorkflowNodeTitle)`
color: ${({ theme }) => theme.font.color.light};
.selected & {
color: ${({ theme }) => theme.font.color.primary};
}
`;
export const WorkflowDiagramEmptyTriggerReadonly = () => {
const { getIcon } = useIcons();
const { t } = useLingui();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowVersionId = useRecoilComponentValue(
workflowVisualizerWorkflowVersionIdComponentState,
);
const { isInRightDrawer } = useContext(ActionMenuContext);
const { openWorkflowViewStepInCommandMenu } = useWorkflowCommandMenu();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const handleClick = () => {
if (
!isDefined(workflowVisualizerWorkflowId) ||
!isDefined(workflowVisualizerWorkflowVersionId)
) {
throw new Error(
'Workflow ID and Version ID must be defined to open the command menu.',
);
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(TRIGGER_STEP_ID);
openWorkflowViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: t`Add a Trigger`,
icon: getIcon(null),
});
};
return (
<StyledNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={handleClick}
>
<WorkflowNodeIconContainer />
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<StyledNodeLabel>{t`Trigger`}</StyledNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<StyledNodeTitle>{t`Add a Trigger`}</StyledNodeTitle>
</WorkflowNodeRightPart>
</StyledNodeContainer>
);
};
@@ -0,0 +1,95 @@
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, type HandleProps } from '@xyflow/react';
import { FeatureFlagKey } from '~/generated/graphql';
type WorkflowDiagramHandleEditableProps = HandleProps & {
selected: boolean;
};
const HANDLE_SCALE_ON_HOVER = 1.5;
const StyledHandle = styled(Handle, {
shouldForwardProp: (prop) =>
prop !== 'disableHoverEffect' && prop !== 'selected',
})<{
disableHoverEffect: boolean;
selected: boolean;
}>`
&.react-flow__handle {
height: ${NODE_HANDLE_HEIGHT_PX}px;
width: ${NODE_HANDLE_WIDTH_PX}px;
border-color: ${({ theme, selected }) =>
selected ? theme.color.blue : theme.border.color.strong};
background: ${({ theme, selected }) =>
selected ? theme.adaptiveColors.blue1 : theme.background.primary};
transition:
transform 0.1s ease-out,
background 0.1s,
border-color 0.1s;
z-index: 1;
${({ position }) => {
if (position === 'top') {
return css`
transform: translate(-50%, -50%);
transform-origin: top left;
`;
} else if (position === 'bottom') {
return css`
transform: translate(-50%, 50%);
transform-origin: bottom left;
`;
}
}}
&.connectionindicator {
cursor: pointer;
}
${({ disableHoverEffect, theme, position }) => {
if (disableHoverEffect) {
return undefined;
}
return css`
&:hover {
background: ${theme.adaptiveColors.blue1} !important;
border-color: ${theme.color.blue} !important;
${position === 'top' &&
css`
transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(-50%, -50%);
`}
${position === 'bottom' &&
css`
transform: scale(${HANDLE_SCALE_ON_HOVER}) translate(-50%, 50%);
`}
}
`;
}}
}
`;
export const WorkflowDiagramHandleEditable = ({
type,
position,
selected,
}: WorkflowDiagramHandleEditableProps) => {
const isWorkflowBranchEnabled = useIsFeatureEnabled(
FeatureFlagKey.IS_WORKFLOW_BRANCH_ENABLED,
);
return (
<StyledHandle
type={type}
position={position}
disableHoverEffect={!isWorkflowBranchEnabled}
selected={selected}
/>
);
};
@@ -0,0 +1,53 @@
import { NODE_HANDLE_HEIGHT_PX } from '@/workflow/workflow-diagram/constants/NodeHandleHeightPx';
import { NODE_HANDLE_WIDTH_PX } from '@/workflow/workflow-diagram/constants/NodeHandleWidthPx';
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import { Handle, type HandleProps } from '@xyflow/react';
type WorkflowDiagramHandleReadonlyProps = HandleProps & {
selected: boolean;
};
const StyledHandle = styled(Handle)<{
selected: boolean;
}>`
&.react-flow__handle {
height: ${NODE_HANDLE_HEIGHT_PX}px;
width: ${NODE_HANDLE_WIDTH_PX}px;
border-color: ${({ theme, selected }) =>
selected ? theme.color.blue : theme.border.color.strong};
background: ${({ theme, selected }) =>
selected ? theme.adaptiveColors.blue1 : theme.background.primary};
transition:
transform 0.1s ease-out,
background 0.1s,
border-color 0.1s;
z-index: 1;
${({ position }) => {
if (position === 'top') {
return css`
transform: translate(-50%, -50%);
transform-origin: top left;
`;
} else if (position === 'bottom') {
return css`
transform: translate(-50%, 50%);
transform-origin: bottom left;
`;
}
}}
&.connectionindicator {
cursor: default;
}
}
`;
export const WorkflowDiagramHandleReadonly = ({
type,
position,
selected,
}: WorkflowDiagramHandleReadonlyProps) => {
return <StyledHandle type={type} position={position} selected={selected} />;
};
@@ -4,10 +4,10 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WorkflowDiagramStepNodeEditableContent } from '@/workflow/workflow-diagram/components/WorkflowDiagramStepNodeEditableContent';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WorkflowDiagramStepNodeEditableContent } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeEditableContent';
import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
@@ -0,0 +1,167 @@
import { WorkflowDiagramCreateStepElement } from '@/workflow/workflow-diagram/components/WorkflowDiagramCreateStepElement';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { useEdgeSelected } from '@/workflow/workflow-diagram/hooks/useEdgeSelected';
import { useStartNodeCreation } from '@/workflow/workflow-diagram/hooks/useStartNodeCreation';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { type WorkflowDiagramNodeVariant } from '@/workflow/workflow-diagram/types/WorkflowDiagramNodeVariant';
import { WorkflowDiagramHandleEditable } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleEditable';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { useState } from 'react';
import { capitalize } from 'twenty-shared/utils';
import { IconTrash } from 'twenty-ui/display';
import { FloatingIconButton } from 'twenty-ui/input';
const StyledAddStepButtonContainer = styled.div<{
shouldDisplay: boolean;
}>`
display: flex;
align-items: center;
position: absolute;
justify-content: center;
flex-direction: column;
opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)};
left: 50%;
bottom: 0;
transform: translateX(-50%) translateY(100%);
`;
const StyledNodeContainer = styled(WorkflowNodeContainer)`
border-color: ${({ theme }) => theme.border.color.strong};
background: ${({ theme }) => theme.background.secondary};
&:hover {
background: linear-gradient(
0deg,
${({ theme }) => theme.background.transparent.lighter} 0%,
${({ theme }) => theme.background.transparent.lighter} 100%
),
${({ theme }) => theme.background.secondary};
}
.selected & {
border-color: ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.adaptiveColors.blue1};
}
`;
const StyledNodeLabel = styled(WorkflowNodeLabel)`
color: ${({ theme }) => theme.font.color.tertiary};
.selected & {
color: ${({ theme }) => theme.tag.text.blue};
}
`;
const StyledNodeTitle = styled(WorkflowNodeTitle)`
color: ${({ theme }) => theme.font.color.primary};
`;
const StyledDeleteButtonContainer = styled.div`
display: flex;
align-items: center;
position: absolute;
right: ${({ theme }) => theme.spacing(-4)};
bottom: 0;
top: 0;
transform: translateX(100%);
`;
export const WorkflowDiagramStepNodeEditableContent = ({
id,
data,
selected,
onClick,
onDelete,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
variant: WorkflowDiagramNodeVariant;
selected: boolean;
onDelete: () => void;
onClick?: () => void;
}) => {
const [isHovered, setIsHovered] = useState(false);
const handleMouseEnter = () => {
setIsHovered(true);
};
const handleMouseLeave = () => {
setIsHovered(false);
};
const { isNodeCreationStarted } = useStartNodeCreation();
const { getNodeHandlesSelectedState } = useEdgeSelected();
const handlesSelectedState = getNodeHandlesSelectedState(id);
return (
<>
{data.nodeType !== 'trigger' && (
<WorkflowDiagramHandleEditable
type="target"
position={Position.Top}
selected={handlesSelectedState.targetHandle}
/>
)}
<StyledNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={onClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowNodeIconContainer>
<WorkflowDiagramStepNodeIcon data={data} />
</WorkflowNodeIconContainer>
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<StyledNodeLabel>{capitalize(data.nodeType)}</StyledNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<StyledNodeTitle>{data.name}</StyledNodeTitle>
</WorkflowNodeRightPart>
{selected && (
<StyledDeleteButtonContainer>
<FloatingIconButton
size="medium"
Icon={IconTrash}
onClick={onDelete}
/>
</StyledDeleteButtonContainer>
)}
</StyledNodeContainer>
{!data.hasNextStepIds && (
<StyledAddStepButtonContainer
shouldDisplay={
isHovered ||
selected ||
isNodeCreationStarted({ parentStepId: data.stepId })
}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<WorkflowDiagramCreateStepElement data={data} />
</StyledAddStepButtonContainer>
)}
<WorkflowDiagramHandleEditable
type="source"
position={Position.Bottom}
selected={handlesSelectedState.sourceHandle || selected}
/>
</>
);
};
@@ -0,0 +1,61 @@
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { useTheme } from '@emotion/react';
import { assertUnreachable } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
export const WorkflowDiagramStepNodeIcon = ({
data,
}: {
data: WorkflowDiagramStepNodeData;
}) => {
const theme = useTheme();
const { getIcon } = useIcons();
const Icon = getIcon(getWorkflowNodeIconKey(data));
switch (data.nodeType) {
case 'trigger': {
switch (data.triggerType) {
case 'DATABASE_EVENT':
case 'MANUAL':
case 'CRON':
case 'WEBHOOK': {
return (
<Icon size={theme.icon.size.lg} color={theme.font.color.tertiary} />
);
}
}
return assertUnreachable(data.triggerType);
}
case 'action': {
switch (data.actionType) {
case 'CODE':
case 'HTTP_REQUEST': {
return (
<Icon
size={theme.icon.size.lg}
color={theme.color.orange}
stroke={theme.icon.stroke.sm}
/>
);
}
case 'SEND_EMAIL': {
return <Icon size={theme.icon.size.lg} color={theme.color.blue} />;
}
case 'AI_AGENT': {
return <Icon size={theme.icon.size.lg} color={theme.color.pink} />;
}
default: {
return (
<Icon
size={theme.icon.size.lg}
color={theme.font.color.tertiary}
stroke={theme.icon.stroke.sm}
/>
);
}
}
}
}
};
@@ -0,0 +1,144 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WorkflowDiagramHandleReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleReadonly';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
const StyledNodeContainer = styled(WorkflowNodeContainer)`
border-color: ${({ theme }) => theme.border.color.strong};
background: ${({ theme }) => theme.background.secondary};
&:hover {
background: linear-gradient(
0deg,
${({ theme }) => theme.background.transparent.lighter} 0%,
${({ theme }) => theme.background.transparent.lighter} 100%
),
${({ theme }) => theme.background.secondary};
}
.selected & {
border-color: ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.adaptiveColors.blue1};
}
`;
const StyledNodeLabel = styled(WorkflowNodeLabel)`
color: ${({ theme }) => theme.font.color.tertiary};
.selected & {
color: ${({ theme }) => theme.tag.text.blue};
}
`;
const StyledNodeTitle = styled(WorkflowNodeTitle)`
color: ${({ theme }) => theme.font.color.primary};
`;
export const WorkflowDiagramStepNodeReadonly = ({
id,
selected,
data,
}: {
id: string;
selected: boolean;
data: WorkflowDiagramStepNodeData;
}) => {
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowVisualizerWorkflowVersionId = useRecoilComponentValue(
workflowVisualizerWorkflowVersionIdComponentState,
);
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const { openWorkflowViewStepInCommandMenu } = useWorkflowCommandMenu();
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const handleClick = () => {
if (
!isDefined(workflowVisualizerWorkflowId) ||
!isDefined(workflowVisualizerWorkflowVersionId)
) {
throw new Error('Workflow ID and Version ID must be defined');
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(id);
openWorkflowViewStepInCommandMenu({
workflowId: workflowVisualizerWorkflowId,
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: data.name,
icon: getIcon(getWorkflowNodeIconKey(data)),
});
};
return (
<>
{data.nodeType !== 'trigger' && (
<WorkflowDiagramHandleReadonly
type="target"
position={Position.Top}
selected={false}
/>
)}
<StyledNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
onClick={handleClick}
>
<WorkflowNodeIconContainer>
<WorkflowDiagramStepNodeIcon data={data} />
</WorkflowNodeIconContainer>
<WorkflowNodeRightPart>
<WorkflowNodeLabelWithCounterPart>
<StyledNodeLabel>{capitalize(data.nodeType)}</StyledNodeLabel>
</WorkflowNodeLabelWithCounterPart>
<StyledNodeTitle>{data.name}</StyledNodeTitle>
</WorkflowNodeRightPart>
</StyledNodeContainer>
<WorkflowDiagramHandleReadonly
type="source"
position={Position.Bottom}
selected={selected}
/>
</>
);
};
@@ -0,0 +1,18 @@
import styled from '@emotion/styled';
const StyledNodeContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
max-width: 240px;
min-width: 44px;
padding: ${({ theme }) => theme.spacing(2)};
border-radius: ${({ theme }) => theme.border.radius.md};
border-width: 1px;
border-style: solid;
box-sizing: border-box;
cursor: pointer;
position: relative;
`;
export { StyledNodeContainer as WorkflowNodeContainer };
@@ -0,0 +1,14 @@
import styled from '@emotion/styled';
const StyledNodeIconContainer = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: 4px;
box-sizing: border-box;
display: flex;
height: 32px;
justify-content: center;
width: 32px;
`;
export { StyledNodeIconContainer as WorkflowNodeIconContainer };
@@ -0,0 +1,9 @@
import styled from '@emotion/styled';
import { Label } from 'twenty-ui/display';
const StyledNodeLabel = styled(Label)`
box-sizing: border-box;
flex: 1 0 0;
`;
export { StyledNodeLabel as WorkflowNodeLabel };
@@ -0,0 +1,12 @@
import styled from '@emotion/styled';
const StyledNodeLabelWithCounterPart = styled.div`
align-items: center;
align-self: stretch;
display: flex;
height: 14px;
justify-content: space-between;
box-sizing: border-box;
`;
export { StyledNodeLabelWithCounterPart as WorkflowNodeLabelWithCounterPart };
@@ -0,0 +1,13 @@
import styled from '@emotion/styled';
const StyledNodeRightPart = styled.div`
align-items: flex-start;
align-self: stretch;
display: flex;
flex-direction: column;
justify-content: space-between;
max-width: 184px;
box-sizing: border-box;
`;
export { StyledNodeRightPart as WorkflowNodeRightPart };
@@ -0,0 +1,15 @@
import styled from '@emotion/styled';
const StyledNodeTitle = styled.div`
box-sizing: border-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
align-self: stretch;
display: -webkit-box;
font-size: ${({ theme }) => theme.font.size.md};
font-weight: ${({ theme }) => theme.font.weight.medium};
overflow: hidden;
text-overflow: ellipsis;
`;
export { StyledNodeTitle as WorkflowNodeTitle };
@@ -0,0 +1,240 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { type WorkflowRunDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import { getWorkflowNodeIconKey } from '@/workflow/workflow-diagram/utils/getWorkflowNodeIconKey';
import { WorkflowDiagramHandleReadonly } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramHandleReadonly';
import { WorkflowDiagramStepNodeIcon } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowDiagramStepNodeIcon';
import { WorkflowNodeContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeContainer';
import { WorkflowNodeIconContainer } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeIconContainer';
import { WorkflowNodeLabel } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabel';
import { WorkflowNodeLabelWithCounterPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeLabelWithCounterPart';
import { WorkflowNodeRightPart } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeRightPart';
import { WorkflowNodeTitle } from '@/workflow/workflow-diagram/workflow-nodes/components/WorkflowNodeTitle';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Position } from '@xyflow/react';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { StepStatus } from 'twenty-shared/workflow';
import { IconCheck, IconX, useIcons } from 'twenty-ui/display';
import { Loader } from 'twenty-ui/feedback';
const StyledNodeContainer = styled(WorkflowNodeContainer)`
border-color: ${({ theme }) => theme.border.color.strong};
background: ${({ theme }) => theme.background.secondary};
&:hover {
background: linear-gradient(
0deg,
${({ theme }) => theme.background.transparent.lighter} 0%,
${({ theme }) => theme.background.transparent.lighter} 100%
),
${({ theme }) => theme.background.secondary};
}
.selected & {
border-color: ${({ theme }) => theme.color.blue};
background: ${({ theme }) => theme.adaptiveColors.blue1};
}
.selected &[data-status='RUNNING'],
.selected &[data-status='PENDING'] {
border-color: ${({ theme }) => theme.color.yellow};
background: ${({ theme }) => theme.adaptiveColors.yellow1};
}
.selected &[data-status='FAILED'] {
border-color: ${({ theme }) => theme.color.red};
background: ${({ theme }) => theme.adaptiveColors.red1};
}
.selected &[data-status='SUCCESS'] {
border-color: ${({ theme }) => theme.color.turquoise};
background: ${({ theme }) => theme.adaptiveColors.turquoise1};
}
`;
const StyledNodeLabelWithCounterPart = styled(WorkflowNodeLabelWithCounterPart)`
column-gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledNodeLabel = styled(WorkflowNodeLabel)`
color: ${({ theme }) => theme.font.color.tertiary};
.selected & {
color: ${({ theme }) => theme.tag.text.blue};
}
&[data-status='RUNNING'],
&[data-status='PENDING'] {
color: ${({ theme }) => theme.tag.text.yellow};
}
&[data-status='FAILED'] {
color: ${({ theme }) => theme.tag.text.red};
}
&[data-status='SUCCESS'] {
color: ${({ theme }) => theme.tag.text.green};
}
`;
const StyledNodeTitle = styled(WorkflowNodeTitle)`
color: ${({ theme }) => theme.font.color.light};
&[data-status='RUNNING'],
&[data-status='PENDING'],
&[data-status='FAILED'],
&[data-status='SUCCESS'],
.selected & {
color: ${({ theme }) => theme.font.color.primary};
}
`;
const StyledNodeCounter = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(1)};
justify-content: flex-end;
box-sizing: border-box;
`;
const StyledColorIcon = styled.div`
display: flex;
width: 14px;
height: 14px;
justify-content: center;
align-items: center;
box-sizing: border-box;
border-radius: ${({ theme }) => theme.border.radius.sm};
&[data-status='FAILED'] {
background: ${({ theme }) => theme.tag.background.red};
}
&[data-status='SUCCESS'] {
background: ${({ theme }) => theme.tag.background.turquoise};
}
`;
export const WorkflowRunDiagramStepNode = ({
id,
data,
selected,
}: {
id: string;
data: WorkflowRunDiagramStepNodeData;
selected: boolean;
}) => {
const { getIcon } = useIcons();
const theme = useTheme();
const workflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
const workflowRunId = useWorkflowRunIdOrThrow();
const setWorkflowSelectedNode = useSetRecoilComponentState(
workflowSelectedNodeComponentState,
);
const { openWorkflowRunViewStepInCommandMenu } = useWorkflowCommandMenu();
const { isInRightDrawer } = useContext(ActionMenuContext);
const setCommandMenuNavigationStack = useSetRecoilState(
commandMenuNavigationStackState,
);
const handleClick = () => {
if (!isDefined(workflowId)) {
throw new Error('Workflow ID must be defined');
}
if (!isInRightDrawer) {
setCommandMenuNavigationStack([]);
}
setWorkflowSelectedNode(id);
openWorkflowRunViewStepInCommandMenu({
workflowId,
workflowRunId,
title: data.name,
icon: getIcon(getWorkflowNodeIconKey(data)),
workflowSelectedNode: id,
stepExecutionStatus: data.runStatus,
});
};
return (
<>
{data.nodeType !== 'trigger' && (
<WorkflowDiagramHandleReadonly
type="target"
position={Position.Top}
selected={false}
/>
)}
<StyledNodeContainer
data-click-outside-id={WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID}
data-status={data.runStatus}
onClick={handleClick}
>
<WorkflowNodeIconContainer>
<WorkflowDiagramStepNodeIcon data={data} />
</WorkflowNodeIconContainer>
<WorkflowNodeRightPart>
<StyledNodeLabelWithCounterPart>
<StyledNodeLabel data-status={data.runStatus}>
{capitalize(data.nodeType)}
</StyledNodeLabel>
{data.runStatus === StepStatus.SUCCESS && (
<StyledNodeCounter>
<StyledColorIcon data-status={StepStatus.SUCCESS}>
<IconCheck color={theme.tag.text.turquoise} size={14} />
</StyledColorIcon>
</StyledNodeCounter>
)}
{data.runStatus === StepStatus.FAILED && (
<StyledNodeCounter>
<StyledColorIcon data-status={StepStatus.FAILED}>
<IconX color={theme.tag.text.red} size={14} />
</StyledColorIcon>
</StyledNodeCounter>
)}
{(data.runStatus === StepStatus.RUNNING ||
data.runStatus === StepStatus.PENDING) && (
<StyledNodeCounter>
<Loader color="yellow" />
</StyledNodeCounter>
)}
</StyledNodeLabelWithCounterPart>
<StyledNodeTitle data-status={data.runStatus}>
{data.name}
</StyledNodeTitle>
</WorkflowNodeRightPart>
</StyledNodeContainer>
<WorkflowDiagramHandleReadonly
type="source"
position={Position.Bottom}
selected={selected}
/>
</>
);
};