mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
Compare commits
2 Commits
10.0.41
...
workflow-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b482ec26e | ||
|
|
035707ec72 |
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
69
Common/UI/Components/Workflow/ComponentCategorySection.tsx
Normal file
69
Common/UI/Components/Workflow/ComponentCategorySection.tsx
Normal 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;
|
||||
80
Common/UI/Components/Workflow/DraggableComponentItem.tsx
Normal file
80
Common/UI/Components/Workflow/DraggableComponentItem.tsx
Normal 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;
|
||||
@@ -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 };
|
||||
|
||||
81
Common/UI/Components/Workflow/WorkflowBuilderLayout.tsx
Normal file
81
Common/UI/Components/Workflow/WorkflowBuilderLayout.tsx
Normal 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;
|
||||
115
Common/UI/Components/Workflow/WorkflowCanvas.css
Normal file
115
Common/UI/Components/Workflow/WorkflowCanvas.css
Normal 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;
|
||||
}
|
||||
229
Common/UI/Components/Workflow/WorkflowComponentsSidebar.tsx
Normal file
229
Common/UI/Components/Workflow/WorkflowComponentsSidebar.tsx
Normal 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;
|
||||
68
Common/UI/Components/Workflow/WorkflowEmptyState.tsx
Normal file
68
Common/UI/Components/Workflow/WorkflowEmptyState.tsx
Normal 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;
|
||||
104
Common/UI/Components/Workflow/WorkflowOnboarding.tsx
Normal file
104
Common/UI/Components/Workflow/WorkflowOnboarding.tsx
Normal 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;
|
||||
140
Common/UI/Components/Workflow/workflowLayoutUtils.ts
Normal file
140
Common/UI/Components/Workflow/workflowLayoutUtils.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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`}
|
||||
|
||||
Reference in New Issue
Block a user