Add workflows settings in start node (#1270)

This commit is contained in:
Shuchang Zheng
2024-11-26 10:41:18 -08:00
committed by GitHub
parent 61f94af7f3
commit e0aadac962
13 changed files with 275 additions and 42 deletions

View File

@@ -10,12 +10,13 @@ import {
type Props = {
value: ProxyLocation | null;
onChange: (value: ProxyLocation) => void;
className?: string;
};
function ProxySelector({ value, onChange }: Props) {
function ProxySelector({ value, onChange, className }: Props) {
return (
<Select value={value ?? ""} onValueChange={onChange}>
<SelectTrigger className="w-48">
<SelectTrigger className={className}>
<SelectValue placeholder="Proxy Location" />
</SelectTrigger>
<SelectContent>

View File

@@ -514,6 +514,7 @@ function CreateNewTaskForm({ initialValues }: Props) {
<ProxySelector
value={field.value}
onChange={field.onChange}
className="w-48"
/>
</FormControl>
<FormMessage />

View File

@@ -686,6 +686,7 @@ function SavedTaskForm({ initialValues }: Props) {
<ProxySelector
value={field.value}
onChange={field.onChange}
className="w-48"
/>
</FormControl>
<FormMessage />

View File

@@ -1,4 +1,7 @@
import { getClient } from "@/api/AxiosClient";
import { ProxyLocation } from "@/api/types";
import { ProxySelector } from "@/components/ProxySelector";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
@@ -7,28 +10,29 @@ import {
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useForm } from "react-hook-form";
import { Link, useParams } from "react-router-dom";
import { WorkflowParameterInput } from "./WorkflowParameterInput";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { useApiCredential } from "@/hooks/useApiCredential";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { copyText } from "@/util/copyText";
import { apiBaseUrl } from "@/util/env";
import { CopyIcon, PlayIcon, ReloadIcon } from "@radix-ui/react-icons";
import { ToastAction } from "@radix-ui/react-toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import fetchToCurl from "fetch-to-curl";
import { apiBaseUrl } from "@/util/env";
import { useApiCredential } from "@/hooks/useApiCredential";
import { copyText } from "@/util/copyText";
import { WorkflowParameter } from "./types/workflowTypes";
import { Input } from "@/components/ui/input";
import { useForm } from "react-hook-form";
import { Link, useParams } from "react-router-dom";
import { z } from "zod";
import { ProxyLocation } from "@/api/types";
import { ProxySelector } from "@/components/ProxySelector";
import { WorkflowParameter } from "./types/workflowTypes";
import { WorkflowParameterInput } from "./WorkflowParameterInput";
type Props = {
workflowParameters: Array<WorkflowParameter>;
initialValues: Record<string, unknown>;
initialSettings: {
proxyLocation: ProxyLocation;
webhookCallbackUrl: string;
};
};
function parseValuesForWorkflowRun(
@@ -92,19 +96,23 @@ function getRunWorkflowRequestBody(
}
type RunWorkflowFormType = Record<string, unknown> & {
webhookCallbackUrl: string | null;
proxyLocation: ProxyLocation | null;
webhookCallbackUrl: string;
proxyLocation: ProxyLocation;
};
function RunWorkflowForm({ workflowParameters, initialValues }: Props) {
function RunWorkflowForm({
workflowParameters,
initialValues,
initialSettings,
}: Props) {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const form = useForm<RunWorkflowFormType>({
defaultValues: {
...initialValues,
webhookCallbackUrl: null,
proxyLocation: ProxyLocation.Residential,
webhookCallbackUrl: initialSettings.webhookCallbackUrl,
proxyLocation: initialSettings.proxyLocation,
},
});
const apiCredential = useApiCredential();
@@ -313,6 +321,7 @@ function RunWorkflowForm({ workflowParameters, initialValues }: Props) {
<ProxySelector
value={field.value}
onChange={field.onChange}
className="w-48"
/>
</FormControl>
<FormMessage />

View File

@@ -5,6 +5,7 @@ import { useLocation, useParams } from "react-router-dom";
import { RunWorkflowForm } from "./RunWorkflowForm";
import { WorkflowApiResponse } from "./types/workflowTypes";
import { Skeleton } from "@/components/ui/skeleton";
import { ProxyLocation } from "@/api/types";
function WorkflowRunParameters() {
const credentialGetter = useCredentialGetter();
@@ -93,6 +94,10 @@ function WorkflowRunParameters() {
<RunWorkflowForm
initialValues={initialValues}
workflowParameters={workflowParameters}
initialSettings={{
proxyLocation: workflow.proxy_location ?? ProxyLocation.Residential,
webhookCallbackUrl: workflow.webhook_callback_url ?? "",
}}
/>
</div>
);

View File

@@ -37,6 +37,7 @@ import {
AWSSecretParameter,
WorkflowApiResponse,
WorkflowParameterValueType,
WorkflowSettings,
} from "../types/workflowTypes";
import {
BitwardenLoginCredentialParameterYAML,
@@ -50,7 +51,12 @@ import {
import { WorkflowHeader } from "./WorkflowHeader";
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
import { edgeTypes } from "./edges";
import { AppNode, nodeTypes, WorkflowBlockNode } from "./nodes";
import {
AppNode,
isWorkflowBlockNode,
nodeTypes,
WorkflowBlockNode,
} from "./nodes";
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import "./reactFlowOverrideStyles.css";
@@ -63,6 +69,7 @@ import {
getOutputParameterKey,
getWorkflowBlocks,
getWorkflowErrors,
getWorkflowSettings,
layout,
nodeAdderNode,
startNode,
@@ -200,6 +207,7 @@ function FlowRenderer({
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
title: string;
settings: WorkflowSettings;
}) => {
if (!workflowPermanentId) {
return;
@@ -208,8 +216,9 @@ function FlowRenderer({
const requestBody: WorkflowCreateYAMLRequest = {
title: data.title,
description: workflow.description,
proxy_location: workflow.proxy_location,
webhook_callback_url: workflow.webhook_callback_url,
proxy_location: data.settings.proxyLocation,
webhook_callback_url: data.settings.webhookCallbackUrl,
persist_browser_session: data.settings.persistBrowserSession,
totp_verification_url: workflow.totp_verification_url,
workflow_definition: {
parameters: data.parameters,
@@ -267,6 +276,7 @@ function FlowRenderer({
async function handleSave() {
const blocks = getWorkflowBlocks(nodes, edges);
const settings = getWorkflowSettings(nodes);
const parametersInYAMLConvertibleJSON = convertToParametersYAML(parameters);
const filteredParameters = workflow.workflow_definition.parameters.filter(
(parameter) => {
@@ -295,6 +305,7 @@ function FlowRenderer({
],
blocks,
title,
settings,
});
}
@@ -308,10 +319,13 @@ function FlowRenderer({
const newNodes: Array<AppNode> = [];
const newEdges: Array<Edge> = [];
const id = nanoid();
const existingLabels = nodes
.filter(isWorkflowBlockNode)
.map((node) => node.data.label);
const node = createNode(
{ id, parentId: parent },
nodeType,
generateNodeLabel(nodes.map((node) => node.data.label)),
generateNodeLabel(existingLabels),
);
newNodes.push(node);
if (previous) {
@@ -343,7 +357,9 @@ function FlowRenderer({
// when loop node is first created it needs an adder node so nodes can be added inside the loop
const startNodeId = nanoid();
const adderNodeId = nanoid();
newNodes.push(startNode(startNodeId, id));
newNodes.push(
startNode(startNodeId, { withWorkflowSettings: false }, id),
);
newNodes.push(nodeAdderNode(adderNodeId, id));
newEdges.push(defaultEdge(startNodeId, adderNodeId));
}
@@ -370,7 +386,7 @@ function FlowRenderer({
function deleteNode(id: string) {
const node = nodes.find((node) => node.id === id);
if (!node) {
if (!node || !isWorkflowBlockNode(node)) {
return;
}
const deletedNodeLabel = node.data.label;

View File

@@ -7,6 +7,7 @@ import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { FlowRenderer } from "./FlowRenderer";
import { getElements } from "./workflowEditorUtils";
import { LogoMinimized } from "@/components/LogoMinimized";
import { WorkflowSettings } from "../types/workflowTypes";
function WorkflowEditor() {
const { workflowPermanentId } = useParams();
@@ -39,7 +40,13 @@ function WorkflowEditor() {
return null;
}
const elements = getElements(workflow.workflow_definition.blocks);
const settings: WorkflowSettings = {
persistBrowserSession: workflow.persist_browser_session,
proxyLocation: workflow.proxy_location,
webhookCallbackUrl: workflow.webhook_callback_url,
};
const elements = getElements(workflow.workflow_definition.blocks, settings);
return (
<div className="h-screen w-full">

View File

@@ -1,6 +1,109 @@
import { Handle, Position } from "@xyflow/react";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { StartNode } from "./types";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { useState } from "react";
import { ProxyLocation } from "@/api/types";
import { Label } from "@/components/ui/label";
import { HelpTooltip } from "@/components/HelpTooltip";
import { Input } from "@/components/ui/input";
import { ProxySelector } from "@/components/ProxySelector";
import { Switch } from "@/components/ui/switch";
import { Separator } from "@/components/ui/separator";
function StartNode({ id, data }: NodeProps<StartNode>) {
const { updateNodeData } = useReactFlow();
const [inputs, setInputs] = useState({
webhookCallbackUrl: data.withWorkflowSettings
? data.webhookCallbackUrl
: "",
proxyLocation: data.withWorkflowSettings
? data.proxyLocation
: ProxyLocation.Residential,
persistBrowserSession: data.withWorkflowSettings
? data.persistBrowserSession
: false,
});
function handleChange(key: string, value: unknown) {
setInputs({ ...inputs, [key]: value });
updateNodeData(id, { [key]: value });
}
if (data.withWorkflowSettings) {
return (
<div>
<Handle
type="source"
position={Position.Bottom}
id="a"
className="opacity-0"
/>
<div className="w-[30rem] rounded-lg bg-slate-elevation3 px-6 py-4 text-center">
<div className="space-y-4">
<header>Start</header>
<Separator />
<Accordion type="single" collapsible>
<AccordionItem value="settings" className="border-b-0">
<AccordionTrigger className="py-2">
Workflow Settings
</AccordionTrigger>
<AccordionContent className="pl-6 pr-1 pt-1">
<div className="space-y-4">
<div className="space-y-2">
<div className="flex gap-2">
<Label>Webhook Callback URL</Label>
<HelpTooltip content="The URL of a webhook endpoint to send the workflow results" />
</div>
<Input
value={inputs.webhookCallbackUrl}
placeholder="https://"
onChange={(event) => {
handleChange(
"webhookCallbackUrl",
event.target.value,
);
}}
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label>Proxy Location</Label>
<HelpTooltip content="Route Skyvern through one of our available proxies." />
</div>
<ProxySelector
value={inputs.proxyLocation}
onChange={(value) => {
handleChange("proxyLocation", value);
}}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label>Persist Browser Session</Label>
<HelpTooltip content="Persist session information across workflow runs" />
<Switch
checked={inputs.persistBrowserSession}
onCheckedChange={(value) => {
handleChange("persistBrowserSession", value);
}}
/>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
);
}
function StartNode() {
return (
<div>
<Handle

View File

@@ -1,5 +1,28 @@
import { ProxyLocation } from "@/api/types";
import type { Node } from "@xyflow/react";
import { AppNode } from "..";
export type StartNodeData = Record<string, never>;
export type WorkflowStartNodeData = {
withWorkflowSettings: true;
webhookCallbackUrl: string;
proxyLocation: ProxyLocation;
persistBrowserSession: boolean;
};
export type OtherStartNodeData = {
withWorkflowSettings: false;
};
export type StartNodeData = WorkflowStartNodeData | OtherStartNodeData;
export type StartNode = Node<StartNodeData, "start">;
export function isStartNode(node: AppNode): node is StartNode {
return node.type === "start";
}
export function isWorkflowStartNodeData(
data: StartNodeData,
): data is WorkflowStartNodeData {
return data.withWorkflowSettings;
}

View File

@@ -9,6 +9,7 @@ import type {
WorkflowApiResponse,
WorkflowBlock,
WorkflowParameterValueType,
WorkflowSettings,
} from "../types/workflowTypes";
import {
ActionBlockYAML,
@@ -53,7 +54,12 @@ import {
} from "./nodes/LoopNode/types";
import { NodeAdderNode } from "./nodes/NodeAdderNode/types";
import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types";
import { StartNode } from "./nodes/StartNode/types";
import {
isStartNode,
isWorkflowStartNodeData,
StartNode,
StartNodeData,
} from "./nodes/StartNode/types";
import { isTaskNode, taskNodeDefaultData } from "./nodes/TaskNode/types";
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
import { NodeBaseData } from "./nodes/types";
@@ -73,6 +79,7 @@ import {
} from "./nodes/ExtractionNode/types";
import { loginNodeDefaultData } from "./nodes/LoginNode/types";
import { waitNodeDefaultData } from "./nodes/WaitNode/types";
import { ProxyLocation } from "@/api/types";
export const NEW_NODE_LABEL_PREFIX = "block_";
@@ -458,12 +465,16 @@ export function edgeWithAddButton(source: string, target: string) {
};
}
export function startNode(id: string, parentId?: string): StartNode {
export function startNode(
id: string,
data: StartNodeData,
parentId?: string,
): StartNode {
const node: StartNode = {
id,
type: "start",
position: { x: 0, y: 0 },
data: {},
data,
draggable: false,
connectable: false,
};
@@ -488,7 +499,10 @@ export function nodeAdderNode(id: string, parentId?: string): NodeAdderNode {
return node;
}
function getElements(blocks: Array<WorkflowBlock>): {
function getElements(
blocks: Array<WorkflowBlock>,
settings: WorkflowSettings,
): {
nodes: Array<AppNode>;
edges: Array<Edge>;
} {
@@ -497,7 +511,14 @@ function getElements(blocks: Array<WorkflowBlock>): {
const edges: Array<Edge> = [];
const startNodeId = nanoid();
nodes.push(startNode(startNodeId));
nodes.push(
startNode(startNodeId, {
withWorkflowSettings: true,
persistBrowserSession: settings.persistBrowserSession,
proxyLocation: settings.proxyLocation ?? ProxyLocation.Residential,
webhookCallbackUrl: settings.webhookCallbackUrl ?? "",
}),
);
data.forEach((d, index) => {
const node = convertToNode(
@@ -519,7 +540,15 @@ function getElements(blocks: Array<WorkflowBlock>): {
const loopBlocks = data.filter((d) => d.block.block_type === "for_loop");
loopBlocks.forEach((block) => {
const startNodeId = nanoid();
nodes.push(startNode(startNodeId, block.id));
nodes.push(
startNode(
startNodeId,
{
withWorkflowSettings: false,
},
block.id,
),
);
const children = data.filter((b) => b.parentId === block.id);
if (children.length === 0) {
const adderNodeId = nanoid();
@@ -555,7 +584,7 @@ function createNode(
identifiers: { id: string; parentId?: string },
nodeType: NonNullable<WorkflowBlockNode["type"]>,
label: string,
): AppNode {
): WorkflowBlockNode {
const common = {
draggable: false,
position: { x: 0, y: 0 },
@@ -991,6 +1020,30 @@ function getWorkflowBlocks(
return getWorkflowBlocksUtil(nodes, edges);
}
function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
const defaultSettings = {
persistBrowserSession: false,
proxyLocation: ProxyLocation.Residential,
webhookCallbackUrl: null,
};
const startNodes = nodes.filter(isStartNode);
const startNodeWithWorkflowSettings = startNodes.find(
(node) => node.data.withWorkflowSettings,
);
if (!startNodeWithWorkflowSettings) {
return defaultSettings;
}
const data = startNodeWithWorkflowSettings.data;
if (isWorkflowStartNodeData(data)) {
return {
persistBrowserSession: data.persistBrowserSession,
proxyLocation: data.proxyLocation,
webhookCallbackUrl: data.webhookCallbackUrl,
};
}
return defaultSettings;
}
function generateNodeLabel(existingLabels: Array<string>) {
for (let i = 1; i < existingLabels.length + 2; i++) {
const label = NEW_NODE_LABEL_PREFIX + i;
@@ -1608,6 +1661,7 @@ export {
getBlockNameOfOutputParameterKey,
getDefaultValueForParameterType,
getElements,
getWorkflowSettings,
getOutputParameterKey,
getPreviousNodeIds,
getUniqueLabelForExistingNode,

View File

@@ -1,5 +1,5 @@
import { useNodes, useReactFlow } from "@xyflow/react";
import { AppNode } from "../editor/nodes";
import { AppNode, isWorkflowBlockNode } from "../editor/nodes";
import {
getUniqueLabelForExistingNode,
getUpdatedNodesAfterLabelUpdateForParameterKeys,
@@ -21,7 +21,9 @@ function useNodeLabelChangeHandler({ id, initialValue }: Props) {
useWorkflowParametersState();
function handleLabelChange(value: string) {
const existingLabels = nodes.map((n) => n.data.label);
const existingLabels = nodes
.filter(isWorkflowBlockNode)
.map((n) => n.data.label);
const labelWithoutWhitespace = value.replace(/\s+/g, "_");
const newLabel = getUniqueLabelForExistingNode(
labelWithoutWhitespace,

View File

@@ -1,3 +1,5 @@
import { ProxyLocation } from "@/api/types";
export type WorkflowParameterBase = {
parameter_type: WorkflowParameterType;
key: string;
@@ -296,14 +298,22 @@ export type WorkflowApiResponse = {
version: number;
description: string;
workflow_definition: WorkflowDefinition;
proxy_location: string;
webhook_callback_url: string;
totp_verification_url: string;
proxy_location: ProxyLocation | null;
webhook_callback_url: string | null;
persist_browser_session: boolean;
totp_verification_url: string | null;
totp_identifier: string | null;
created_at: string;
modified_at: string;
deleted_at: string | null;
};
export type WorkflowSettings = {
proxyLocation: ProxyLocation | null;
webhookCallbackUrl: string | null;
persistBrowserSession: boolean;
};
export function isOutputParameter(
parameter: Parameter,
): parameter is OutputParameter {

View File

@@ -3,6 +3,7 @@ export type WorkflowCreateYAMLRequest = {
description?: string | null;
proxy_location?: string | null;
webhook_callback_url?: string | null;
persist_browser_session?: boolean;
totp_verification_url?: string | null;
workflow_definition: WorkflowDefinitionYAML;
is_saved_task?: boolean;