Editable workflows (#792)
Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
@@ -31,15 +31,29 @@ import {
|
||||
CounterClockwiseClockIcon,
|
||||
Pencil2Icon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReloadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard";
|
||||
import { WorkflowTitle } from "./WorkflowTitle";
|
||||
import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes";
|
||||
import { stringify as convertToYAML } from "yaml";
|
||||
|
||||
const emptyWorkflowRequest: WorkflowCreateYAMLRequest = {
|
||||
title: "New Workflow",
|
||||
description: "",
|
||||
workflow_definition: {
|
||||
blocks: [],
|
||||
parameters: [],
|
||||
},
|
||||
};
|
||||
|
||||
function Workflows() {
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const workflowsPage = searchParams.get("workflowsPage")
|
||||
? Number(searchParams.get("workflowsPage"))
|
||||
@@ -79,6 +93,27 @@ function Workflows() {
|
||||
},
|
||||
});
|
||||
|
||||
const createNewWorkflowMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const client = await getClient(credentialGetter);
|
||||
const yaml = convertToYAML(emptyWorkflowRequest);
|
||||
return client.post<
|
||||
typeof emptyWorkflowRequest,
|
||||
{ data: WorkflowApiResponse }
|
||||
>("/workflows", yaml, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
});
|
||||
},
|
||||
onSuccess: (response) => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["workflows"],
|
||||
});
|
||||
navigate(`/workflows/${response.data.workflow_permanent_id}`);
|
||||
},
|
||||
});
|
||||
|
||||
if (workflows?.length === 0 && workflowsPage === 1) {
|
||||
return <WorkflowsBetaAlertCard />;
|
||||
}
|
||||
@@ -115,8 +150,21 @@ function Workflows() {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<header>
|
||||
<header className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-semibold">Workflows</h1>
|
||||
<Button
|
||||
disabled={createNewWorkflowMutation.isPending}
|
||||
onClick={() => {
|
||||
createNewWorkflowMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{createNewWorkflowMutation.isPending ? (
|
||||
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<PlusIcon className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Create Workflow
|
||||
</Button>
|
||||
</header>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import CodeMirror, { EditorView } from "@uiw/react-codemirror";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
|
||||
@@ -22,7 +22,10 @@ function CodeEditor({
|
||||
className,
|
||||
fontSize = 8,
|
||||
}: Props) {
|
||||
const extensions = language === "json" ? [json()] : [python()];
|
||||
const extensions =
|
||||
language === "json"
|
||||
? [json(), EditorView.lineWrapping]
|
||||
: [python(), EditorView.lineWrapping];
|
||||
return (
|
||||
<CodeMirror
|
||||
value={value}
|
||||
|
||||
@@ -13,23 +13,100 @@ import "@xyflow/react/dist/style.css";
|
||||
import { WorkflowHeader } from "./WorkflowHeader";
|
||||
import { AppNode, nodeTypes } from "./nodes";
|
||||
import "./reactFlowOverrideStyles.css";
|
||||
import { layout } from "./workflowEditorUtils";
|
||||
import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils";
|
||||
import { useEffect, useState } from "react";
|
||||
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
|
||||
import { edgeTypes } from "./edges";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
|
||||
import {
|
||||
BitwardenLoginCredentialParameterYAML,
|
||||
BlockYAML,
|
||||
WorkflowParameterYAML,
|
||||
} from "../types/workflowYamlTypes";
|
||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||
import { WorkflowParameterValueType } from "../types/workflowTypes";
|
||||
|
||||
function convertToParametersYAML(
|
||||
parameters: ParametersState,
|
||||
): Array<WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML> {
|
||||
return parameters.map((parameter) => {
|
||||
if (parameter.parameterType === "workflow") {
|
||||
return {
|
||||
parameter_type: "workflow",
|
||||
key: parameter.key,
|
||||
description: parameter.description || null,
|
||||
workflow_parameter_type: parameter.dataType,
|
||||
default_value: null,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
parameter_type: "bitwarden_login_credential",
|
||||
key: parameter.key,
|
||||
description: parameter.description || null,
|
||||
bitwarden_collection_id: parameter.collectionId,
|
||||
url_parameter_key: parameter.urlParameterKey,
|
||||
bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID",
|
||||
bitwarden_client_secret_aws_secret_key:
|
||||
"SKYVERN_BITWARDEN_CLIENT_SECRET",
|
||||
bitwarden_master_password_aws_secret_key:
|
||||
"SKYVERN_BITWARDEN_MASTER_PASSWORD",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export type ParametersState = Array<
|
||||
| {
|
||||
key: string;
|
||||
parameterType: "workflow";
|
||||
dataType: WorkflowParameterValueType;
|
||||
description?: string;
|
||||
}
|
||||
| {
|
||||
key: string;
|
||||
parameterType: "credential";
|
||||
collectionId: string;
|
||||
urlParameterKey: string;
|
||||
description?: string;
|
||||
}
|
||||
>;
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
initialTitle: string;
|
||||
initialNodes: Array<AppNode>;
|
||||
initialEdges: Array<Edge>;
|
||||
initialParameters: ParametersState;
|
||||
handleSave: (
|
||||
parameters: Array<
|
||||
WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML
|
||||
>,
|
||||
blocks: Array<BlockYAML>,
|
||||
title: string,
|
||||
) => void;
|
||||
};
|
||||
|
||||
function FlowRenderer({ title, initialEdges, initialNodes }: Props) {
|
||||
const [rightSidePanelOpen, setRightSidePanelOpen] = useState(false);
|
||||
const [rightSidePanelContent, setRightSidePanelContent] = useState<
|
||||
"parameters" | "nodeLibrary" | null
|
||||
>(null);
|
||||
export type AddNodeProps = {
|
||||
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">;
|
||||
previous: string | null;
|
||||
next: string | null;
|
||||
parent?: string;
|
||||
connectingEdgeType: string;
|
||||
};
|
||||
|
||||
function FlowRenderer({
|
||||
initialTitle,
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
initialParameters,
|
||||
handleSave,
|
||||
}: Props) {
|
||||
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
|
||||
useWorkflowPanelStore();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
const [parameters, setParameters] = useState(initialParameters);
|
||||
const [title, setTitle] = useState(initialTitle);
|
||||
const nodesInitialized = useNodesInitialized();
|
||||
|
||||
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
|
||||
@@ -45,62 +122,173 @@ function FlowRenderer({ title, initialEdges, initialNodes }: Props) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [nodesInitialized]);
|
||||
|
||||
function addNode({
|
||||
nodeType,
|
||||
previous,
|
||||
next,
|
||||
parent,
|
||||
connectingEdgeType,
|
||||
}: AddNodeProps) {
|
||||
const newNodes: Array<AppNode> = [];
|
||||
const newEdges: Array<Edge> = [];
|
||||
const index = parent
|
||||
? nodes.filter((node) => node.parentId === parent).length
|
||||
: nodes.length;
|
||||
const id = parent ? `${parent}-${index}` : String(index);
|
||||
const node = createNode({ id, parentId: parent }, nodeType, String(index));
|
||||
newNodes.push(node);
|
||||
if (previous) {
|
||||
const newEdge = {
|
||||
id: `edge-${previous}-${id}`,
|
||||
type: "edgeWithAddButton",
|
||||
source: previous,
|
||||
target: id,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
if (next) {
|
||||
const newEdge = {
|
||||
id: `edge-${id}-${next}`,
|
||||
type: connectingEdgeType,
|
||||
source: id,
|
||||
target: next,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
};
|
||||
newEdges.push(newEdge);
|
||||
}
|
||||
|
||||
if (nodeType === "loop") {
|
||||
newNodes.push({
|
||||
id: `${id}-nodeAdder`,
|
||||
type: "nodeAdder",
|
||||
parentId: id,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const editedEdges = previous
|
||||
? edges.filter((edge) => edge.source !== previous)
|
||||
: edges;
|
||||
|
||||
const previousNode = nodes.find((node) => node.id === previous);
|
||||
const previousNodeIndex = previousNode
|
||||
? nodes.indexOf(previousNode)
|
||||
: nodes.length - 1;
|
||||
|
||||
const newNodesAfter = [
|
||||
...nodes.slice(0, previousNodeIndex + 1),
|
||||
...newNodes,
|
||||
...nodes.slice(previousNodeIndex + 1),
|
||||
];
|
||||
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={(changes) => {
|
||||
const dimensionChanges = changes.filter(
|
||||
(change) => change.type === "dimensions",
|
||||
);
|
||||
const tempNodes = [...nodes];
|
||||
dimensionChanges.forEach((change) => {
|
||||
const node = tempNodes.find((node) => node.id === change.id);
|
||||
if (node) {
|
||||
if (node.measured?.width) {
|
||||
node.measured.width = change.dimensions?.width;
|
||||
}
|
||||
if (node.measured?.height) {
|
||||
node.measured.height = change.dimensions?.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
colorMode="dark"
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1,
|
||||
}}
|
||||
<WorkflowParametersStateContext.Provider
|
||||
value={[parameters, setParameters]}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
|
||||
<Controls position="bottom-left" />
|
||||
<Panel position="top-center" className="h-20">
|
||||
<WorkflowHeader
|
||||
title={title}
|
||||
parametersPanelOpen={rightSidePanelOpen}
|
||||
onParametersClick={() => {
|
||||
setRightSidePanelOpen((open) => !open);
|
||||
setRightSidePanelContent("parameters");
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
{rightSidePanelOpen && (
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="w-96 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
|
||||
>
|
||||
{rightSidePanelContent === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={(changes) => {
|
||||
const dimensionChanges = changes.filter(
|
||||
(change) => change.type === "dimensions",
|
||||
);
|
||||
const tempNodes = [...nodes];
|
||||
dimensionChanges.forEach((change) => {
|
||||
const node = tempNodes.find((node) => node.id === change.id);
|
||||
if (node) {
|
||||
if (node.measured?.width) {
|
||||
node.measured.width = change.dimensions?.width;
|
||||
}
|
||||
if (node.measured?.height) {
|
||||
node.measured.height = change.dimensions?.height;
|
||||
}
|
||||
}
|
||||
});
|
||||
if (dimensionChanges.length > 0) {
|
||||
doLayout(tempNodes, edges);
|
||||
}
|
||||
onNodesChange(changes);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
colorMode="dark"
|
||||
fitView
|
||||
fitViewOptions={{
|
||||
maxZoom: 1,
|
||||
}}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
|
||||
<Controls position="bottom-left" />
|
||||
<Panel position="top-center" className="h-20">
|
||||
<WorkflowHeader
|
||||
title={title}
|
||||
onTitleChange={setTitle}
|
||||
parametersPanelOpen={
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
}
|
||||
onParametersClick={() => {
|
||||
if (
|
||||
workflowPanelState.active &&
|
||||
workflowPanelState.content === "parameters"
|
||||
) {
|
||||
closeWorkflowPanel();
|
||||
} else {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "parameters",
|
||||
});
|
||||
}
|
||||
}}
|
||||
onSave={() => {
|
||||
const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes);
|
||||
const parametersInYAMLConvertibleJSON =
|
||||
convertToParametersYAML(parameters);
|
||||
handleSave(
|
||||
parametersInYAMLConvertibleJSON,
|
||||
blocksInYAMLConvertibleJSON,
|
||||
title,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
{workflowPanelState.active && (
|
||||
<Panel position="top-right">
|
||||
{workflowPanelState.content === "parameters" && (
|
||||
<WorkflowParametersPanel />
|
||||
)}
|
||||
{workflowPanelState.content === "nodeLibrary" && (
|
||||
<WorkflowNodeLibraryPanel
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
)}
|
||||
{nodes.length === 0 && (
|
||||
<Panel position="top-right">
|
||||
<WorkflowNodeLibraryPanel
|
||||
onNodeClick={(props) => {
|
||||
addNode(props);
|
||||
}}
|
||||
first
|
||||
/>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</WorkflowParametersStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,75 @@
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
|
||||
import { FlowRenderer } from "./FlowRenderer";
|
||||
import { getElements } from "./workflowEditorUtils";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import {
|
||||
BlockYAML,
|
||||
ParameterYAML,
|
||||
WorkflowCreateYAMLRequest,
|
||||
} from "../types/workflowYamlTypes";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { stringify as convertToYAML } from "yaml";
|
||||
import { ReactFlowProvider } from "@xyflow/react";
|
||||
import { FlowRenderer } from "./FlowRenderer";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
function WorkflowEditor() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
|
||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
});
|
||||
|
||||
const saveWorkflowMutation = useMutation({
|
||||
mutationFn: async (data: {
|
||||
parameters: Array<ParameterYAML>;
|
||||
blocks: Array<BlockYAML>;
|
||||
title: string;
|
||||
}) => {
|
||||
if (!workflow || !workflowPermanentId) {
|
||||
return;
|
||||
}
|
||||
const client = await getClient(credentialGetter);
|
||||
const requestBody: WorkflowCreateYAMLRequest = {
|
||||
title: data.title,
|
||||
description: workflow.description,
|
||||
proxy_location: workflow.proxy_location,
|
||||
webhook_callback_url: workflow.webhook_callback_url,
|
||||
totp_verification_url: workflow.totp_verification_url,
|
||||
workflow_definition: {
|
||||
parameters: data.parameters,
|
||||
blocks: data.blocks,
|
||||
},
|
||||
is_saved_task: workflow.is_saved_task,
|
||||
};
|
||||
const yaml = convertToYAML(requestBody);
|
||||
return client
|
||||
.put(`/workflows/${workflowPermanentId}`, yaml, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
})
|
||||
.then((response) => response.data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Changes saved",
|
||||
description: "Your changes have been saved",
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: (error: AxiosError) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// TODO
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -30,9 +89,38 @@ function WorkflowEditor() {
|
||||
<div className="h-screen w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowRenderer
|
||||
title={workflow.title}
|
||||
initialTitle={workflow.title}
|
||||
initialNodes={elements.nodes}
|
||||
initialEdges={elements.edges}
|
||||
initialParameters={workflow.workflow_definition.parameters
|
||||
.filter(
|
||||
(parameter) =>
|
||||
parameter.parameter_type === "workflow" ||
|
||||
parameter.parameter_type === "bitwarden_login_credential",
|
||||
)
|
||||
.map((parameter) => {
|
||||
if (parameter.parameter_type === "workflow") {
|
||||
return {
|
||||
key: parameter.key,
|
||||
parameterType: "workflow",
|
||||
dataType: parameter.workflow_parameter_type,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: parameter.key,
|
||||
parameterType: "credential",
|
||||
collectionId: parameter.bitwarden_collection_id,
|
||||
urlParameterKey: parameter.url_parameter_key,
|
||||
};
|
||||
}
|
||||
})}
|
||||
handleSave={(parameters, blocks, title) => {
|
||||
saveWorkflowMutation.mutate({
|
||||
parameters,
|
||||
blocks,
|
||||
title,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SaveIcon } from "@/components/icons/SaveIcon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
@@ -6,17 +7,22 @@ import {
|
||||
PlayIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
parametersPanelOpen: boolean;
|
||||
onParametersClick: () => void;
|
||||
onSave: () => void;
|
||||
onTitleChange: (title: string) => void;
|
||||
};
|
||||
|
||||
function WorkflowHeader({
|
||||
title,
|
||||
parametersPanelOpen,
|
||||
onParametersClick,
|
||||
onSave,
|
||||
onTitleChange,
|
||||
}: Props) {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -24,17 +30,34 @@ function WorkflowHeader({
|
||||
return (
|
||||
<div className="flex h-full w-full bg-slate-elevation2">
|
||||
<div className="flex h-full w-1/3 items-center pl-6">
|
||||
<div
|
||||
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
navigate("/workflows");
|
||||
}}
|
||||
>
|
||||
<ExitIcon className="h-6 w-6" />
|
||||
<div className="flex">
|
||||
<div
|
||||
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
navigate("/workflows");
|
||||
}}
|
||||
>
|
||||
<ExitIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
onSave();
|
||||
}}
|
||||
>
|
||||
<SaveIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-full w-1/3 items-center justify-center">
|
||||
<span className="max-w-max truncate text-3xl">{title}</span>
|
||||
<div className="flex h-full w-1/3 items-center justify-center p-1">
|
||||
<EditableNodeTitle
|
||||
editable={true}
|
||||
onChange={onTitleChange}
|
||||
value={title}
|
||||
className="max-w-96 text-3xl"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex h-full w-1/3 items-center justify-end gap-4 p-4">
|
||||
<Button variant="secondary" size="lg" onClick={onParametersClick}>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import { ParametersState } from "./FlowRenderer";
|
||||
|
||||
type WorkflowParametersState = [
|
||||
ParametersState,
|
||||
React.Dispatch<React.SetStateAction<ParametersState>>,
|
||||
];
|
||||
|
||||
const WorkflowParametersStateContext = createContext<
|
||||
WorkflowParametersState | undefined
|
||||
>(undefined);
|
||||
|
||||
export { WorkflowParametersStateContext };
|
||||
@@ -0,0 +1,2 @@
|
||||
// nodes have 1000 Z index and we want edges above
|
||||
export const REACT_FLOW_EDGE_Z_INDEX = 1001;
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
EdgeProps,
|
||||
getBezierPath,
|
||||
useNodes,
|
||||
} from "@xyflow/react";
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
|
||||
|
||||
function EdgeWithAddButton({
|
||||
source,
|
||||
target,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style = {},
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const nodes = useNodes();
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||
(state) => state.setWorkflowPanelState,
|
||||
);
|
||||
const sourceNode = nodes.find((node) => node.id === source);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
|
||||
fontSize: 12,
|
||||
// everything inside EdgeLabelRenderer has no pointer events by default
|
||||
// if you have an interactive element, set pointer-events: all
|
||||
pointerEvents: "all",
|
||||
zIndex: REACT_FLOW_EDGE_Z_INDEX + 1, // above the edge
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
<Button
|
||||
size="icon"
|
||||
className="h-4 w-4 rounded-full transition-all hover:scale-150"
|
||||
onClick={() => {
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: source,
|
||||
next: target,
|
||||
parent: sourceNode?.parentId,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export { EdgeWithAddButton };
|
||||
@@ -0,0 +1,5 @@
|
||||
import { EdgeWithAddButton } from "./EdgeWithAddButton";
|
||||
|
||||
export const edgeTypes = {
|
||||
edgeWithAddButton: EdgeWithAddButton,
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { CodeBlockNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
|
||||
function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -26,8 +29,12 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
|
||||
<CodeIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<span className="text-xs text-slate-400">Task Block</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Code Block</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@@ -39,9 +46,11 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
|
||||
<CodeEditor
|
||||
language="python"
|
||||
value={data.code}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
onChange={(value) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { code: value });
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,9 @@ export type CodeBlockNodeData = {
|
||||
};
|
||||
|
||||
export type CodeBlockNode = Node<CodeBlockNodeData, "codeBlock">;
|
||||
|
||||
export const codeBlockNodeDefaultData: CodeBlockNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
code: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { DownloadNode } from "./types";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
|
||||
function DownloadNode({ data }: NodeProps<DownloadNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -26,7 +29,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
|
||||
<DownloadIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Download Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,11 +46,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
|
||||
<Label className="text-sm text-slate-400">File URL</Label>
|
||||
<Input
|
||||
value={data.url}
|
||||
onChange={() => {
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
updateNodeData(id, { url: event.target.value });
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,9 @@ export type DownloadNodeData = {
|
||||
};
|
||||
|
||||
export type DownloadNode = Node<DownloadNodeData, "download">;
|
||||
|
||||
export const downloadNodeDefaultData: DownloadNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
url: "SKYVERN_DOWNLOAD_DIRECTORY",
|
||||
} as const;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { FileParserNode } from "./types";
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function FileParserNode({ data }: NodeProps<FileParserNode>) {
|
||||
function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -25,7 +27,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
|
||||
<CursorTextIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">File Parser Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,11 +44,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
|
||||
<span className="text-sm text-slate-400">File URL</span>
|
||||
<Input
|
||||
value={data.fileUrl}
|
||||
onChange={() => {
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
updateNodeData(id, { fileUrl: event.target.value });
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,9 @@ export type FileParserNodeData = {
|
||||
};
|
||||
|
||||
export type FileParserNode = Node<FileParserNodeData, "fileParser">;
|
||||
|
||||
export const fileParserNodeDefaultData: FileParserNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
fileUrl: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useNodes } from "@xyflow/react";
|
||||
import {
|
||||
Handle,
|
||||
NodeProps,
|
||||
Position,
|
||||
useNodes,
|
||||
useReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import type { LoopNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const nodes = useNodes();
|
||||
const children = nodes.filter((node) => node.parentId === id);
|
||||
const furthestDownChild: Node | null = children.reduce(
|
||||
@@ -54,7 +62,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
<UpdateIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Loop Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,11 +78,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
|
||||
<Label className="text-xs text-slate-300">Loop Value</Label>
|
||||
<Input
|
||||
value={data.loopValue}
|
||||
onChange={() => {
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
updateNodeData(id, { loopValue: event.target.value });
|
||||
}}
|
||||
placeholder="What value are you iterating over?"
|
||||
className="nopan"
|
||||
|
||||
@@ -7,3 +7,9 @@ export type LoopNodeData = {
|
||||
};
|
||||
|
||||
export type LoopNode = Node<LoopNodeData, "loop">;
|
||||
|
||||
export const loopNodeDefaultData: LoopNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
loopValue: "",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
|
||||
import type { NodeAdderNode } from "./types";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
|
||||
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
|
||||
const edges = useEdges();
|
||||
const setWorkflowPanelState = useWorkflowPanelStore(
|
||||
(state) => state.setWorkflowPanelState,
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="a"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="b"
|
||||
className="opacity-0"
|
||||
/>
|
||||
<div
|
||||
className="rounded-full bg-slate-50 p-2"
|
||||
onClick={() => {
|
||||
const previous = edges.find((edge) => edge.target === id)?.source;
|
||||
setWorkflowPanelState({
|
||||
active: true,
|
||||
content: "nodeLibrary",
|
||||
data: {
|
||||
previous: previous ?? null,
|
||||
next: id,
|
||||
parent: parentId,
|
||||
connectingEdgeType: "default",
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-12 w-12 text-slate-950" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { NodeAdderNode };
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type NodeAdderNodeData = Record<string, never>;
|
||||
|
||||
export type NodeAdderNode = Node<NodeAdderNodeData, "nodeAdder">;
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { SendEmailNode } from "./types";
|
||||
import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
|
||||
function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -28,7 +30,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
<EnvelopeClosedIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Send Email Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -37,24 +43,42 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Recipient</Label>
|
||||
<Label className="text-xs text-slate-300">Sender</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { sender: event.target.value });
|
||||
}}
|
||||
value={data.recipients.join(", ")}
|
||||
value={data.sender}
|
||||
placeholder="example@gmail.com"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Recipients</Label>
|
||||
<Input
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { recipients: event.target.value });
|
||||
}}
|
||||
value={data.recipients}
|
||||
placeholder="example@gmail.com, example2@gmail.com..."
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Subject</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { subject: event.target.value });
|
||||
}}
|
||||
value={data.subject}
|
||||
placeholder="What is the gist?"
|
||||
@@ -64,9 +88,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Body</Label>
|
||||
<Input
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { body: event.target.value });
|
||||
}}
|
||||
value={data.body}
|
||||
placeholder="What would you like to say?"
|
||||
@@ -77,21 +103,16 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">File Attachments</Label>
|
||||
<Input
|
||||
value={data.fileAttachments?.join(", ") ?? ""}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
value={data.fileAttachments}
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { fileAttachments: event.target.value });
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center gap-10">
|
||||
<Label className="text-xs text-slate-300">
|
||||
Attach all downloaded files
|
||||
</Label>
|
||||
<Switch />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import type { Node } from "@xyflow/react";
|
||||
|
||||
export type SendEmailNodeData = {
|
||||
recipients: string[];
|
||||
recipients: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
fileAttachments: string[] | null;
|
||||
fileAttachments: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
sender: string;
|
||||
};
|
||||
|
||||
export type SendEmailNode = Node<SendEmailNodeData, "sendEmail">;
|
||||
|
||||
export const sendEmailNodeDefaultData: SendEmailNodeData = {
|
||||
recipients: "",
|
||||
subject: "",
|
||||
body: "",
|
||||
fileAttachments: "",
|
||||
editable: true,
|
||||
label: "",
|
||||
sender: "",
|
||||
} as const;
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { DotsHorizontalIcon, ListBulletIcon } from "@radix-ui/react-icons";
|
||||
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
||||
import type { TaskNodeDisplayMode } from "./types";
|
||||
import type { TaskNode } from "./types";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { DataSchema } from "../../../components/DataSchema";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { TaskNodeErrorMapping } from "./TaskNodeErrorMapping";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import {
|
||||
DotsHorizontalIcon,
|
||||
ListBulletIcon,
|
||||
MixerVerticalIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import { useState } from "react";
|
||||
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
|
||||
import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel";
|
||||
import type { TaskNode, TaskNodeDisplayMode } from "./types";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
|
||||
const { editable } = data;
|
||||
|
||||
@@ -28,9 +40,12 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<AutoResizingTextarea
|
||||
value={data.url}
|
||||
className="nopan"
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
name="url"
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { url: event.target.value });
|
||||
}}
|
||||
placeholder="https://"
|
||||
/>
|
||||
@@ -38,9 +53,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Goal</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { navigationGoal: event.target.value });
|
||||
}}
|
||||
value={data.navigationGoal}
|
||||
placeholder="What are you looking to do?"
|
||||
@@ -63,9 +80,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">URL</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { url: event.target.value });
|
||||
}}
|
||||
value={data.url}
|
||||
placeholder="https://"
|
||||
@@ -75,9 +94,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Goal</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { navigationGoal: event.target.value });
|
||||
}}
|
||||
value={data.navigationGoal}
|
||||
placeholder="What are you looking to do?"
|
||||
@@ -96,28 +117,56 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
Data Extraction Goal
|
||||
</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
dataExtractionGoal: event.target.value,
|
||||
});
|
||||
}}
|
||||
value={data.dataExtractionGoal}
|
||||
placeholder="What outputs are you looking to get?"
|
||||
className="nopan"
|
||||
/>
|
||||
</div>
|
||||
<DataSchema
|
||||
value={data.dataSchema}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Data Schema</Label>
|
||||
<Checkbox
|
||||
checked={data.dataSchema !== "null"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
dataSchema: checked ? "{}" : "null",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.dataSchema !== "null" && (
|
||||
<div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={data.dataSchema}
|
||||
onChange={(value) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { dataSchema: value });
|
||||
}}
|
||||
className="nowheel nopan"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
<AccordionItem value="limits">
|
||||
<AccordionTrigger>Limits</AccordionTrigger>
|
||||
<AccordionContent className="pl-[1.5rem] pr-1">
|
||||
<AccordionContent className="pl-[1.5rem] pr-1 pt-1">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
@@ -127,10 +176,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="nopan w-44"
|
||||
min="0"
|
||||
value={data.maxRetries ?? 0}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
maxRetries: Number(event.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -142,10 +196,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
type="number"
|
||||
placeholder="0"
|
||||
className="nopan w-44"
|
||||
min="0"
|
||||
value={data.maxStepsOverride ?? 0}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
maxStepsOverride: Number(event.target.value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -156,20 +215,49 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<div className="w-44">
|
||||
<Switch
|
||||
checked={data.allowDownloads}
|
||||
onCheckedChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
onCheckedChange={(checked) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { allowDownloads: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TaskNodeErrorMapping
|
||||
value={data.errorCodeMapping}
|
||||
onChange={() => {
|
||||
if (!editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Error Messages
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked={data.errorCodeMapping !== "null"}
|
||||
disabled={!editable}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
errorCodeMapping: checked ? "{}" : "null",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.errorCodeMapping !== "null" && (
|
||||
<div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={data.errorCodeMapping}
|
||||
onChange={(value) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { errorCodeMapping: value });
|
||||
}}
|
||||
className="nowheel nopan"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -198,7 +286,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<ListBulletIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Task Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -206,10 +298,28 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
|
||||
<DotsHorizontalIcon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<TaskNodeDisplayModeSwitch
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
/>
|
||||
<div className="flex justify-between">
|
||||
<TaskNodeDisplayModeSwitch
|
||||
value={displayMode}
|
||||
onChange={setDisplayMode}
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="icon" variant="outline">
|
||||
<MixerVerticalIcon />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72">
|
||||
<TaskNodeParametersPanel
|
||||
parameters={data.parameterKeys}
|
||||
onParametersChange={(parameterKeys) => {
|
||||
updateNodeData(id, { parameterKeys });
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{displayMode === "basic" && basicContent}
|
||||
{displayMode === "advanced" && advancedContent}
|
||||
</div>
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
|
||||
type Props = {
|
||||
value: Record<string, unknown> | null;
|
||||
onChange: (value: Record<string, unknown> | null) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function TaskNodeErrorMapping({ value, onChange, disabled }: Props) {
|
||||
if (value === null) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Error Messages
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked={false}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => {
|
||||
onChange({});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs font-normal text-slate-300">
|
||||
Error Messages
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => {
|
||||
onChange(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
}}
|
||||
className="nowheel nopan"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskNodeErrorMapping };
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useWorkflowParametersState } from "../../useWorkflowParametersState";
|
||||
|
||||
type Props = {
|
||||
parameters: Array<string>;
|
||||
onParametersChange: (parameters: Array<string>) => void;
|
||||
};
|
||||
|
||||
function TaskNodeParametersPanel({ parameters, onParametersChange }: Props) {
|
||||
const [workflowParameters] = useWorkflowParametersState();
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-1">
|
||||
<h1>Parameters</h1>
|
||||
<span className="text-xs text-slate-300">
|
||||
Check off the parameters you want to use in this task.
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
{workflowParameters.map((workflowParameter) => {
|
||||
return (
|
||||
<div
|
||||
key={workflowParameter.key}
|
||||
className="flex items-center gap-2 rounded-sm bg-slate-elevation1 px-3 py-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={parameters.includes(workflowParameter.key)}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
onParametersChange([...parameters, workflowParameter.key]);
|
||||
} else {
|
||||
onParametersChange(
|
||||
parameters.filter(
|
||||
(parameter) => parameter !== workflowParameter.key,
|
||||
),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs">{workflowParameter.key}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { TaskNodeParametersPanel };
|
||||
@@ -4,15 +4,30 @@ export type TaskNodeData = {
|
||||
url: string;
|
||||
navigationGoal: string;
|
||||
dataExtractionGoal: string;
|
||||
errorCodeMapping: Record<string, string> | null;
|
||||
dataSchema: Record<string, unknown> | null;
|
||||
errorCodeMapping: string;
|
||||
dataSchema: string;
|
||||
maxRetries: number | null;
|
||||
maxStepsOverride: number | null;
|
||||
allowDownloads: boolean;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
parameterKeys: Array<string>;
|
||||
};
|
||||
|
||||
export type TaskNode = Node<TaskNodeData, "task">;
|
||||
|
||||
export type TaskNodeDisplayMode = "basic" | "advanced";
|
||||
|
||||
export const taskNodeDefaultData: TaskNodeData = {
|
||||
url: "",
|
||||
navigationGoal: "",
|
||||
dataExtractionGoal: "",
|
||||
errorCodeMapping: "null",
|
||||
dataSchema: "null",
|
||||
maxRetries: null,
|
||||
maxStepsOverride: null,
|
||||
allowDownloads: false,
|
||||
editable: true,
|
||||
label: "",
|
||||
parameterKeys: [],
|
||||
} as const;
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { TextPromptNode } from "./types";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { DataSchema } from "@/routes/workflows/components/DataSchema";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
const { editable } = data;
|
||||
|
||||
function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -28,7 +33,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
|
||||
<CursorTextIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Text Prompt Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,9 +48,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Prompt</Label>
|
||||
<AutoResizingTextarea
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
onChange={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { prompt: event.target.value });
|
||||
}}
|
||||
value={data.prompt}
|
||||
placeholder="What do you want to generate?"
|
||||
@@ -49,13 +60,37 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<DataSchema
|
||||
value={data.jsonSchema}
|
||||
onChange={() => {
|
||||
if (!data.editable) return;
|
||||
// TODO
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Label className="text-xs text-slate-300">Data Schema</Label>
|
||||
<Checkbox
|
||||
checked={data.jsonSchema !== "null"}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, {
|
||||
jsonSchema: checked ? "{}" : "null",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{data.jsonSchema !== "null" && (
|
||||
<div>
|
||||
<CodeEditor
|
||||
language="json"
|
||||
value={data.jsonSchema}
|
||||
onChange={(value) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
updateNodeData(id, { jsonSchema: value });
|
||||
}}
|
||||
className="nowheel nopan"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,16 @@ import type { Node } from "@xyflow/react";
|
||||
|
||||
export type TextPromptNodeData = {
|
||||
prompt: string;
|
||||
jsonSchema: Record<string, unknown> | null;
|
||||
jsonSchema: string;
|
||||
editable: boolean;
|
||||
label: string;
|
||||
};
|
||||
|
||||
export type TextPromptNode = Node<TextPromptNodeData, "textPrompt">;
|
||||
|
||||
export const textPromptNodeDefaultData: TextPromptNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
prompt: "",
|
||||
jsonSchema: "null",
|
||||
} as const;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Handle, NodeProps, Position } from "@xyflow/react";
|
||||
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||
import type { UploadNode } from "./types";
|
||||
import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||
|
||||
function UploadNode({ id, data }: NodeProps<UploadNode>) {
|
||||
const { updateNodeData } = useReactFlow();
|
||||
|
||||
function UploadNode({ data }: NodeProps<UploadNode>) {
|
||||
return (
|
||||
<div>
|
||||
<Handle
|
||||
@@ -26,7 +29,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
|
||||
<UploadIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">{data.label}</span>
|
||||
<EditableNodeTitle
|
||||
value={data.label}
|
||||
editable={data.editable}
|
||||
onChange={(value) => updateNodeData(id, { label: value })}
|
||||
/>
|
||||
<span className="text-xs text-slate-400">Upload Block</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,11 +46,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
|
||||
<Label className="text-sm text-slate-400">File Path</Label>
|
||||
<Input
|
||||
value={data.path}
|
||||
onChange={() => {
|
||||
onChange={(event) => {
|
||||
if (!data.editable) {
|
||||
return;
|
||||
}
|
||||
// TODO
|
||||
updateNodeData(id, { path: event.target.value });
|
||||
}}
|
||||
className="nopan"
|
||||
/>
|
||||
|
||||
@@ -7,3 +7,9 @@ export type UploadNodeData = {
|
||||
};
|
||||
|
||||
export type UploadNode = Node<UploadNodeData, "upload">;
|
||||
|
||||
export const uploadNodeDefaultData: UploadNodeData = {
|
||||
editable: true,
|
||||
label: "",
|
||||
path: "SKYVERN_DOWNLOAD_DIRECTORY",
|
||||
} as const;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/util/utils";
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
editable: boolean;
|
||||
onChange: (value: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function EditableNodeTitle({ value, editable, onChange, className }: Props) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// size the textarea correctly on first render
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
ref.current.style.width = `${ref.current.scrollWidth + 2}px`;
|
||||
}, []);
|
||||
|
||||
function setSize() {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
}
|
||||
ref.current.style.width = "auto";
|
||||
ref.current.style.width = `${ref.current.scrollWidth + 2}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
disabled={!editable}
|
||||
ref={ref}
|
||||
className={cn("w-fit min-w-fit max-w-64 border-0 px-0", className)}
|
||||
onBlur={(event) => {
|
||||
if (!editable) {
|
||||
event.currentTarget.value = value;
|
||||
return;
|
||||
}
|
||||
onChange(event.target.value);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!editable) {
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter") {
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.currentTarget.value = value;
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
setSize();
|
||||
}}
|
||||
onInput={setSize}
|
||||
defaultValue={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { EditableNodeTitle };
|
||||
@@ -15,6 +15,8 @@ import type { UploadNode } from "./UploadNode/types";
|
||||
import { UploadNode as UploadNodeComponent } from "./UploadNode/UploadNode";
|
||||
import type { DownloadNode } from "./DownloadNode/types";
|
||||
import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode";
|
||||
import type { NodeAdderNode } from "./NodeAdderNode/types";
|
||||
import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode";
|
||||
|
||||
export type AppNode =
|
||||
| LoopNode
|
||||
@@ -24,7 +26,8 @@ export type AppNode =
|
||||
| CodeBlockNode
|
||||
| FileParserNode
|
||||
| UploadNode
|
||||
| DownloadNode;
|
||||
| DownloadNode
|
||||
| NodeAdderNode;
|
||||
|
||||
export const nodeTypes = {
|
||||
loop: memo(LoopNodeComponent),
|
||||
@@ -35,4 +38,5 @@ export const nodeTypes = {
|
||||
fileParser: memo(FileParserNodeComponent),
|
||||
upload: memo(UploadNodeComponent),
|
||||
download: memo(DownloadNodeComponent),
|
||||
nodeAdder: memo(NodeAdderNodeComponent),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import {
|
||||
CodeIcon,
|
||||
Cross2Icon,
|
||||
CursorTextIcon,
|
||||
DownloadIcon,
|
||||
EnvelopeClosedIcon,
|
||||
FileIcon,
|
||||
ListBulletIcon,
|
||||
PlusIcon,
|
||||
UpdateIcon,
|
||||
UploadIcon,
|
||||
} from "@radix-ui/react-icons";
|
||||
import { nodeTypes } from "../nodes";
|
||||
import { AddNodeProps } from "../FlowRenderer";
|
||||
|
||||
const nodeLibraryItems: Array<{
|
||||
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">;
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
description: string;
|
||||
}> = [
|
||||
{
|
||||
nodeType: "task",
|
||||
icon: <ListBulletIcon className="h-6 w-6" />,
|
||||
title: "Task Block",
|
||||
description: "Takes actions or extracts information",
|
||||
},
|
||||
{
|
||||
nodeType: "loop",
|
||||
icon: <UpdateIcon className="h-6 w-6" />,
|
||||
title: "For Loop Block",
|
||||
description: "Repeats nested elements",
|
||||
},
|
||||
{
|
||||
nodeType: "textPrompt",
|
||||
icon: <CursorTextIcon className="h-6 w-6" />,
|
||||
title: "Text Prompt Block",
|
||||
description: "Generates AI response",
|
||||
},
|
||||
{
|
||||
nodeType: "sendEmail",
|
||||
icon: <EnvelopeClosedIcon className="h-6 w-6" />,
|
||||
title: "Send Email Block",
|
||||
description: "Sends an email",
|
||||
},
|
||||
{
|
||||
nodeType: "codeBlock",
|
||||
icon: <CodeIcon className="h-6 w-6" />,
|
||||
title: "Code Block",
|
||||
description: "Executes Python code",
|
||||
},
|
||||
{
|
||||
nodeType: "fileParser",
|
||||
icon: <FileIcon className="h-6 w-6" />,
|
||||
title: "File Parser Block",
|
||||
description: "Downloads and parses a file",
|
||||
},
|
||||
{
|
||||
nodeType: "download",
|
||||
icon: <DownloadIcon className="h-6 w-6" />,
|
||||
title: "Download Block",
|
||||
description: "Downloads a file from S3",
|
||||
},
|
||||
{
|
||||
nodeType: "upload",
|
||||
icon: <UploadIcon className="h-6 w-6" />,
|
||||
title: "Upload Block",
|
||||
description: "Uploads a file to S3",
|
||||
},
|
||||
];
|
||||
|
||||
type Props = {
|
||||
onNodeClick: (props: AddNodeProps) => void;
|
||||
first?: boolean;
|
||||
};
|
||||
|
||||
function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
|
||||
const workflowPanelData = useWorkflowPanelStore(
|
||||
(state) => state.workflowPanelState.data,
|
||||
);
|
||||
const closeWorkflowPanel = useWorkflowPanelStore(
|
||||
(state) => state.closeWorkflowPanel,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<div className="space-y-4">
|
||||
<header className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<h1 className="text-lg">Node Library</h1>
|
||||
{!first && (
|
||||
<Cross2Icon
|
||||
className="h-6 w-6 cursor-pointer"
|
||||
onClick={() => {
|
||||
closeWorkflowPanel();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-slate-400">
|
||||
{first
|
||||
? "Click on the node type to add your first node"
|
||||
: "Click on the node type you want to add"}
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2">
|
||||
{nodeLibraryItems.map((item) => {
|
||||
return (
|
||||
<div
|
||||
key={item.nodeType}
|
||||
className="flex cursor-pointer items-center justify-between rounded-sm bg-slate-elevation4 p-4 hover:bg-slate-elevation5"
|
||||
onClick={() => {
|
||||
onNodeClick({
|
||||
nodeType: item.nodeType,
|
||||
next: workflowPanelData?.next ?? null,
|
||||
parent: workflowPanelData?.parent,
|
||||
previous: workflowPanelData?.previous ?? null,
|
||||
connectingEdgeType:
|
||||
workflowPanelData?.connectingEdgeType ??
|
||||
"edgeWithAddButton",
|
||||
});
|
||||
closeWorkflowPanel();
|
||||
}}
|
||||
>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="max-w-64 truncate text-base">
|
||||
{item.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{item.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<PlusIcon className="h-6 w-6" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowNodeLibraryPanel };
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { WorkflowParameterValueType } from "../../types/workflowTypes";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ParametersState } from "../FlowRenderer";
|
||||
|
||||
type Props = {
|
||||
type: "workflow" | "credential";
|
||||
onClose: () => void;
|
||||
onSave: (value: ParametersState[number]) => void;
|
||||
};
|
||||
|
||||
const workflowParameterTypeOptions = [
|
||||
{ label: "string", value: WorkflowParameterValueType.String },
|
||||
{ label: "number", value: WorkflowParameterValueType.Float },
|
||||
{ label: "boolean", value: WorkflowParameterValueType.Boolean },
|
||||
{ label: "file", value: WorkflowParameterValueType.FileURL },
|
||||
{ label: "JSON", value: WorkflowParameterValueType.JSON },
|
||||
];
|
||||
|
||||
function WorkflowParameterAddPanel({ type, onClose, onSave }: Props) {
|
||||
const [key, setKey] = useState("");
|
||||
const [urlParameterKey, setUrlParameterKey] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [collectionId, setCollectionId] = useState("");
|
||||
const [parameterType, setParameterType] =
|
||||
useState<WorkflowParameterValueType>("string");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<span>
|
||||
Add {type === "workflow" ? "Workflow" : "Credential"} Parameter
|
||||
</span>
|
||||
<Cross2Icon className="h-6 w-6 cursor-pointer" onClick={onClose} />
|
||||
</header>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Key</Label>
|
||||
<Input value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{type === "workflow" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Value Type</Label>
|
||||
<Select
|
||||
value={parameterType}
|
||||
onValueChange={(value) =>
|
||||
setParameterType(value as WorkflowParameterValueType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workflowParameterTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{type === "credential" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">URL Parameter Key</Label>
|
||||
<Input
|
||||
value={urlParameterKey}
|
||||
onChange={(e) => setUrlParameterKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Collection ID</Label>
|
||||
<Input
|
||||
value={collectionId}
|
||||
onChange={(e) => setCollectionId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (type === "workflow") {
|
||||
onSave({
|
||||
key,
|
||||
parameterType: "workflow",
|
||||
dataType: parameterType,
|
||||
description,
|
||||
});
|
||||
}
|
||||
if (type === "credential") {
|
||||
onSave({
|
||||
key,
|
||||
parameterType: "credential",
|
||||
collectionId,
|
||||
urlParameterKey,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowParameterAddPanel };
|
||||
@@ -0,0 +1,149 @@
|
||||
import { Cross2Icon } from "@radix-ui/react-icons";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import { WorkflowParameterValueType } from "../../types/workflowTypes";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ParametersState } from "../FlowRenderer";
|
||||
|
||||
type Props = {
|
||||
type: "workflow" | "credential";
|
||||
onClose: () => void;
|
||||
onSave: (value: ParametersState[number]) => void;
|
||||
initialValues: ParametersState[number];
|
||||
};
|
||||
|
||||
const workflowParameterTypeOptions = [
|
||||
{ label: "string", value: WorkflowParameterValueType.String },
|
||||
{ label: "number", value: WorkflowParameterValueType.Float },
|
||||
{ label: "boolean", value: WorkflowParameterValueType.Boolean },
|
||||
{ label: "file", value: WorkflowParameterValueType.FileURL },
|
||||
{ label: "JSON", value: WorkflowParameterValueType.JSON },
|
||||
];
|
||||
|
||||
function WorkflowParameterEditPanel({
|
||||
type,
|
||||
onClose,
|
||||
onSave,
|
||||
initialValues,
|
||||
}: Props) {
|
||||
const [key, setKey] = useState(initialValues.key);
|
||||
const [urlParameterKey, setUrlParameterKey] = useState(
|
||||
initialValues.parameterType === "credential"
|
||||
? initialValues.urlParameterKey
|
||||
: "",
|
||||
);
|
||||
const [description, setDescription] = useState(
|
||||
initialValues.description || "",
|
||||
);
|
||||
const [collectionId, setCollectionId] = useState(
|
||||
initialValues.parameterType === "credential"
|
||||
? initialValues.collectionId
|
||||
: "",
|
||||
);
|
||||
const [parameterType, setParameterType] =
|
||||
useState<WorkflowParameterValueType>(
|
||||
initialValues.parameterType === "workflow"
|
||||
? initialValues.dataType
|
||||
: "string",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header className="flex items-center justify-between">
|
||||
<span>
|
||||
Edit {type === "workflow" ? "Workflow" : "Credential"} Parameter
|
||||
</span>
|
||||
<Cross2Icon className="h-6 w-6 cursor-pointer" onClick={onClose} />
|
||||
</header>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Key</Label>
|
||||
<Input value={key} onChange={(e) => setKey(e.target.value)} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Description</Label>
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{type === "workflow" && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Value Type</Label>
|
||||
<Select
|
||||
value={parameterType}
|
||||
onValueChange={(value) =>
|
||||
setParameterType(value as WorkflowParameterValueType)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{workflowParameterTypeOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{type === "credential" && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">URL Parameter Key</Label>
|
||||
<Input
|
||||
value={urlParameterKey}
|
||||
onChange={(e) => setUrlParameterKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">Collection ID</Label>
|
||||
<Input
|
||||
value={collectionId}
|
||||
onChange={(e) => setCollectionId(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (type === "workflow") {
|
||||
onSave({
|
||||
key,
|
||||
parameterType: "workflow",
|
||||
dataType: parameterType,
|
||||
description,
|
||||
});
|
||||
}
|
||||
if (type === "credential") {
|
||||
onSave({
|
||||
key,
|
||||
parameterType: "credential",
|
||||
urlParameterKey,
|
||||
collectionId,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowParameterEditPanel };
|
||||
@@ -1,45 +1,227 @@
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useWorkflowQuery } from "../../hooks/useWorkflowQuery";
|
||||
import { useState } from "react";
|
||||
import { useWorkflowParametersState } from "../useWorkflowParametersState";
|
||||
import { WorkflowParameterAddPanel } from "./WorkflowParameterAddPanel";
|
||||
import { ParametersState } from "../FlowRenderer";
|
||||
import { WorkflowParameterEditPanel } from "./WorkflowParameterEditPanel";
|
||||
import { MixerVerticalIcon, PlusIcon } from "@radix-ui/react-icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GarbageIcon } from "@/components/icons/GarbageIcon";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
|
||||
const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16;
|
||||
const WORKFLOW_EDIT_PANEL_GAP = 1 * 16;
|
||||
|
||||
function WorkflowParametersPanel() {
|
||||
const { workflowPermanentId } = useParams();
|
||||
|
||||
const { data: workflow, isLoading } = useWorkflowQuery({
|
||||
workflowPermanentId,
|
||||
const [workflowParameters, setWorkflowParameters] =
|
||||
useWorkflowParametersState();
|
||||
const [operationPanelState, setOperationPanelState] = useState<{
|
||||
active: boolean;
|
||||
operation: "add" | "edit";
|
||||
parameter?: ParametersState[number] | null;
|
||||
type: "workflow" | "credential";
|
||||
}>({
|
||||
active: false,
|
||||
operation: "add",
|
||||
parameter: null,
|
||||
type: "workflow",
|
||||
});
|
||||
|
||||
if (isLoading || !workflow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const workflowParameters = workflow.workflow_definition.parameters.filter(
|
||||
(parameter) => parameter.parameter_type === "workflow",
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
<h1 className="text-lg">Workflow Parameters</h1>
|
||||
<span className="text-sm text-slate-400">
|
||||
Create placeholder values that you can link in nodes. You will be
|
||||
prompted to fill them in before running your workflow.
|
||||
</span>
|
||||
</header>
|
||||
<section className="space-y-2">
|
||||
{workflowParameters.map((parameter) => {
|
||||
return (
|
||||
<div
|
||||
key={parameter.key}
|
||||
className="flex items-center gap-4 rounded-md bg-slate-elevation1 px-3 py-2"
|
||||
<div className="relative w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<div className="space-y-4">
|
||||
<header>
|
||||
<h1 className="text-lg">Workflow Parameters</h1>
|
||||
<span className="text-sm text-slate-400">
|
||||
Create placeholder values that you can link in nodes. You will be
|
||||
prompted to fill them in before running your workflow.
|
||||
</span>
|
||||
</header>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="w-full">
|
||||
<PlusIcon className="mr-2 h-6 w-6" />
|
||||
Add Parameter
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-60">
|
||||
<DropdownMenuLabel>Add Parameter</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOperationPanelState({
|
||||
active: true,
|
||||
operation: "add",
|
||||
type: "workflow",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="text-sm">{parameter.key}</span>
|
||||
<span className="text-sm text-slate-400">
|
||||
{parameter.workflow_parameter_type}
|
||||
</span>
|
||||
Workflow Parameter
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setOperationPanelState({
|
||||
active: true,
|
||||
operation: "add",
|
||||
type: "credential",
|
||||
});
|
||||
}}
|
||||
>
|
||||
Credential Parameter
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<section className="space-y-2">
|
||||
{workflowParameters.map((parameter) => {
|
||||
return (
|
||||
<div
|
||||
key={parameter.key}
|
||||
className="flex items-center justify-between rounded-md bg-slate-elevation1 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-sm">{parameter.key}</span>
|
||||
{parameter.parameterType === "workflow" ? (
|
||||
<span className="text-sm text-slate-400">
|
||||
{parameter.dataType}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-slate-400">
|
||||
{parameter.parameterType}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<MixerVerticalIcon
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setOperationPanelState({
|
||||
active: true,
|
||||
operation: "edit",
|
||||
parameter: parameter,
|
||||
type: parameter.parameterType,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<GarbageIcon className="size-4 cursor-pointer text-destructive-foreground text-red-600" />
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Are you sure?</DialogTitle>
|
||||
<DialogDescription>
|
||||
This parameter will be deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setWorkflowParameters(
|
||||
workflowParameters.filter(
|
||||
(p) => p.key !== parameter.key,
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
</div>
|
||||
{operationPanelState.active && (
|
||||
<div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: 0,
|
||||
left: -1 * (WORKFLOW_EDIT_PANEL_WIDTH + WORKFLOW_EDIT_PANEL_GAP),
|
||||
}}
|
||||
>
|
||||
{operationPanelState.operation === "add" && (
|
||||
<div className="w-80 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<WorkflowParameterAddPanel
|
||||
type={operationPanelState.type}
|
||||
onSave={(parameter) => {
|
||||
setWorkflowParameters([...workflowParameters, parameter]);
|
||||
setOperationPanelState({
|
||||
active: false,
|
||||
operation: "add",
|
||||
type: "workflow",
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setOperationPanelState({
|
||||
active: false,
|
||||
operation: "add",
|
||||
type: "workflow",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
)}
|
||||
{operationPanelState.operation === "edit" &&
|
||||
operationPanelState.parameter && (
|
||||
<div className="w-80 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
|
||||
<WorkflowParameterEditPanel
|
||||
type={operationPanelState.type}
|
||||
initialValues={operationPanelState.parameter}
|
||||
onSave={(editedParameter) => {
|
||||
setWorkflowParameters(
|
||||
workflowParameters.map((parameter) => {
|
||||
if (
|
||||
parameter.key === operationPanelState.parameter?.key
|
||||
) {
|
||||
return editedParameter;
|
||||
}
|
||||
return parameter;
|
||||
}),
|
||||
);
|
||||
setOperationPanelState({
|
||||
active: false,
|
||||
operation: "edit",
|
||||
parameter: null,
|
||||
type: "workflow",
|
||||
});
|
||||
}}
|
||||
onClose={() => {
|
||||
setOperationPanelState({
|
||||
active: false,
|
||||
operation: "edit",
|
||||
parameter: null,
|
||||
type: "workflow",
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
|
||||
|
||||
function useWorkflowParametersState() {
|
||||
const value = useContext(WorkflowParametersStateContext);
|
||||
if (value === undefined) {
|
||||
throw new Error(
|
||||
"useWorkflowParametersState must be used within a WorkflowParametersStateProvider",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export { useWorkflowParametersState };
|
||||
@@ -2,6 +2,18 @@ import { Edge } from "@xyflow/react";
|
||||
import { AppNode } from "./nodes";
|
||||
import Dagre from "@dagrejs/dagre";
|
||||
import type { WorkflowBlock } from "../types/workflowTypes";
|
||||
import { nodeTypes } from "./nodes";
|
||||
import { taskNodeDefaultData } from "./nodes/TaskNode/types";
|
||||
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
|
||||
import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types";
|
||||
import { downloadNodeDefaultData } from "./nodes/DownloadNode/types";
|
||||
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
|
||||
import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types";
|
||||
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
|
||||
import { fileParserNodeDefaultData } from "./nodes/FileParserNode/types";
|
||||
import { BlockYAML } from "../types/workflowYamlTypes";
|
||||
import { NodeAdderNode } from "./nodes/NodeAdderNode/types";
|
||||
import { REACT_FLOW_EDGE_Z_INDEX } from "./constants";
|
||||
|
||||
function layoutUtil(
|
||||
nodes: Array<AppNode>,
|
||||
@@ -52,8 +64,12 @@ function layout(
|
||||
(node) => node.id === edge.source || node.id === edge.target,
|
||||
),
|
||||
);
|
||||
const maxChildWidth = Math.max(
|
||||
...childNodes.map((node) => node.measured?.width ?? 0),
|
||||
);
|
||||
const loopNodeWidth = 60 * 16; // 60 rem
|
||||
const layouted = layoutUtil(childNodes, childEdges, {
|
||||
marginx: 240,
|
||||
marginx: (loopNodeWidth - maxChildWidth) / 2,
|
||||
marginy: 200,
|
||||
});
|
||||
loopNodeChildren[index] = layouted.nodes;
|
||||
@@ -75,6 +91,8 @@ function convertToNode(
|
||||
): AppNode {
|
||||
const common = {
|
||||
draggable: false,
|
||||
position: { x: 0, y: 0 },
|
||||
connectable: false,
|
||||
};
|
||||
switch (block.block_type) {
|
||||
case "task": {
|
||||
@@ -84,17 +102,17 @@ function convertToNode(
|
||||
type: "task",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
url: block.url ?? "",
|
||||
navigationGoal: block.navigation_goal ?? "",
|
||||
dataExtractionGoal: block.data_extraction_goal ?? "",
|
||||
dataSchema: block.data_schema ?? null,
|
||||
errorCodeMapping: block.error_code_mapping ?? null,
|
||||
dataSchema: JSON.stringify(block.data_schema, null, 2),
|
||||
errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
|
||||
allowDownloads: block.complete_on_download ?? false,
|
||||
maxRetries: block.max_retries ?? null,
|
||||
maxStepsOverride: block.max_steps_per_run ?? null,
|
||||
parameterKeys: block.parameters.map((p) => p.key),
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "code": {
|
||||
@@ -104,10 +122,9 @@ function convertToNode(
|
||||
type: "codeBlock",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
code: block.code,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "send_email": {
|
||||
@@ -117,13 +134,13 @@ function convertToNode(
|
||||
type: "sendEmail",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
body: block.body,
|
||||
fileAttachments: block.file_attachments,
|
||||
recipients: block.recipients,
|
||||
fileAttachments: block.file_attachments.join(", "),
|
||||
recipients: block.recipients.join(", "),
|
||||
subject: block.subject,
|
||||
sender: block.sender,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "text_prompt": {
|
||||
@@ -133,11 +150,10 @@ function convertToNode(
|
||||
type: "textPrompt",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
prompt: block.prompt,
|
||||
jsonSchema: block.json_schema ?? null,
|
||||
jsonSchema: JSON.stringify(block.json_schema, null, 2),
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "for_loop": {
|
||||
@@ -147,10 +163,9 @@ function convertToNode(
|
||||
type: "loop",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
loopValue: block.loop_over.key,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
case "file_url_parser": {
|
||||
@@ -160,10 +175,9 @@ function convertToNode(
|
||||
type: "fileParser",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
fileUrl: block.file_url,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -174,10 +188,9 @@ function convertToNode(
|
||||
type: "download",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
url: block.url,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -188,10 +201,9 @@ function convertToNode(
|
||||
type: "upload",
|
||||
data: {
|
||||
label: block.label,
|
||||
editable: false,
|
||||
editable: true,
|
||||
path: block.path,
|
||||
},
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -210,22 +222,274 @@ function getElements(
|
||||
nodes.push(convertToNode({ id, parentId }, block));
|
||||
if (block.block_type === "for_loop") {
|
||||
const subElements = getElements(block.loop_blocks, id);
|
||||
if (subElements.nodes.length === 0) {
|
||||
nodes.push({
|
||||
id: `${id}-nodeAdder`,
|
||||
type: "nodeAdder",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
});
|
||||
}
|
||||
nodes.push(...subElements.nodes);
|
||||
edges.push(...subElements.edges);
|
||||
}
|
||||
if (index !== blocks.length - 1) {
|
||||
edges.push({
|
||||
id: `edge-${id}-${nextId}`,
|
||||
type: "edgeWithAddButton",
|
||||
source: id,
|
||||
target: nextId,
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
zIndex: REACT_FLOW_EDGE_Z_INDEX,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (nodes.length > 0) {
|
||||
edges.push({
|
||||
id: "edge-nodeAdder",
|
||||
type: "default",
|
||||
source: nodes[nodes.length - 1]!.id,
|
||||
target: "nodeAdder",
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
},
|
||||
});
|
||||
nodes.push({
|
||||
id: "nodeAdder",
|
||||
type: "nodeAdder",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
draggable: false,
|
||||
connectable: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
export { getElements, layout };
|
||||
function createNode(
|
||||
identifiers: { id: string; parentId?: string },
|
||||
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">,
|
||||
labelPostfix: string, // unique label requirement
|
||||
): AppNode {
|
||||
const label = "Block " + labelPostfix;
|
||||
const common = {
|
||||
draggable: false,
|
||||
position: { x: 0, y: 0 },
|
||||
};
|
||||
switch (nodeType) {
|
||||
case "task": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "task",
|
||||
data: {
|
||||
...taskNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "loop": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "loop",
|
||||
data: {
|
||||
...loopNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "codeBlock": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "codeBlock",
|
||||
data: {
|
||||
...codeBlockNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "download": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "download",
|
||||
data: {
|
||||
...downloadNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "upload": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "upload",
|
||||
data: {
|
||||
...uploadNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "sendEmail": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "sendEmail",
|
||||
data: {
|
||||
...sendEmailNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "textPrompt": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "textPrompt",
|
||||
data: {
|
||||
...textPromptNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "fileParser": {
|
||||
return {
|
||||
...identifiers,
|
||||
...common,
|
||||
type: "fileParser",
|
||||
data: {
|
||||
...fileParserNodeDefaultData,
|
||||
label,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function JSONParseSafe(json: string): Record<string, unknown> | null {
|
||||
try {
|
||||
return JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkflowBlock(
|
||||
node: Exclude<AppNode, LoopNode | NodeAdderNode>,
|
||||
): BlockYAML {
|
||||
switch (node.type) {
|
||||
case "task": {
|
||||
return {
|
||||
block_type: "task",
|
||||
label: node.data.label,
|
||||
url: node.data.url,
|
||||
navigation_goal: node.data.navigationGoal,
|
||||
data_extraction_goal: node.data.dataExtractionGoal,
|
||||
data_schema: JSONParseSafe(node.data.dataSchema),
|
||||
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
|
||||
string,
|
||||
string
|
||||
> | null,
|
||||
max_retries: node.data.maxRetries ?? undefined,
|
||||
max_steps_per_run: node.data.maxStepsOverride,
|
||||
complete_on_download: node.data.allowDownloads,
|
||||
parameter_keys: node.data.parameterKeys,
|
||||
};
|
||||
}
|
||||
case "sendEmail": {
|
||||
return {
|
||||
block_type: "send_email",
|
||||
label: node.data.label,
|
||||
body: node.data.body,
|
||||
file_attachments: node.data.fileAttachments.split(","),
|
||||
recipients: node.data.recipients.split(","),
|
||||
subject: node.data.subject,
|
||||
sender: node.data.sender,
|
||||
};
|
||||
}
|
||||
case "codeBlock": {
|
||||
return {
|
||||
block_type: "code",
|
||||
label: node.data.label,
|
||||
code: node.data.code,
|
||||
};
|
||||
}
|
||||
case "download": {
|
||||
return {
|
||||
block_type: "download_to_s3",
|
||||
label: node.data.label,
|
||||
url: node.data.url,
|
||||
};
|
||||
}
|
||||
case "upload": {
|
||||
return {
|
||||
block_type: "upload_to_s3",
|
||||
label: node.data.label,
|
||||
path: node.data.path,
|
||||
};
|
||||
}
|
||||
case "fileParser": {
|
||||
return {
|
||||
block_type: "file_url_parser",
|
||||
label: node.data.label,
|
||||
file_url: node.data.fileUrl,
|
||||
file_type: "csv",
|
||||
};
|
||||
}
|
||||
case "textPrompt": {
|
||||
return {
|
||||
block_type: "text_prompt",
|
||||
label: node.data.label,
|
||||
llm_key: "",
|
||||
prompt: node.data.prompt,
|
||||
json_schema: JSONParseSafe(node.data.jsonSchema),
|
||||
};
|
||||
}
|
||||
default: {
|
||||
throw new Error("Invalid node type for getWorkflowBlock");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getWorkflowBlocksUtil(nodes: Array<AppNode>): Array<BlockYAML> {
|
||||
return nodes.flatMap((node) => {
|
||||
if (node.parentId) {
|
||||
return [];
|
||||
}
|
||||
if (node.type === "loop") {
|
||||
return [
|
||||
{
|
||||
block_type: "for_loop",
|
||||
label: node.data.label,
|
||||
loop_over_parameter_key: node.data.loopValue,
|
||||
loop_blocks: nodes
|
||||
.filter((n) => n.parentId === node.id)
|
||||
.map((n) => {
|
||||
return getWorkflowBlock(
|
||||
n as Exclude<AppNode, LoopNode | NodeAdderNode>,
|
||||
);
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
getWorkflowBlock(node as Exclude<AppNode, LoopNode | NodeAdderNode>),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function getWorkflowBlocks(nodes: Array<AppNode>): Array<BlockYAML> {
|
||||
return getWorkflowBlocksUtil(
|
||||
nodes.filter((node) => node.type !== "nodeAdder"),
|
||||
);
|
||||
}
|
||||
|
||||
export { getElements, layout, createNode, getWorkflowBlocks };
|
||||
|
||||
@@ -196,7 +196,7 @@ export type WorkflowBlock =
|
||||
| FileURLParserBlock;
|
||||
|
||||
export type WorkflowDefinition = {
|
||||
parameters: Array<WorkflowParameter>;
|
||||
parameters: Array<Parameter>;
|
||||
blocks: Array<WorkflowBlock>;
|
||||
};
|
||||
|
||||
@@ -211,6 +211,7 @@ export type WorkflowApiResponse = {
|
||||
workflow_definition: WorkflowDefinition;
|
||||
proxy_location: string;
|
||||
webhook_callback_url: string;
|
||||
totp_verification_url: string;
|
||||
created_at: string;
|
||||
modified_at: string;
|
||||
deleted_at: string | null;
|
||||
|
||||
133
skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts
Normal file
133
skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
export type WorkflowCreateYAMLRequest = {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
proxy_location?: string | null;
|
||||
webhook_callback_url?: string | null;
|
||||
totp_verification_url?: string | null;
|
||||
workflow_definition: WorkflowDefinitionYAML;
|
||||
is_saved_task?: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowDefinitionYAML = {
|
||||
parameters: Array<ParameterYAML>;
|
||||
blocks: Array<BlockYAML>;
|
||||
};
|
||||
|
||||
export type ParameterYAML =
|
||||
| WorkflowParameterYAML
|
||||
| BitwardenLoginCredentialParameterYAML;
|
||||
|
||||
export type ParameterYAMLBase = {
|
||||
parameter_type: string;
|
||||
key: string;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowParameterYAML = ParameterYAMLBase & {
|
||||
parameter_type: "workflow";
|
||||
workflow_parameter_type: string;
|
||||
default_value: string | null;
|
||||
};
|
||||
|
||||
export type BitwardenLoginCredentialParameterYAML = ParameterYAMLBase & {
|
||||
parameter_type: "bitwarden_login_credential";
|
||||
bitwarden_collection_id: string;
|
||||
url_parameter_key: string;
|
||||
bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID";
|
||||
bitwarden_client_secret_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_SECRET";
|
||||
bitwarden_master_password_aws_secret_key: "SKYVERN_BITWARDEN_MASTER_PASSWORD";
|
||||
};
|
||||
|
||||
const BlockTypes = {
|
||||
TASK: "task",
|
||||
FOR_LOOP: "for_loop",
|
||||
CODE: "code",
|
||||
TEXT_PROMPT: "text_prompt",
|
||||
DOWNLOAD_TO_S3: "download_to_s3",
|
||||
UPLOAD_TO_S3: "upload_to_s3",
|
||||
SEND_EMAIL: "send_email",
|
||||
FILE_URL_PARSER: "file_url_parser",
|
||||
} as const;
|
||||
|
||||
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
|
||||
|
||||
export type BlockYAML =
|
||||
| TaskBlockYAML
|
||||
| CodeBlockYAML
|
||||
| TextPromptBlockYAML
|
||||
| DownloadToS3BlockYAML
|
||||
| UploadToS3BlockYAML
|
||||
| SendEmailBlockYAML
|
||||
| FileUrlParserBlockYAML
|
||||
| ForLoopBlockYAML;
|
||||
|
||||
export type BlockYAMLBase = {
|
||||
block_type: BlockType;
|
||||
label: string;
|
||||
continue_on_failure?: boolean;
|
||||
};
|
||||
|
||||
export type TaskBlockYAML = BlockYAMLBase & {
|
||||
block_type: "task";
|
||||
url: string | null;
|
||||
title?: string;
|
||||
navigation_goal: string | null;
|
||||
data_extraction_goal: string | null;
|
||||
data_schema: Record<string, unknown> | null;
|
||||
error_code_mapping: Record<string, string> | null;
|
||||
max_retries?: number;
|
||||
max_steps_per_run?: number | null;
|
||||
parameter_keys?: Array<string> | null;
|
||||
complete_on_download?: boolean;
|
||||
};
|
||||
|
||||
export type CodeBlockYAML = BlockYAMLBase & {
|
||||
block_type: "code";
|
||||
code: string;
|
||||
parameter_keys?: Array<string> | null;
|
||||
};
|
||||
|
||||
export type TextPromptBlockYAML = BlockYAMLBase & {
|
||||
block_type: "text_prompt";
|
||||
llm_key: string;
|
||||
prompt: string;
|
||||
json_schema?: Record<string, unknown> | null;
|
||||
parameter_keys?: Array<string> | null;
|
||||
};
|
||||
|
||||
export type DownloadToS3BlockYAML = BlockYAMLBase & {
|
||||
block_type: "download_to_s3";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type UploadToS3BlockYAML = BlockYAMLBase & {
|
||||
block_type: "upload_to_s3";
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
export type SendEmailBlockYAML = BlockYAMLBase & {
|
||||
block_type: "send_email";
|
||||
|
||||
smtp_host_secret_parameter_key?: string;
|
||||
smtp_port_secret_parameter_key?: string;
|
||||
smtp_username_secret_parameter_key?: string;
|
||||
smtp_password_secret_parameter_key?: string;
|
||||
|
||||
sender: string;
|
||||
recipients: Array<string>;
|
||||
subject: string;
|
||||
body: string;
|
||||
file_attachments?: Array<string> | null;
|
||||
};
|
||||
|
||||
export type FileUrlParserBlockYAML = BlockYAMLBase & {
|
||||
block_type: "file_url_parser";
|
||||
file_url: string;
|
||||
file_type: "csv";
|
||||
};
|
||||
|
||||
export type ForLoopBlockYAML = BlockYAMLBase & {
|
||||
block_type: "for_loop";
|
||||
loop_over_parameter_key: string;
|
||||
loop_blocks: Array<BlockYAML>;
|
||||
};
|
||||
Reference in New Issue
Block a user