Compare commits

...

2 Commits

11 changed files with 1444 additions and 269 deletions

View File

@@ -1,7 +1,7 @@
import Icon, { ThickProp } from "../Icon/Icon";
import Pill from "../Pill/Pill";
import Tooltip from "../Tooltip/Tooltip";
import { Green } from "../../../Types/BrandColors";
import { Yellow } from "../../../Types/BrandColors";
import IconProp from "../../../Types/Icon/IconProp";
import {
ComponentType,
@@ -20,32 +20,85 @@ export interface ComponentProps {
const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
const [isHovering, setIsHovering] = useState<boolean>(false);
let textColor: string = "#6b7280";
let descriptionColor: string = "#6b7280";
const isTrigger: boolean =
props.data.metadata.componentType === ComponentType.Trigger;
const isPlaceholder: boolean =
props.data.nodeType === NodeType.PlaceholderNode;
const hasError: boolean = Boolean(
props.data.error && !props.data.isPreview && !isPlaceholder,
);
if (isHovering) {
textColor = "#111827";
descriptionColor = "#111827";
}
let componentStyle: React.CSSProperties = {
width: "15rem",
height: "10rem",
padding: "1rem",
borderColor: props.selected ? "#6366f1" : textColor,
alignItems: "center",
borderRadius: "0.25rem",
borderWidth: "2px",
backgroundColor: "white",
display: "inline-block",
verticalAlign: "middle",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
// Colors based on state
const getBorderColor = (): string => {
if (props.selected) {
return "#6366f1"; // indigo-500
}
if (hasError) {
return "#ef4444"; // red-500
}
if (isHovering) {
if (isPlaceholder) {
return "#a5b4fc"; // indigo-300
}
return "#818cf8"; // indigo-400
}
if (isPlaceholder) {
return "#cbd5e1"; // slate-300
}
if (isTrigger) {
return "#fbbf24"; // amber-400
}
return "#e2e8f0"; // slate-200
};
let handleStyle: React.CSSProperties = {
background: "#6b7280",
height: "0.75rem",
width: "0.75rem",
const getBackgroundGradient = (): string => {
if (isPlaceholder) {
return "linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)";
}
if (isTrigger) {
return "linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)";
}
return "linear-gradient(135deg, #ffffff 0%, #f8fafc 100%)";
};
const getIconBackgroundColor = (): string => {
if (isPlaceholder) {
return isHovering ? "#e0e7ff" : "#f1f5f9";
}
if (isTrigger) {
return "#fef3c7";
}
if (props.selected) {
return "#e0e7ff";
}
return isHovering ? "#e0e7ff" : "#f1f5f9";
};
const getIconColor = (): string => {
if (isPlaceholder) {
return isHovering ? "#6366f1" : "#94a3b8";
}
if (isTrigger) {
return "#d97706";
}
if (props.selected || isHovering) {
return "#6366f1";
}
return "#64748b";
};
const getTitleColor = (): string => {
if (isPlaceholder) {
return isHovering ? "#475569" : "#94a3b8";
}
return isHovering || props.selected ? "#1e293b" : "#334155";
};
const getDescriptionColor = (): string => {
if (isPlaceholder) {
return isHovering ? "#64748b" : "#cbd5e1";
}
return "#64748b";
};
type GetPortPositionFunction = (
@@ -60,7 +113,7 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
isLabel: boolean,
): React.CSSProperties => {
if (portCount === 1 && totalPorts === 1) {
return isLabel ? { left: 100 } : {};
return isLabel ? { left: 112 } : {};
}
if (portCount === 1 && totalPorts === 2) {
@@ -72,54 +125,37 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
}
if (portCount === 1 && totalPorts === 3) {
return { left: isLabel ? 70 : 80 };
return { left: isLabel ? 50 : 60 };
}
if (portCount === 2 && totalPorts === 3) {
return isLabel ? { left: 100 } : {};
return isLabel ? { left: 112 } : {};
}
if (portCount === 3 && totalPorts === 3) {
return { left: isLabel ? 150 : 160 };
return { left: isLabel ? 170 : 180 };
}
// default
return {};
};
if (props.data.nodeType === NodeType.PlaceholderNode) {
handleStyle = {
background: "#cbd5e1",
height: "0.75rem",
width: "0.75rem",
};
const handleStyle: React.CSSProperties = {
background: isPlaceholder ? "#cbd5e1" : "#6366f1",
height: "12px",
width: "12px",
border: "2px solid white",
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.2)",
transition: "transform 0.15s ease",
};
componentStyle = {
borderStyle: "dashed",
width: "15rem",
height: "8rem",
padding: "1rem",
display: "inline-block",
alignItems: "center",
verticalAlign: "middle",
borderColor: isHovering ? "#94a3b8" : "#cbd5e1",
borderRadius: "0.25rem",
borderWidth: "2px",
backgroundColor: "white",
};
textColor = "#cbd5e1";
descriptionColor = "#cbd5e1";
if (isHovering) {
textColor = "#94a3b8";
descriptionColor = "#94a3b8";
}
}
const handleHoverStyle: React.CSSProperties = {
...handleStyle,
transform: "scale(1.2)",
};
return (
<div
className="cursor-pointer"
className="cursor-pointer transition-all duration-200"
onMouseOver={() => {
setIsHovering(true);
}}
@@ -127,8 +163,20 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
setIsHovering(false);
}}
style={{
...componentStyle,
height: props.data.id ? "12rem" : "10rem",
width: "240px",
minHeight: isPlaceholder ? "120px" : props.data.id ? "160px" : "140px",
padding: "16px",
borderRadius: "12px",
borderWidth: isPlaceholder ? "2px" : "1px",
borderStyle: isPlaceholder ? "dashed" : "solid",
borderColor: getBorderColor(),
background: getBackgroundGradient(),
boxShadow: props.selected
? "0 0 0 3px rgba(99, 102, 241, 0.2), 0 10px 25px -5px rgba(0, 0, 0, 0.1)"
: isHovering
? "0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1)"
: "0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -2px rgba(0, 0, 0, 0.05)",
transform: isHovering && !isPlaceholder ? "translateY(-2px)" : "none",
}}
onClick={() => {
if (props.data.onClick) {
@@ -136,42 +184,35 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
}
}}
>
<div className="flex justify-center">
{props.data.metadata.componentType === ComponentType.Trigger &&
props.data.nodeType !== NodeType.PlaceholderNode &&
!props.data.isPreview && <Pill text="Trigger" color={Green} />}
</div>
{!props.data.isPreview &&
props.data.error &&
props.data.nodeType !== NodeType.PlaceholderNode && (
<div
style={{
width: "20px",
height: "20px",
borderRadius: "100px",
color: "#ef4444",
position: "absolute",
top: "0px",
left: "220px",
cursor: "pointer",
}}
onClick={() => {}}
>
<Icon
icon={IconProp.Alert}
style={{
color: "#ef4444",
width: "1rem",
height: "1rem",
textAlign: "center",
margin: "auto",
marginTop: "2px",
}}
thick={ThickProp.Thick}
/>
</div>
{/* Badge area */}
<div className="flex justify-between items-start mb-2">
<div>
{isTrigger && !isPlaceholder && !props.data.isPreview && (
<Pill text="Trigger" color={Yellow} />
)}
</div>
{/* Error indicator */}
{hasError && (
<Tooltip text={props.data.error || "Error in component"}>
<div
className="flex items-center justify-center w-6 h-6 rounded-full bg-red-100"
style={{ cursor: "help" }}
>
<Icon
icon={IconProp.Alert}
style={{
color: "#ef4444",
width: "14px",
height: "14px",
}}
thick={ThickProp.Thick}
/>
</div>
</Tooltip>
)}
</div>
{/* Input ports (top) */}
{!props.data.isPreview &&
props.data.metadata.componentType !== ComponentType.Trigger && (
<div>
@@ -187,7 +228,7 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
isConnectable={true}
position={Position.Top}
style={{
...handleStyle,
...(isHovering ? handleHoverStyle : handleStyle),
...getPortPosition(
i + 1,
props.data.metadata.inPorts.length,
@@ -200,120 +241,118 @@ const Node: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
</div>
)}
<div
style={{
width: "100%",
display: "flex",
justifyContent: "center",
}}
>
<div
style={{
margin: "auto",
marginTop: props.data.metadata.iconProp ? "0.5rem" : "1rem",
}}
>
{props.data.metadata.iconProp && (
{/* Icon and content */}
<div className="flex flex-col items-center text-center">
{/* Icon container */}
{props.data.metadata.iconProp && (
<div
className="flex items-center justify-center rounded-xl mb-3 transition-colors duration-200"
style={{
width: "48px",
height: "48px",
backgroundColor: getIconBackgroundColor(),
}}
>
<Icon
icon={props.data.metadata.iconProp}
style={{
color: textColor,
width: "1.5rem",
height: "1.5rem",
textAlign: "center",
margin: "auto",
color: getIconColor(),
width: "24px",
height: "24px",
}}
/>
)}
</div>
)}
{/* Title */}
<p
className="font-semibold text-sm leading-tight mb-1 transition-colors duration-200"
style={{ color: getTitleColor() }}
>
{props.data.metadata.title}
</p>
{/* Component ID */}
{!props.data.isPreview && props.data.id && (
<p
style={{
color: textColor,
fontSize: "0.875rem",
lineHeight: "1.25rem",
textAlign: "center",
marginTop: "6px",
}}
className="text-xs mb-1 font-mono"
style={{ color: "#94a3b8" }}
>
{props.data.metadata.title}
{props.data.id}
</p>
{!props.data.isPreview && props.data.id && (
<p
style={{
color: descriptionColor,
fontSize: "0.875rem",
textAlign: "center",
}}
>
({props.data.id.trim()})
</p>
)}
<p
style={{
color: descriptionColor,
fontSize: "0.775rem",
lineHeight: "1.0rem",
textAlign: "center",
marginTop: "6px",
}}
>
{props.data.metadata.description}
</p>
</div>
)}
{/* Description */}
<p
className="text-xs leading-tight transition-colors duration-200"
style={{
color: getDescriptionColor(),
maxWidth: "200px",
overflow: "hidden",
textOverflow: "ellipsis",
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
}}
>
{props.data.metadata.description}
</p>
</div>
{!props.data.isPreview &&
props.data.nodeType !== NodeType.PlaceholderNode && (
<>
<div>
{props.data.metadata.outPorts &&
props.data.metadata.outPorts.length > 0 &&
props.data.metadata.outPorts.map((port: Port, i: number) => {
return (
<Handle
key={i}
type="source"
id={port.id}
onConnect={(_params: Connection) => {}}
isConnectable={true}
position={Position.Bottom}
{/* Output ports (bottom) */}
{!props.data.isPreview && !isPlaceholder && (
<>
<div>
{props.data.metadata.outPorts &&
props.data.metadata.outPorts.length > 0 &&
props.data.metadata.outPorts.map((port: Port, i: number) => {
return (
<Handle
key={i}
type="source"
id={port.id}
onConnect={(_params: Connection) => {}}
isConnectable={true}
position={Position.Bottom}
style={{
...(isHovering ? handleHoverStyle : handleStyle),
...getPortPosition(
i + 1,
props.data.metadata.outPorts.length,
false,
),
}}
/>
);
})}
</div>
{/* Port labels */}
<div>
{props.data.metadata.outPorts &&
props.data.metadata.outPorts.length > 1 &&
props.data.metadata.outPorts.map((port: Port, i: number) => {
return (
<Tooltip key={i} text={port.description || ""}>
<div
className="absolute text-xs font-medium"
style={{
...handleStyle,
bottom: "8px",
color: "#94a3b8",
...getPortPosition(
i + 1,
props.data.metadata.outPorts.length,
false,
true,
),
}}
/>
);
})}
</div>
<div>
{props.data.metadata.outPorts &&
props.data.metadata.outPorts.length > 0 &&
props.data.metadata.outPorts.map((port: Port, i: number) => {
return (
<Tooltip key={i} text={port.description || ""}>
<div
key={i}
className="text-sm text-gray-400 absolute"
style={{
bottom: "10px",
...getPortPosition(
i + 1,
props.data.metadata.outPorts.length,
true,
),
}}
>
{port.title}
</div>
</Tooltip>
);
})}
</div>
</>
)}
>
{port.title}
</div>
</Tooltip>
);
})}
</div>
</>
)}
</div>
);
};

View File

@@ -0,0 +1,69 @@
import Icon from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import ComponentMetadata, {
ComponentCategory,
} from "../../../Types/Workflow/Component";
import DraggableComponentItem from "./DraggableComponentItem";
import React, { FunctionComponent, useState } from "react";
export interface ComponentProps {
category: ComponentCategory;
components: Array<ComponentMetadata>;
defaultExpanded?: boolean;
}
const ComponentCategorySection: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const [isExpanded, setIsExpanded] = useState<boolean>(
props.defaultExpanded ?? true,
);
const componentCount: number = props.components.length;
if (componentCount === 0) {
return <></>;
}
return (
<div className="mb-4">
<button
onClick={() => {
setIsExpanded(!isExpanded);
}}
className="w-full flex items-center justify-between p-2 rounded-md hover:bg-gray-100 transition-colors"
>
<div className="flex items-center gap-2">
<Icon
icon={props.category.icon}
className="w-4 h-4 text-gray-500"
/>
<span className="text-sm font-medium text-gray-700">
{props.category.name}
</span>
<span className="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded-full">
{componentCount}
</span>
</div>
<Icon
icon={isExpanded ? IconProp.ChevronDown : IconProp.ChevronRight}
className="w-4 h-4 text-gray-400"
/>
</button>
{isExpanded && (
<div className="mt-2 ml-2">
{props.components.map(
(component: ComponentMetadata, index: number) => {
return (
<DraggableComponentItem key={index} component={component} />
);
},
)}
</div>
)}
</div>
);
};
export default ComponentCategorySection;

View File

@@ -0,0 +1,80 @@
import Icon from "../Icon/Icon";
import ComponentMetadata from "../../../Types/Workflow/Component";
import React, { FunctionComponent, useState } from "react";
export interface ComponentProps {
component: ComponentMetadata;
}
const DraggableComponentItem: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const [isHovering, setIsHovering] = useState<boolean>(false);
const [isDragging, setIsDragging] = useState<boolean>(false);
const handleDragStart = (event: React.DragEvent<HTMLDivElement>): void => {
event.dataTransfer.setData(
"application/reactflow",
JSON.stringify({
componentId: props.component.id,
componentType: props.component.componentType,
}),
);
event.dataTransfer.effectAllowed = "move";
setIsDragging(true);
};
const handleDragEnd = (): void => {
setIsDragging(false);
};
return (
<div
draggable
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onMouseOver={() => {
setIsHovering(true);
}}
onMouseOut={() => {
setIsHovering(false);
}}
className={`
flex items-center gap-3 p-3 rounded-md cursor-grab
border border-gray-200 bg-white
transition-all duration-150 ease-in-out
${isHovering ? "border-indigo-400 shadow-md" : ""}
${isDragging ? "opacity-50 cursor-grabbing" : ""}
`}
style={{
marginBottom: "0.5rem",
}}
>
{props.component.iconProp && (
<div
className={`
flex-shrink-0 w-8 h-8 rounded-md flex items-center justify-center
${isHovering ? "bg-indigo-100" : "bg-gray-100"}
`}
>
<Icon
icon={props.component.iconProp}
className={`w-4 h-4 ${isHovering ? "text-indigo-600" : "text-gray-500"}`}
/>
</div>
)}
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${isHovering ? "text-gray-900" : "text-gray-700"}`}
>
{props.component.title}
</p>
<p className="text-xs text-gray-400 truncate">
{props.component.description}
</p>
</div>
</div>
);
};
export default DraggableComponentItem;

View File

@@ -2,10 +2,10 @@ import WorkflowComponent from "./Component";
import ComponentSettingsModal from "./ComponentSettingsModal";
import ComponentsModal from "./ComponentsModal";
import RunModal from "./RunModal";
import WorkflowEmptyState from "./WorkflowEmptyState";
import { loadComponentsAndCategories } from "./Utils";
import { VoidFunction } from "../../../Types/FunctionTypes";
import IconProp from "../../../Types/Icon/IconProp";
import { JSONObject } from "../../../Types/JSON";
import ObjectID from "../../../Types/ObjectID";
import ComponentMetadata, {
ComponentCategory,
@@ -22,7 +22,9 @@ import React, {
} from "react";
import ReactFlow, {
Background,
BackgroundVariant,
Connection,
ConnectionLineType,
Controls,
Edge,
MarkerType,
@@ -31,14 +33,17 @@ import ReactFlow, {
NodeTypes,
OnConnect,
ProOptions,
ReactFlowProvider,
addEdge,
getConnectedEdges,
updateEdge,
useEdgesState,
useNodesState,
useReactFlow,
} from "reactflow";
// 👇 you need to import the reactflow styles
import "reactflow/dist/style.css";
import "./WorkflowCanvas.css";
type GetPlaceholderTriggerNodeFunction = () => Node;
@@ -75,23 +80,59 @@ const edgeStyle: React.CSSProperties = {
};
const selectedEdgeStyle: React.CSSProperties = {
strokeWidth: "2px",
stroke: "#818cf8",
color: "#818cf8",
strokeWidth: "3px",
stroke: "#6366f1",
color: "#6366f1",
};
type GetEdgeDefaultPropsFunction = (selected: boolean) => JSONObject;
const animatedEdgeStyle: React.CSSProperties = {
strokeWidth: "2px",
stroke: "#10b981",
color: "#10b981",
};
interface EdgeDefaultProps {
type: string;
markerEnd: {
type: MarkerType;
color: string;
width: number;
height: number;
};
style: React.CSSProperties;
animated: boolean;
}
type GetEdgeDefaultPropsFunction = (
selected: boolean,
animated?: boolean,
) => EdgeDefaultProps;
export const getEdgeDefaultProps: GetEdgeDefaultPropsFunction = (
selected: boolean,
): JSONObject => {
animated?: boolean,
): EdgeDefaultProps => {
let style: React.CSSProperties = edgeStyle;
let markerColor: string = edgeStyle.color?.toString() || "#94a3b8";
if (selected) {
style = selectedEdgeStyle;
markerColor = selectedEdgeStyle.color?.toString() || "#6366f1";
} else if (animated) {
style = animatedEdgeStyle;
markerColor = animatedEdgeStyle.color?.toString() || "#10b981";
}
return {
type: "smoothstep",
markerEnd: {
type: MarkerType.Arrow,
color: edgeStyle.color?.toString() || "",
type: MarkerType.ArrowClosed,
color: markerColor,
width: 20,
height: 20,
},
style: selected ? { ...selectedEdgeStyle } : { ...edgeStyle },
style,
animated: animated || false,
};
};
@@ -105,26 +146,20 @@ export interface ComponentProps {
workflowId: ObjectID;
onRunModalUpdate: (isModalShown: boolean) => void;
onRun: (trigger: NodeDataProp) => void;
allComponentMetadata?: Array<ComponentMetadata>;
}
const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
const [allComponentMetadata, setAllComponentMetadata] = useState<
Array<ComponentMetadata>
>([]);
const [allComponentCategories, setAllComponentCategories] = useState<
Array<ComponentCategory>
>([]);
useEffect(() => {
const value: {
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
} = loadComponentsAndCategories();
setAllComponentCategories(value.categories);
setAllComponentMetadata(value.components);
}, []);
// Inner component that uses useReactFlow hook
interface WorkflowInnerProps extends ComponentProps {
allComponentMetadataLoaded: Array<ComponentMetadata>;
allComponentCategoriesLoaded: Array<ComponentCategory>;
}
const WorkflowInner: FunctionComponent<WorkflowInnerProps> = (
props: WorkflowInnerProps,
) => {
const reactFlowWrapper = useRef<HTMLDivElement>(null);
const reactFlowInstance = useReactFlow();
const edgeUpdateSuccessful: any = useRef(true);
const [showComponentSettingsModal, setShowComponentSettingsModal] =
useState<boolean>(false);
@@ -334,12 +369,17 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
props.onRunModalUpdate(showRunModal);
}, [showRunModal]);
type AddToGraphFunction = (componentMetadata: ComponentMetadata) => void;
type AddToGraphFunction = (
componentMetadata: ComponentMetadata,
position?: { x: number; y: number },
) => void;
const addToGraph: AddToGraphFunction = (
componentMetadata: ComponentMetadata,
position?: { x: number; y: number },
) => {
const metaDataId: string = componentMetadata.id;
const nodePosition: { x: number; y: number } = position || { x: 200, y: 200 };
let hasFoundExistingId: boolean = true;
let idCounter: number = 1;
@@ -361,7 +401,7 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
const compToAdd: Node = {
id: ObjectID.generate().toString(), // react-flow id
type: "node",
position: { x: 200, y: 200 },
position: nodePosition,
selected: true,
data: {
nodeType: NodeType.Node,
@@ -397,12 +437,120 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
}
};
const [isDragOver, setIsDragOver] = useState<boolean>(false);
// Drag and drop handlers for sidebar components
const onDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
setIsDragOver(true);
}, []);
const onDragLeave = useCallback(() => {
setIsDragOver(false);
}, []);
const onDrop = useCallback(
(event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const dataStr: string = event.dataTransfer.getData("application/reactflow");
if (!dataStr) {
return;
}
try {
const data: { componentId: string; componentType: ComponentType } =
JSON.parse(dataStr);
// Find the component metadata
const componentMetadata: ComponentMetadata | undefined = (
props.allComponentMetadataLoaded || []
).find((c: ComponentMetadata) => {
return c.id === data.componentId;
});
if (!componentMetadata) {
return;
}
// Get the position relative to the canvas
const bounds: DOMRect | undefined =
reactFlowWrapper.current?.getBoundingClientRect();
if (!bounds) {
return;
}
const position: { x: number; y: number } =
reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addToGraph(componentMetadata, position);
} catch (e) {
// Invalid JSON data
}
},
[props.allComponentMetadataLoaded, reactFlowInstance, addToGraph],
);
// Custom minimap node color
const minimapNodeColor = (node: Node): string => {
if (node.data?.nodeType === NodeType.PlaceholderNode) {
return "#e2e8f0";
}
if (node.data?.componentType === ComponentType.Trigger) {
return "#fbbf24";
}
return "#6366f1";
};
// Check if workflow is essentially empty (only has placeholder or single trigger)
const isWorkflowEmpty: boolean =
nodes.length === 0 ||
(nodes.length === 1 &&
nodes[0]?.data?.nodeType === NodeType.PlaceholderNode);
const hasTrigger: boolean = nodes.some((node: Node) => {
return (
node.data?.componentType === ComponentType.Trigger &&
node.data?.nodeType === NodeType.Node
);
});
return (
<div className="h-[48rem]">
<div
className={`h-full relative transition-all duration-200 ${isDragOver ? "ring-2 ring-inset ring-indigo-400" : ""}`}
ref={reactFlowWrapper}
onDragLeave={onDragLeave}
>
{/* Drop zone overlay */}
{isDragOver && (
<div className="absolute inset-0 bg-indigo-50/30 z-10 pointer-events-none flex items-center justify-center">
<div className="bg-white/90 px-6 py-3 rounded-lg shadow-lg border-2 border-dashed border-indigo-400">
<p className="text-indigo-600 font-medium text-sm">
Drop component here
</p>
</div>
</div>
)}
{/* Empty state hint */}
{isWorkflowEmpty && !isDragOver && (
<WorkflowEmptyState hasTrigger={hasTrigger} />
)}
<ReactFlow
nodes={nodes}
edges={edges}
fitView={true}
fitViewOptions={{
padding: 0.2,
minZoom: 0.5,
maxZoom: 1.5,
}}
onEdgeClick={() => {
refreshEdges();
}}
@@ -418,10 +566,48 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
nodeTypes={nodeTypes}
onEdgeUpdateStart={onEdgeUpdateStart}
onEdgeUpdateEnd={onEdgeUpdateEnd}
onDrop={onDrop}
onDragOver={onDragOver}
defaultEdgeOptions={{
type: "smoothstep",
style: edgeStyle,
}}
connectionLineStyle={{
stroke: "#6366f1",
strokeWidth: 2,
}}
connectionLineType={ConnectionLineType.SmoothStep}
snapToGrid={true}
snapGrid={[15, 15]}
minZoom={0.2}
maxZoom={2}
>
<MiniMap />
<Controls />
<Background color="#111827" />
<MiniMap
nodeColor={minimapNodeColor}
maskColor="rgba(0, 0, 0, 0.1)"
style={{
backgroundColor: "#f8fafc",
border: "1px solid #e2e8f0",
borderRadius: "8px",
}}
zoomable
pannable
/>
<Controls
style={{
display: "flex",
flexDirection: "column",
gap: "4px",
}}
showInteractive={false}
/>
<Background
variant={BackgroundVariant.Dots}
gap={20}
size={1}
color="#d1d5db"
style={{ backgroundColor: "#f9fafb" }}
/>
</ReactFlow>
{showComponentsModal && (
@@ -430,10 +616,12 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
onCloseModal={() => {
setShowComponentsModal(false);
}}
categories={allComponentCategories}
components={allComponentMetadata.filter((comp: ComponentMetadata) => {
return comp.componentType === ComponentType.Component;
})}
categories={props.allComponentCategoriesLoaded}
components={props.allComponentMetadataLoaded.filter(
(comp: ComponentMetadata) => {
return comp.componentType === ComponentType.Component;
},
)}
onComponentClick={(component: ComponentMetadata) => {
setShowComponentsModal(false);
@@ -448,10 +636,12 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
onCloseModal={() => {
setShowTriggersModal(false);
}}
categories={allComponentCategories}
components={allComponentMetadata.filter((comp: ComponentMetadata) => {
return comp.componentType === ComponentType.Trigger;
})}
categories={props.allComponentCategoriesLoaded}
components={props.allComponentMetadataLoaded.filter(
(comp: ComponentMetadata) => {
return comp.componentType === ComponentType.Trigger;
},
)}
onComponentClick={(component: ComponentMetadata) => {
setShowTriggersModal(false);
@@ -522,4 +712,39 @@ const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
);
};
// Main Workflow component that wraps everything in ReactFlowProvider
const Workflow: FunctionComponent<ComponentProps> = (props: ComponentProps) => {
const [allComponentMetadata, setAllComponentMetadata] = useState<
Array<ComponentMetadata>
>([]);
const [allComponentCategories, setAllComponentCategories] = useState<
Array<ComponentCategory>
>([]);
useEffect(() => {
const value: {
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
} = loadComponentsAndCategories();
setAllComponentCategories(value.categories);
setAllComponentMetadata(value.components);
}, []);
return (
<ReactFlowProvider>
<WorkflowInner
{...props}
allComponentMetadataLoaded={
props.allComponentMetadata || allComponentMetadata
}
allComponentCategoriesLoaded={allComponentCategories}
/>
</ReactFlowProvider>
);
};
export default Workflow;
// Export for use in WorkflowBuilderLayout
export { loadComponentsAndCategories };

View File

@@ -0,0 +1,81 @@
import Button, { ButtonStyleType } from "../Button/Button";
import IconProp from "../../../Types/Icon/IconProp";
import ComponentMetadata, {
ComponentCategory,
ComponentType,
NodeDataProp,
} from "../../../Types/Workflow/Component";
import WorkflowComponentsSidebar from "./WorkflowComponentsSidebar";
import React, { FunctionComponent, ReactElement } from "react";
import { Node } from "reactflow";
export interface ComponentProps {
children: ReactElement;
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
nodes: Array<Node>;
saveStatus: string;
onRunWorkflow: () => void;
onAutoLayout: () => void;
isAutoLayouting?: boolean;
}
const WorkflowBuilderLayout: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
// Check if workflow has a real trigger (not placeholder)
const hasTrigger: boolean = props.nodes.some((node: Node) => {
const data: NodeDataProp = node.data as NodeDataProp;
return (
data.componentType === ComponentType.Trigger &&
data.metadataId !== "" &&
data.id !== ""
);
});
return (
<div className="flex h-[52rem] border border-gray-200 rounded-lg overflow-hidden bg-white shadow-sm">
{/* Left Sidebar */}
<WorkflowComponentsSidebar
components={props.components}
categories={props.categories}
hasTrigger={hasTrigger}
/>
{/* Main Content Area */}
<div className="flex-1 flex flex-col">
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 bg-gray-50">
<div className="flex items-center gap-4">
<h2 className="text-sm font-medium text-gray-700">
Workflow Canvas
</h2>
{props.saveStatus && (
<span className="text-xs text-gray-400">{props.saveStatus}</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
title="Auto Layout"
icon={IconProp.TableCells}
buttonStyle={ButtonStyleType.OUTLINE}
onClick={props.onAutoLayout}
disabled={props.isAutoLayouting}
/>
<Button
title="Run Workflow"
icon={IconProp.Play}
buttonStyle={ButtonStyleType.PRIMARY}
onClick={props.onRunWorkflow}
/>
</div>
</div>
{/* Canvas Area */}
<div className="flex-1 relative">{props.children}</div>
</div>
</div>
);
};
export default WorkflowBuilderLayout;

View File

@@ -0,0 +1,115 @@
/* Custom styles for ReactFlow workflow canvas */
/* Controls styling */
.react-flow__controls {
background: white;
border-radius: 8px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
border: 1px solid #e2e8f0;
overflow: hidden;
}
.react-flow__controls-button {
background: white;
border: none;
border-bottom: 1px solid #e2e8f0;
padding: 8px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background-color 0.15s ease;
}
.react-flow__controls-button:last-child {
border-bottom: none;
}
.react-flow__controls-button:hover {
background: #f1f5f9;
}
.react-flow__controls-button svg {
fill: #64748b;
width: 14px;
height: 14px;
}
.react-flow__controls-button:hover svg {
fill: #334155;
}
/* MiniMap styling */
.react-flow__minimap {
border-radius: 8px !important;
overflow: hidden;
}
/* Edge animation */
.react-flow__edge.animated path {
stroke-dasharray: 5;
animation: dashdraw 0.5s linear infinite;
}
@keyframes dashdraw {
from {
stroke-dashoffset: 10;
}
to {
stroke-dashoffset: 0;
}
}
/* Connection line while dragging */
.react-flow__connection-line {
stroke: #6366f1;
stroke-width: 2px;
}
/* Handle (port) hover effects */
.react-flow__handle:hover {
transform: scale(1.3);
}
/* Selection box */
.react-flow__selection {
background: rgba(99, 102, 241, 0.08);
border: 1px dashed #6366f1;
}
/* Node selection */
.react-flow__node.selected {
box-shadow: 0 0 0 2px #6366f1;
}
/* Attribution (hidden) */
.react-flow__attribution {
display: none;
}
/* Panel styling */
.react-flow__panel {
margin: 12px;
}
/* Smooth transitions */
.react-flow__node {
transition: transform 0.1s ease, box-shadow 0.15s ease;
}
/* Edge path */
.react-flow__edge-path {
transition: stroke 0.15s ease, stroke-width 0.15s ease;
}
/* Edge interaction path (wider for easier clicking) */
.react-flow__edge-interaction {
stroke-width: 20px;
}
/* Background pattern enhancement */
.react-flow__background {
opacity: 1;
}

View File

@@ -0,0 +1,229 @@
import Icon from "../Icon/Icon";
import Input from "../Input/Input";
import IconProp from "../../../Types/Icon/IconProp";
import ComponentMetadata, {
ComponentCategory,
ComponentType,
} from "../../../Types/Workflow/Component";
import ComponentCategorySection from "./ComponentCategorySection";
import React, { FunctionComponent, useEffect, useState } from "react";
export interface ComponentProps {
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
onTriggerSelect?: (component: ComponentMetadata) => void;
hasTrigger: boolean;
}
const WorkflowComponentsSidebar: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const [isCollapsed, setIsCollapsed] = useState<boolean>(false);
const [filteredComponents, setFilteredComponents] = useState<
Array<ComponentMetadata>
>([]);
// Separate triggers and components
const triggers: Array<ComponentMetadata> = props.components.filter(
(c: ComponentMetadata) => {
return c.componentType === ComponentType.Trigger;
},
);
const components: Array<ComponentMetadata> = props.components.filter(
(c: ComponentMetadata) => {
return c.componentType === ComponentType.Component;
},
);
useEffect(() => {
if (!searchTerm.trim()) {
setFilteredComponents(props.components);
return;
}
const searchLower: string = searchTerm.toLowerCase().trim();
const filtered: Array<ComponentMetadata> = props.components.filter(
(component: ComponentMetadata) => {
return (
component.title.toLowerCase().includes(searchLower) ||
component.description.toLowerCase().includes(searchLower) ||
component.category.toLowerCase().includes(searchLower)
);
},
);
setFilteredComponents(filtered);
}, [searchTerm, props.components]);
// Get filtered components by type
const getFilteredByType = (
type: ComponentType,
): Array<ComponentMetadata> => {
return filteredComponents.filter((c: ComponentMetadata) => {
return c.componentType === type;
});
};
// Get components for a specific category
const getComponentsForCategory = (
categoryName: string,
type: ComponentType,
): Array<ComponentMetadata> => {
return getFilteredByType(type).filter((c: ComponentMetadata) => {
return c.category === categoryName;
});
};
// Get unique categories that have components
const getCategoriesWithComponents = (
type: ComponentType,
): Array<ComponentCategory> => {
const componentsOfType: Array<ComponentMetadata> = getFilteredByType(type);
const categoryNames: Set<string> = new Set(
componentsOfType.map((c: ComponentMetadata) => {
return c.category;
}),
);
return props.categories.filter((cat: ComponentCategory) => {
return categoryNames.has(cat.name);
});
};
if (isCollapsed) {
return (
<div className="w-12 bg-gray-50 border-r border-gray-200 flex flex-col items-center py-4">
<button
onClick={() => {
setIsCollapsed(false);
}}
className="p-2 rounded-md hover:bg-gray-200 transition-colors"
title="Expand sidebar"
>
<Icon icon={IconProp.ChevronRight} className="w-5 h-5 text-gray-600" />
</button>
<div className="mt-4 flex flex-col gap-2">
<div
className="p-2 rounded-md bg-gray-200"
title="Drag components from sidebar"
>
<Icon icon={IconProp.Cube} className="w-5 h-5 text-gray-600" />
</div>
</div>
</div>
);
}
return (
<div className="w-72 bg-gray-50 border-r border-gray-200 flex flex-col h-full">
{/* Header */}
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold text-gray-800">Components</h3>
<button
onClick={() => {
setIsCollapsed(true);
}}
className="p-1 rounded hover:bg-gray-200 transition-colors"
title="Collapse sidebar"
>
<Icon icon={IconProp.ChevronLeft} className="w-4 h-4 text-gray-500" />
</button>
</div>
<Input
placeholder="Search components..."
value={searchTerm}
onChange={(value: string) => {
setSearchTerm(value);
}}
/>
</div>
{/* Drag hint */}
<div className="px-4 py-2 bg-indigo-50 border-b border-indigo-100">
<p className="text-xs text-indigo-600 flex items-center gap-1">
<Icon icon={IconProp.Info} className="w-3 h-3" />
Drag components onto the canvas
</p>
</div>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-4">
{/* Triggers section - only show if no trigger exists */}
{!props.hasTrigger && triggers.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Icon icon={IconProp.Bolt} className="w-4 h-4 text-amber-500" />
<h4 className="text-sm font-medium text-gray-700">
Triggers
</h4>
<span className="text-xs text-gray-400">
(Start your workflow)
</span>
</div>
{getCategoriesWithComponents(ComponentType.Trigger).length > 0 ? (
getCategoriesWithComponents(ComponentType.Trigger).map(
(category: ComponentCategory, index: number) => {
return (
<ComponentCategorySection
key={index}
category={category}
components={getComponentsForCategory(
category.name,
ComponentType.Trigger,
)}
defaultExpanded={true}
/>
);
},
)
) : (
<p className="text-sm text-gray-400 text-center py-4">
No triggers found
</p>
)}
</div>
)}
{/* Components section */}
<div>
<div className="flex items-center gap-2 mb-3">
<Icon icon={IconProp.Cube} className="w-4 h-4 text-indigo-500" />
<h4 className="text-sm font-medium text-gray-700">
Actions
</h4>
</div>
{getCategoriesWithComponents(ComponentType.Component).length > 0 ? (
getCategoriesWithComponents(ComponentType.Component).map(
(category: ComponentCategory, index: number) => {
return (
<ComponentCategorySection
key={index}
category={category}
components={getComponentsForCategory(
category.name,
ComponentType.Component,
)}
defaultExpanded={index < 3}
/>
);
},
)
) : (
<p className="text-sm text-gray-400 text-center py-4">
No components match your search
</p>
)}
</div>
</div>
{/* Footer with count */}
<div className="p-3 border-t border-gray-200 bg-gray-100">
<p className="text-xs text-gray-500 text-center">
{components.length} components available
</p>
</div>
</div>
);
};
export default WorkflowComponentsSidebar;

View File

@@ -0,0 +1,68 @@
import Icon from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent } from "react";
export interface ComponentProps {
hasTrigger: boolean;
}
const WorkflowEmptyState: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
return (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="text-center max-w-md px-8">
{/* Animated background circles */}
<div className="relative mb-6">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-32 h-32 rounded-full bg-indigo-50 animate-pulse" />
</div>
<div className="absolute inset-0 flex items-center justify-center">
<div
className="w-24 h-24 rounded-full bg-indigo-100"
style={{ animationDelay: "150ms" }}
/>
</div>
<div className="relative flex items-center justify-center">
<div className="w-16 h-16 rounded-full bg-indigo-500 flex items-center justify-center shadow-lg">
<Icon
icon={props.hasTrigger ? IconProp.Cube : IconProp.Bolt}
className="w-8 h-8 text-white"
/>
</div>
</div>
</div>
{/* Title and description */}
<h3 className="text-lg font-semibold text-gray-800 mb-2">
{props.hasTrigger
? "Add your first component"
: "Start with a trigger"}
</h3>
<p className="text-sm text-gray-500 mb-6">
{props.hasTrigger
? "Drag components from the sidebar onto the canvas to build your workflow automation."
: "Click the trigger placeholder above or drag a trigger from the sidebar to start building your workflow."}
</p>
{/* Visual hints */}
<div className="flex items-center justify-center gap-6 text-xs text-gray-400">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center">
<Icon icon={IconProp.CursorArrowRays} className="w-3 h-3 text-gray-400" />
</div>
<span>Click to configure</span>
</div>
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded bg-gray-100 flex items-center justify-center">
<Icon icon={IconProp.Drag} className="w-3 h-3 text-gray-400" />
</div>
<span>Drag to connect</span>
</div>
</div>
</div>
</div>
);
};
export default WorkflowEmptyState;

View File

@@ -0,0 +1,104 @@
import Button, { ButtonStyleType } from "../Button/Button";
import Icon from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
import React, { FunctionComponent, useEffect, useState } from "react";
const STORAGE_KEY: string = "oneuptime_workflow_onboarding_dismissed";
export interface ComponentProps {
isWorkflowEmpty: boolean;
}
const WorkflowOnboarding: FunctionComponent<ComponentProps> = (
props: ComponentProps,
) => {
const [isDismissed, setIsDismissed] = useState<boolean>(true);
useEffect(() => {
const dismissed: string | null = localStorage.getItem(STORAGE_KEY);
setIsDismissed(dismissed === "true");
}, []);
const handleDismiss = (): void => {
localStorage.setItem(STORAGE_KEY, "true");
setIsDismissed(true);
};
if (isDismissed || !props.isWorkflowEmpty) {
return null;
}
return (
<div className="absolute inset-0 bg-gray-900/50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full mx-4 p-6">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center">
<Icon icon={IconProp.Bolt} className="w-5 h-5 text-indigo-600" />
</div>
<h2 className="text-lg font-semibold text-gray-900">
Welcome to the Workflow Builder
</h2>
</div>
<p className="text-sm text-gray-600 mb-6">
Build automated workflows by connecting components together. Here is how
to get started:
</p>
<div className="space-y-4 mb-6">
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-amber-100 flex items-center justify-center text-xs font-medium text-amber-700">
1
</div>
<div>
<p className="text-sm font-medium text-gray-800">
Add a Trigger
</p>
<p className="text-xs text-gray-500">
Click the trigger placeholder or drag a trigger from the sidebar
</p>
</div>
</div>
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-indigo-100 flex items-center justify-center text-xs font-medium text-indigo-700">
2
</div>
<div>
<p className="text-sm font-medium text-gray-800">
Drag Components
</p>
<p className="text-xs text-gray-500">
Drag components from the left sidebar onto the canvas
</p>
</div>
</div>
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-green-100 flex items-center justify-center text-xs font-medium text-green-700">
3
</div>
<div>
<p className="text-sm font-medium text-gray-800">
Connect & Configure
</p>
<p className="text-xs text-gray-500">
Connect components by dragging between ports and click to configure
</p>
</div>
</div>
</div>
<div className="flex justify-end">
<Button
title="Got it!"
buttonStyle={ButtonStyleType.PRIMARY}
onClick={handleDismiss}
/>
</div>
</div>
</div>
);
};
export default WorkflowOnboarding;

View File

@@ -0,0 +1,140 @@
import type { ElkExtendedEdge, ElkNode, LayoutOptions } from "elkjs";
import ELK from "elkjs/lib/elk.bundled.js";
import { Edge, Node } from "reactflow";
// Minimal interface for the ELK layout engine
interface ElkLayoutEngine {
layout: (graph: ElkNode) => Promise<ElkNode>;
}
// Node dimensions based on Component.tsx styling
const NODE_WIDTH: number = 240; // 15rem = 240px
const NODE_HEIGHT: number = 192; // 12rem = 192px
export interface LayoutResult {
nodes: Array<Node>;
edges: Array<Edge>;
}
export async function applyAutoLayout(
nodes: Array<Node>,
edges: Array<Edge>,
): Promise<LayoutResult> {
const elk: ElkLayoutEngine = new ELK();
// Build ELK graph structure
const elkNodes: Array<{
id: string;
width: number;
height: number;
}> = nodes.map((node: Node) => {
return {
id: node.id,
width: NODE_WIDTH,
height: NODE_HEIGHT,
};
});
const elkEdges: Array<ElkExtendedEdge> = edges.map((edge: Edge) => {
return {
id: edge.id,
sources: [edge.source],
targets: [edge.target],
};
});
const layoutOptions: LayoutOptions = {
"elk.algorithm": "layered",
"elk.direction": "DOWN",
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
"elk.spacing.nodeNode": "80",
"elk.edgeRouting": "ORTHOGONAL",
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
};
const elkGraph: ElkNode = {
id: "root",
layoutOptions,
children: elkNodes,
edges: elkEdges,
};
try {
const layoutedGraph: ElkNode = await elk.layout(elkGraph);
// Map back to ReactFlow nodes with new positions
const layoutedNodes: Array<Node> = nodes.map((node: Node) => {
const elkNode:
| {
id: string;
x?: number;
y?: number;
width?: number;
height?: number;
}
| undefined = layoutedGraph.children?.find(
(n: { id: string }) => {
return n.id === node.id;
},
);
if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) {
return {
...node,
position: {
x: elkNode.x,
y: elkNode.y,
},
};
}
return node;
});
return { nodes: layoutedNodes, edges };
} catch (error) {
// If layout fails, return original nodes
return { nodes, edges };
}
}
// Helper to center the layout around a specific point
export function centerLayout(
nodes: Array<Node>,
centerX: number = 400,
centerY: number = 100,
): Array<Node> {
if (nodes.length === 0) {
return nodes;
}
// Find current bounds
let minX: number = Infinity;
let minY: number = Infinity;
let maxX: number = -Infinity;
let maxY: number = -Infinity;
nodes.forEach((node: Node) => {
minX = Math.min(minX, node.position.x);
minY = Math.min(minY, node.position.y);
maxX = Math.max(maxX, node.position.x + NODE_WIDTH);
maxY = Math.max(maxY, node.position.y + NODE_HEIGHT);
});
const currentCenterX: number = (minX + maxX) / 2;
const currentCenterY: number = minY; // Align to top
const offsetX: number = centerX - currentCenterX;
const offsetY: number = centerY - currentCenterY;
return nodes.map((node: Node) => {
return {
...node,
position: {
x: node.position.x + offsetX,
y: node.position.y + offsetY,
},
};
});
}

View File

@@ -2,7 +2,6 @@ import PageComponentProps from "../../PageComponentProps";
import URL from "Common/Types/API/URL";
import BadDataException from "Common/Types/Exception/BadDataException";
import { PromiseVoidFunction } from "Common/Types/FunctionTypes";
import IconProp from "Common/Types/Icon/IconProp";
import { JSONObject } from "Common/Types/JSON";
import JSONFunctions from "Common/Types/JSONFunctions";
import ObjectID from "Common/Types/ObjectID";
@@ -13,8 +12,7 @@ import ComponentMetadata, {
NodeType,
} from "Common/Types/Workflow/Component";
import Banner from "Common/UI/Components/Banner/Banner";
import Button, { ButtonStyleType } from "Common/UI/Components/Button/Button";
import Card from "Common/UI/Components/Card/Card";
import { ButtonStyleType } from "Common/UI/Components/Button/Button";
import ComponentLoader from "Common/UI/Components/ComponentLoader/ComponentLoader";
import ConfirmModal from "Common/UI/Components/Modal/ConfirmModal";
import { loadComponentsAndCategories } from "Common/UI/Components/Workflow/Utils";
@@ -22,6 +20,8 @@ import Workflow, {
getEdgeDefaultProps,
getPlaceholderTriggerNode,
} from "Common/UI/Components/Workflow/Workflow";
import WorkflowBuilderLayout from "Common/UI/Components/Workflow/WorkflowBuilderLayout";
import { applyAutoLayout } from "Common/UI/Components/Workflow/workflowLayoutUtils";
import { WORKFLOW_URL } from "Common/UI/Config";
import API from "Common/UI/Utils/API/API";
import ModelAPI from "Common/UI/Utils/ModelAPI/ModelAPI";
@@ -31,6 +31,7 @@ import React, {
Fragment,
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
import { Edge, Node } from "reactflow";
@@ -57,6 +58,44 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
const [showRunModal, setShowRunModal] = useState<boolean>(false);
const [isAutoLayouting, setIsAutoLayouting] = useState<boolean>(false);
// Load component metadata for sidebar
const [allComponentMetadata, setAllComponentMetadata] = useState<
Array<ComponentMetadata>
>([]);
const [allComponentCategories, setAllComponentCategories] = useState<
Array<ComponentCategory>
>([]);
useEffect(() => {
const value: {
components: Array<ComponentMetadata>;
categories: Array<ComponentCategory>;
} = loadComponentsAndCategories();
setAllComponentCategories(value.categories);
setAllComponentMetadata(value.components);
}, []);
const handleAutoLayout = async (): Promise<void> => {
if (nodes.length < 2) {
return;
}
setIsAutoLayouting(true);
try {
const result: { nodes: Array<Node>; edges: Array<Edge> } =
await applyAutoLayout(nodes, edges);
setNodes(result.nodes);
setEdges(result.edges);
await saveGraph(result.nodes, result.edges);
} catch (err) {
setError("Failed to apply auto layout");
}
setIsAutoLayouting(false);
};
const loadGraph: PromiseVoidFunction = async (): Promise<void> => {
try {
setIsLoading(true);
@@ -261,36 +300,23 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
link={URL.fromString("https://youtu.be/k1-reCQTZnM")}
hideOnMobile={true}
/>
<Card
title={"Workflow Builder"}
description={"Workflow builder for OneUptime"}
rightElement={
<div className="flex">
<p className="text-sm text-gray-400 mr-3 mt-2">{saveStatus}</p>
<div>
<Button
title="Add Component"
icon={IconProp.Add}
onClick={() => {
setShowComponentPickerModal(true);
}}
/>
</div>
<div>
<Button
title="Run Workflow Manually"
icon={IconProp.Play}
onClick={() => {
setShowRunModal(true);
}}
/>
</div>
</div>
}
>
{isLoading ? <ComponentLoader /> : <></>}
{!isLoading ? (
{isLoading ? (
<div className="p-8">
<ComponentLoader />
</div>
) : (
<WorkflowBuilderLayout
components={allComponentMetadata}
categories={allComponentCategories}
nodes={nodes}
saveStatus={saveStatus}
onRunWorkflow={() => {
setShowRunModal(true);
}}
onAutoLayout={handleAutoLayout}
isAutoLayouting={isAutoLayouting}
>
<Workflow
workflowId={modelId}
showComponentsPickerModal={showComponentPickerModal}
@@ -303,6 +329,7 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
}}
showRunModal={showRunModal}
initialEdges={edges}
allComponentMetadata={allComponentMetadata}
onWorkflowUpdated={async (
nodes: Array<Node>,
edges: Array<Edge>,
@@ -333,10 +360,8 @@ const Delete: FunctionComponent<PageComponentProps> = (): ReactElement => {
}
}}
/>
) : (
<></>
)}
</Card>
</WorkflowBuilderLayout>
)}
{error && (
<ConfirmModal
title={`Error`}