Add action block (#1243)
This commit is contained in:
@@ -15,7 +15,7 @@ function HelpTooltip({ content }: Props) {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<QuestionMarkCircledIcon className="h-4 w-4" />
|
<QuestionMarkCircledIcon className="size-4" />
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent className="max-w-[250px]">{content}</TooltipContent>
|
<TooltipContent className="max-w-[250px]">{content}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
26
skyvern-frontend/src/components/icons/ClickIcon.tsx
Normal file
26
skyvern-frontend/src/components/icons/ClickIcon.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
type Props = {
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ClickIcon({ className }: Props) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M9 3.5V2M5.06066 5.06066L4 4M5.06066 13L4 14.0607M13 5.06066L14.0607 4M3.5 9H2M8.5 8.5L12.6111 21.2778L15.5 18.3889L19.1111 22L22 19.1111L18.3889 15.5L21.2778 12.6111L8.5 8.5Z"
|
||||||
|
stroke="#F8FAFC"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ClickIcon };
|
||||||
@@ -13,15 +13,17 @@ const TooltipContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||||
<TooltipPrimitive.Content
|
<TooltipPrimitive.Portal>
|
||||||
ref={ref}
|
<TooltipPrimitive.Content
|
||||||
sideOffset={sideOffset}
|
ref={ref}
|
||||||
className={cn(
|
sideOffset={sideOffset}
|
||||||
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
className={cn(
|
||||||
className,
|
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
));
|
));
|
||||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ import { isLoopNode, LoopNode } from "./nodes/LoopNode/types";
|
|||||||
import { isTaskNode } from "./nodes/TaskNode/types";
|
import { isTaskNode } from "./nodes/TaskNode/types";
|
||||||
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
|
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
|
||||||
import { isValidationNode } from "./nodes/ValidationNode/types";
|
import { isValidationNode } from "./nodes/ValidationNode/types";
|
||||||
|
import { isActionNode } from "./nodes/ActionNode/types";
|
||||||
|
|
||||||
function convertToParametersYAML(
|
function convertToParametersYAML(
|
||||||
parameters: ParametersState,
|
parameters: ParametersState,
|
||||||
@@ -459,13 +460,28 @@ function FlowRenderer({
|
|||||||
const errors: Array<string> = [];
|
const errors: Array<string> = [];
|
||||||
|
|
||||||
const workflowBlockNodes = nodes.filter(isWorkflowBlockNode);
|
const workflowBlockNodes = nodes.filter(isWorkflowBlockNode);
|
||||||
if (workflowBlockNodes[0]!.type === "validation") {
|
if (
|
||||||
|
workflowBlockNodes.length > 0 &&
|
||||||
|
workflowBlockNodes[0]!.type === "validation"
|
||||||
|
) {
|
||||||
const label = workflowBlockNodes[0]!.data.label;
|
const label = workflowBlockNodes[0]!.data.label;
|
||||||
errors.push(
|
errors.push(
|
||||||
`${label}: Validation block can't be the first block in a workflow`,
|
`${label}: Validation block can't be the first block in a workflow.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionNodes = nodes.filter(isActionNode);
|
||||||
|
actionNodes.forEach((node) => {
|
||||||
|
if (node.data.navigationGoal.length === 0) {
|
||||||
|
errors.push(`${node.data.label}: Action Instruction is required.`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
JSON.parse(node.data.errorCodeMapping);
|
||||||
|
} catch {
|
||||||
|
errors.push(`${node.data.label}: Error messages is not valid JSON.`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// check loop node parameters
|
// check loop node parameters
|
||||||
const loopNodes: Array<LoopNode> = nodes.filter(isLoopNode);
|
const loopNodes: Array<LoopNode> = nodes.filter(isLoopNode);
|
||||||
const emptyLoopNodes = loopNodes.filter(
|
const emptyLoopNodes = loopNodes.filter(
|
||||||
@@ -474,7 +490,7 @@ function FlowRenderer({
|
|||||||
if (emptyLoopNodes.length > 0) {
|
if (emptyLoopNodes.length > 0) {
|
||||||
emptyLoopNodes.forEach((node) => {
|
emptyLoopNodes.forEach((node) => {
|
||||||
errors.push(
|
errors.push(
|
||||||
`${node.data.label}: Loop value parameter must be selected`,
|
`${node.data.label}: Loop value parameter must be selected.`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -485,12 +501,12 @@ function FlowRenderer({
|
|||||||
try {
|
try {
|
||||||
JSON.parse(node.data.dataSchema);
|
JSON.parse(node.data.dataSchema);
|
||||||
} catch {
|
} catch {
|
||||||
errors.push(`${node.data.label}: Data schema is not valid JSON`);
|
errors.push(`${node.data.label}: Data schema is not valid JSON.`);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
JSON.parse(node.data.errorCodeMapping);
|
JSON.parse(node.data.errorCodeMapping);
|
||||||
} catch {
|
} catch {
|
||||||
errors.push(`${node.data.label}: Error messages is not valid JSON`);
|
errors.push(`${node.data.label}: Error messages is not valid JSON.`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@/components/ui/accordion";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
|
||||||
|
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
|
||||||
|
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import { helpTooltipContent, type ActionNode } from "./types";
|
||||||
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { fieldPlaceholders } from "./types";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { errorMappingExampleValue } from "../types";
|
||||||
|
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { ClickIcon } from "@/components/icons/ClickIcon";
|
||||||
|
|
||||||
|
function ActionNode({ id, data }: NodeProps<ActionNode>) {
|
||||||
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const { editable } = data;
|
||||||
|
const [label, setLabel] = useNodeLabelChangeHandler({
|
||||||
|
id,
|
||||||
|
initialValue: data.label,
|
||||||
|
});
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
navigationGoal: data.navigationGoal,
|
||||||
|
errorCodeMapping: data.errorCodeMapping,
|
||||||
|
maxRetries: data.maxRetries,
|
||||||
|
allowDownloads: data.allowDownloads,
|
||||||
|
continueOnFailure: data.continueOnFailure,
|
||||||
|
cacheActions: data.cacheActions,
|
||||||
|
downloadSuffix: data.downloadSuffix,
|
||||||
|
totpVerificationUrl: data.totpVerificationUrl,
|
||||||
|
totpIdentifier: data.totpIdentifier,
|
||||||
|
});
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputs({ ...inputs, [key]: value });
|
||||||
|
updateNodeData(id, { [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
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="w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4">
|
||||||
|
<header className="flex h-[2.75rem] justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
|
||||||
|
<ClickIcon className="size-6" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<EditableNodeTitle
|
||||||
|
value={label}
|
||||||
|
editable={editable}
|
||||||
|
onChange={setLabel}
|
||||||
|
titleClassName="text-base"
|
||||||
|
inputClassName="text-base"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">Action Block</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<NodeActionMenu
|
||||||
|
onDelete={() => {
|
||||||
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</header>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Action Instruction</Label>
|
||||||
|
<HelpTooltip content={helpTooltipContent["navigationGoal"]} />
|
||||||
|
</div>
|
||||||
|
<AutoResizingTextarea
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("navigationGoal", event.target.value);
|
||||||
|
}}
|
||||||
|
value={inputs.navigationGoal}
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="advanced" className="border-b-0">
|
||||||
|
<AccordionTrigger className="py-0">
|
||||||
|
Advanced Settings
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Max Retries
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={helpTooltipContent["maxRetries"]} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder={fieldPlaceholders["maxRetries"]}
|
||||||
|
className="nopan w-52 text-xs"
|
||||||
|
min="0"
|
||||||
|
value={inputs.maxRetries ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value =
|
||||||
|
event.target.value === ""
|
||||||
|
? null
|
||||||
|
: Number(event.target.value);
|
||||||
|
handleChange("maxRetries", value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Error Messages
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={helpTooltipContent["errorCodeMapping"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Checkbox
|
||||||
|
checked={inputs.errorCodeMapping !== "null"}
|
||||||
|
disabled={!editable}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange(
|
||||||
|
"errorCodeMapping",
|
||||||
|
checked
|
||||||
|
? JSON.stringify(errorMappingExampleValue, null, 2)
|
||||||
|
: "null",
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{inputs.errorCodeMapping !== "null" && (
|
||||||
|
<div>
|
||||||
|
<CodeEditor
|
||||||
|
language="json"
|
||||||
|
value={inputs.errorCodeMapping}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("errorCodeMapping", value);
|
||||||
|
}}
|
||||||
|
className="nowheel nopan"
|
||||||
|
fontSize={8}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Continue on Failure
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={helpTooltipContent["continueOnFailure"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-52">
|
||||||
|
<Switch
|
||||||
|
checked={inputs.continueOnFailure}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("continueOnFailure", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Cache Actions
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={helpTooltipContent["cacheActions"]} />
|
||||||
|
</div>
|
||||||
|
<div className="w-52">
|
||||||
|
<Switch
|
||||||
|
checked={inputs.cacheActions}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("cacheActions", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
Complete on Download
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={helpTooltipContent["completeOnDownload"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-52">
|
||||||
|
<Switch
|
||||||
|
checked={inputs.allowDownloads}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("allowDownloads", checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs font-normal text-slate-300">
|
||||||
|
File Suffix
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={helpTooltipContent["fileSuffix"]} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={fieldPlaceholders["downloadSuffix"]}
|
||||||
|
className="nopan w-52 text-xs"
|
||||||
|
value={inputs.downloadSuffix ?? ""}
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("downloadSuffix", event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
2FA Verification URL
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={helpTooltipContent["totpVerificationUrl"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutoResizingTextarea
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("totpVerificationUrl", event.target.value);
|
||||||
|
}}
|
||||||
|
value={inputs.totpVerificationUrl ?? ""}
|
||||||
|
placeholder={fieldPlaceholders["totpVerificationUrl"]}
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
2FA Identifier
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={helpTooltipContent["totpIdentifier"]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<AutoResizingTextarea
|
||||||
|
onChange={(event) => {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleChange("totpIdentifier", event.target.value);
|
||||||
|
}}
|
||||||
|
value={inputs.totpIdentifier ?? ""}
|
||||||
|
placeholder={fieldPlaceholders["totpIdentifier"]}
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ActionNode };
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import { NodeBaseData } from "../types";
|
||||||
|
|
||||||
|
export type ActionNodeData = NodeBaseData & {
|
||||||
|
url: string;
|
||||||
|
navigationGoal: string;
|
||||||
|
errorCodeMapping: string;
|
||||||
|
maxRetries: number | null;
|
||||||
|
allowDownloads: boolean;
|
||||||
|
downloadSuffix: string | null;
|
||||||
|
parameterKeys: Array<string>;
|
||||||
|
totpVerificationUrl: string | null;
|
||||||
|
totpIdentifier: string | null;
|
||||||
|
cacheActions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ActionNode = Node<ActionNodeData, "action">;
|
||||||
|
|
||||||
|
export const actionNodeDefaultData: ActionNodeData = {
|
||||||
|
label: "",
|
||||||
|
url: "",
|
||||||
|
navigationGoal: "",
|
||||||
|
errorCodeMapping: "null",
|
||||||
|
maxRetries: null,
|
||||||
|
allowDownloads: false,
|
||||||
|
downloadSuffix: null,
|
||||||
|
editable: true,
|
||||||
|
parameterKeys: [],
|
||||||
|
totpVerificationUrl: null,
|
||||||
|
totpIdentifier: null,
|
||||||
|
continueOnFailure: false,
|
||||||
|
cacheActions: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export function isActionNode(node: Node): node is ActionNode {
|
||||||
|
return node.type === "action";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const helpTooltipContent = {
|
||||||
|
navigationGoal:
|
||||||
|
"Specify a single step or action you'd like Skyvern to complete. Actions are one-off tasks like filling a field or interacting with a specific element on the page.\n\nCurrently supported actions are click, input text, upload file, and select.",
|
||||||
|
maxRetries:
|
||||||
|
"Specify how many times you would like a task to retry upon failure.",
|
||||||
|
maxStepsOverride:
|
||||||
|
"Specify the maximum number of steps a task can take in total.",
|
||||||
|
completeOnDownload:
|
||||||
|
"Allow Skyvern to auto-complete the task when it downloads a file.",
|
||||||
|
fileSuffix:
|
||||||
|
"A file suffix that's automatically added to all downloaded files.",
|
||||||
|
errorCodeMapping:
|
||||||
|
"Knowing about why a task terminated can be important, specify error messages here.",
|
||||||
|
totpVerificationUrl:
|
||||||
|
"If you have an internal system for storing TOTP codes, link the endpoint here.",
|
||||||
|
totpIdentifier:
|
||||||
|
"If you are running multiple tasks or workflows at once, you will need to give the task an identifier to know that this TOTP goes with this task.",
|
||||||
|
continueOnFailure:
|
||||||
|
"Allow the workflow to continue if it encounters a failure.",
|
||||||
|
cacheActions: "Cache the actions of this task.",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const fieldPlaceholders = {
|
||||||
|
navigationGoal: 'Input text into "Name" field.',
|
||||||
|
maxRetries: "Default: 3",
|
||||||
|
maxStepsOverride: "Default: 10",
|
||||||
|
downloadSuffix: "Add an ID for downloaded files",
|
||||||
|
totpVerificationUrl: "Provide your 2FA endpoint",
|
||||||
|
totpIdentifier: "Add an ID that links your TOTP to the task",
|
||||||
|
};
|
||||||
@@ -228,7 +228,7 @@ function TaskNode({ id, data }: NodeProps<TaskNode>) {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
<AccordionItem value="advanced" className="border-b-0">
|
<AccordionItem value="advanced" className="border-b-0">
|
||||||
<AccordionTrigger>Advanced Settings</AccordionTrigger>
|
<AccordionTrigger>Advanced Settings</AccordionTrigger>
|
||||||
<AccordionContent className="pl-[1.5rem] pr-1 pt-1">
|
<AccordionContent className="pl-6 pr-1 pt-1">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { StartNode as StartNodeComponent } from "./StartNode/StartNode";
|
|||||||
import type { StartNode } from "./StartNode/types";
|
import type { StartNode } from "./StartNode/types";
|
||||||
import type { ValidationNode } from "./ValidationNode/types";
|
import type { ValidationNode } from "./ValidationNode/types";
|
||||||
import { ValidationNode as ValidationNodeComponent } from "./ValidationNode/ValidationNode";
|
import { ValidationNode as ValidationNodeComponent } from "./ValidationNode/ValidationNode";
|
||||||
|
import type { ActionNode } from "./ActionNode/types";
|
||||||
|
import { ActionNode as ActionNodeComponent } from "./ActionNode/ActionNode";
|
||||||
|
|
||||||
export type UtilityNode = StartNode | NodeAdderNode;
|
export type UtilityNode = StartNode | NodeAdderNode;
|
||||||
|
|
||||||
@@ -33,7 +35,8 @@ export type WorkflowBlockNode =
|
|||||||
| FileParserNode
|
| FileParserNode
|
||||||
| UploadNode
|
| UploadNode
|
||||||
| DownloadNode
|
| DownloadNode
|
||||||
| ValidationNode;
|
| ValidationNode
|
||||||
|
| ActionNode;
|
||||||
|
|
||||||
export function isUtilityNode(node: AppNode): node is UtilityNode {
|
export function isUtilityNode(node: AppNode): node is UtilityNode {
|
||||||
return node.type === "nodeAdder" || node.type === "start";
|
return node.type === "nodeAdder" || node.type === "start";
|
||||||
@@ -57,4 +60,5 @@ export const nodeTypes = {
|
|||||||
nodeAdder: memo(NodeAdderNodeComponent),
|
nodeAdder: memo(NodeAdderNodeComponent),
|
||||||
start: memo(StartNodeComponent),
|
start: memo(StartNodeComponent),
|
||||||
validation: memo(ValidationNodeComponent),
|
validation: memo(ValidationNodeComponent),
|
||||||
|
action: memo(ActionNodeComponent),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "@radix-ui/react-icons";
|
} from "@radix-ui/react-icons";
|
||||||
import { WorkflowBlockNode } from "../nodes";
|
import { WorkflowBlockNode } from "../nodes";
|
||||||
import { AddNodeProps } from "../FlowRenderer";
|
import { AddNodeProps } from "../FlowRenderer";
|
||||||
|
import { ClickIcon } from "@/components/icons/ClickIcon";
|
||||||
|
|
||||||
const nodeLibraryItems: Array<{
|
const nodeLibraryItems: Array<{
|
||||||
nodeType: NonNullable<WorkflowBlockNode["type"]>;
|
nodeType: NonNullable<WorkflowBlockNode["type"]>;
|
||||||
@@ -75,6 +76,12 @@ const nodeLibraryItems: Array<{
|
|||||||
title: "Validation Block",
|
title: "Validation Block",
|
||||||
description: "Validate the state of the workflow or terminate",
|
description: "Validate the state of the workflow or terminate",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nodeType: "action",
|
||||||
|
icon: <ClickIcon className="size-6" />,
|
||||||
|
title: "Action Block",
|
||||||
|
description: "Take a single action",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
WorkflowParameterValueType,
|
WorkflowParameterValueType,
|
||||||
} from "../types/workflowTypes";
|
} from "../types/workflowTypes";
|
||||||
import {
|
import {
|
||||||
|
ActionBlockYAML,
|
||||||
BlockYAML,
|
BlockYAML,
|
||||||
CodeBlockYAML,
|
CodeBlockYAML,
|
||||||
DownloadToS3BlockYAML,
|
DownloadToS3BlockYAML,
|
||||||
@@ -50,6 +51,7 @@ import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
|
|||||||
import { NodeBaseData } from "./nodes/types";
|
import { NodeBaseData } from "./nodes/types";
|
||||||
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
|
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
|
||||||
import { validationNodeDefaultData } from "./nodes/ValidationNode/types";
|
import { validationNodeDefaultData } from "./nodes/ValidationNode/types";
|
||||||
|
import { actionNodeDefaultData } from "./nodes/ActionNode/types";
|
||||||
|
|
||||||
export const NEW_NODE_LABEL_PREFIX = "block_";
|
export const NEW_NODE_LABEL_PREFIX = "block_";
|
||||||
|
|
||||||
@@ -175,6 +177,26 @@ function convertToNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "action": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "action",
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
url: block.url ?? "",
|
||||||
|
navigationGoal: block.navigation_goal ?? "",
|
||||||
|
errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
|
||||||
|
allowDownloads: block.complete_on_download ?? false,
|
||||||
|
downloadSuffix: block.download_suffix ?? null,
|
||||||
|
maxRetries: block.max_retries ?? null,
|
||||||
|
parameterKeys: block.parameters.map((p) => p.key),
|
||||||
|
totpIdentifier: block.totp_identifier ?? null,
|
||||||
|
totpVerificationUrl: block.totp_verification_url ?? null,
|
||||||
|
cacheActions: block.cache_actions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "code": {
|
case "code": {
|
||||||
return {
|
return {
|
||||||
...identifiers,
|
...identifiers,
|
||||||
@@ -476,6 +498,17 @@ function createNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "action": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "action",
|
||||||
|
data: {
|
||||||
|
...actionNodeDefaultData,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
case "loop": {
|
case "loop": {
|
||||||
return {
|
return {
|
||||||
...identifiers,
|
...identifiers,
|
||||||
@@ -601,11 +634,32 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
|
|||||||
block_type: "validation",
|
block_type: "validation",
|
||||||
complete_criterion: node.data.completeCriterion,
|
complete_criterion: node.data.completeCriterion,
|
||||||
terminate_criterion: node.data.terminateCriterion,
|
terminate_criterion: node.data.terminateCriterion,
|
||||||
parameter_keys: node.data.parameterKeys,
|
|
||||||
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
|
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
|
||||||
string,
|
string,
|
||||||
string
|
string
|
||||||
> | null,
|
> | null,
|
||||||
|
parameter_keys: node.data.parameterKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "action": {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
block_type: "action",
|
||||||
|
navigation_goal: node.data.navigationGoal,
|
||||||
|
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
> | null,
|
||||||
|
url: node.data.url,
|
||||||
|
...(node.data.maxRetries !== null && {
|
||||||
|
max_retries: node.data.maxRetries,
|
||||||
|
}),
|
||||||
|
complete_on_download: node.data.allowDownloads,
|
||||||
|
download_suffix: node.data.downloadSuffix,
|
||||||
|
parameter_keys: node.data.parameterKeys,
|
||||||
|
totp_identifier: node.data.totpIdentifier,
|
||||||
|
totp_verification_url: node.data.totpVerificationUrl,
|
||||||
|
cache_actions: node.data.cacheActions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case "sendEmail": {
|
case "sendEmail": {
|
||||||
@@ -1089,6 +1143,23 @@ function convertBlocks(blocks: Array<WorkflowBlock>): Array<BlockYAML> {
|
|||||||
};
|
};
|
||||||
return blockYaml;
|
return blockYaml;
|
||||||
}
|
}
|
||||||
|
case "action": {
|
||||||
|
const blockYaml: ActionBlockYAML = {
|
||||||
|
...base,
|
||||||
|
block_type: "action",
|
||||||
|
url: block.url,
|
||||||
|
navigation_goal: block.navigation_goal,
|
||||||
|
error_code_mapping: block.error_code_mapping,
|
||||||
|
max_retries: block.max_retries,
|
||||||
|
complete_on_download: block.complete_on_download,
|
||||||
|
download_suffix: block.download_suffix,
|
||||||
|
parameter_keys: block.parameters.map((p) => p.key),
|
||||||
|
totp_identifier: block.totp_identifier,
|
||||||
|
totp_verification_url: block.totp_verification_url,
|
||||||
|
cache_actions: block.cache_actions,
|
||||||
|
};
|
||||||
|
return blockYaml;
|
||||||
|
}
|
||||||
case "for_loop": {
|
case "for_loop": {
|
||||||
const blockYaml: ForLoopBlockYAML = {
|
const blockYaml: ForLoopBlockYAML = {
|
||||||
...base,
|
...base,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ export const WorkflowBlockType = {
|
|||||||
UploadToS3: "upload_to_s3",
|
UploadToS3: "upload_to_s3",
|
||||||
SendEmail: "send_email",
|
SendEmail: "send_email",
|
||||||
FileURLParser: "file_url_parser",
|
FileURLParser: "file_url_parser",
|
||||||
|
Validation: "validation",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WorkflowBlockType =
|
export type WorkflowBlockType =
|
||||||
@@ -197,6 +198,11 @@ export type ValidationBlock = WorkflowBlockBase & {
|
|||||||
parameters: Array<WorkflowParameter>;
|
parameters: Array<WorkflowParameter>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActionBlock = Omit<TaskBlock, "block_type"> & {
|
||||||
|
block_type: "action";
|
||||||
|
parameters: Array<WorkflowParameter>;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowBlock =
|
export type WorkflowBlock =
|
||||||
| TaskBlock
|
| TaskBlock
|
||||||
| ForLoopBlock
|
| ForLoopBlock
|
||||||
@@ -206,7 +212,8 @@ export type WorkflowBlock =
|
|||||||
| DownloadToS3Block
|
| DownloadToS3Block
|
||||||
| SendEmailBlock
|
| SendEmailBlock
|
||||||
| FileURLParserBlock
|
| FileURLParserBlock
|
||||||
| ValidationBlock;
|
| ValidationBlock
|
||||||
|
| ActionBlock;
|
||||||
|
|
||||||
export type WorkflowDefinition = {
|
export type WorkflowDefinition = {
|
||||||
parameters: Array<Parameter>;
|
parameters: Array<Parameter>;
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const BlockTypes = {
|
|||||||
SEND_EMAIL: "send_email",
|
SEND_EMAIL: "send_email",
|
||||||
FILE_URL_PARSER: "file_url_parser",
|
FILE_URL_PARSER: "file_url_parser",
|
||||||
VALIDATION: "validation",
|
VALIDATION: "validation",
|
||||||
|
ACTION: "action",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
|
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
|
||||||
@@ -89,7 +90,8 @@ export type BlockYAML =
|
|||||||
| SendEmailBlockYAML
|
| SendEmailBlockYAML
|
||||||
| FileUrlParserBlockYAML
|
| FileUrlParserBlockYAML
|
||||||
| ForLoopBlockYAML
|
| ForLoopBlockYAML
|
||||||
| ValidationBlockYAML;
|
| ValidationBlockYAML
|
||||||
|
| ActionBlockYAML;
|
||||||
|
|
||||||
export type BlockYAMLBase = {
|
export type BlockYAMLBase = {
|
||||||
block_type: BlockType;
|
block_type: BlockType;
|
||||||
@@ -123,6 +125,20 @@ export type ValidationBlockYAML = BlockYAMLBase & {
|
|||||||
parameter_keys?: Array<string> | null;
|
parameter_keys?: Array<string> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ActionBlockYAML = BlockYAMLBase & {
|
||||||
|
block_type: "action";
|
||||||
|
url: string | null;
|
||||||
|
navigation_goal: string | null;
|
||||||
|
error_code_mapping: Record<string, string> | null;
|
||||||
|
max_retries?: number;
|
||||||
|
parameter_keys?: Array<string> | null;
|
||||||
|
complete_on_download?: boolean;
|
||||||
|
download_suffix?: string | null;
|
||||||
|
totp_verification_url?: string | null;
|
||||||
|
totp_identifier?: string | null;
|
||||||
|
cache_actions: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type CodeBlockYAML = BlockYAMLBase & {
|
export type CodeBlockYAML = BlockYAMLBase & {
|
||||||
block_type: "code";
|
block_type: "code";
|
||||||
code: string;
|
code: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user