added UI for HTTP block (#2900)
This commit is contained in:
committed by
GitHub
parent
427ad4d9ac
commit
c9431fad6f
@@ -3,7 +3,7 @@ export const baseHelpTooltipContent = {
|
|||||||
navigationGoal:
|
navigationGoal:
|
||||||
"Give Skyvern an objective. Make sure to include when the block is complete, when it should self-terminate, and any guardrails. Use {{ parameter_name }} to reference a parameter value",
|
"Give Skyvern an objective. Make sure to include when the block is complete, when it should self-terminate, and any guardrails. Use {{ parameter_name }} to reference a parameter value",
|
||||||
parameters:
|
parameters:
|
||||||
"Define placeholder values using the “parameters” drop down that you predefine or redefine run-to-run.",
|
'Define placeholder values using the "parameters" drop down that you predefine or redefine run-to-run.',
|
||||||
dataExtractionGoal:
|
dataExtractionGoal:
|
||||||
"Tell Skyvern what data you would like to scrape at the end of your run.",
|
"Tell Skyvern what data you would like to scrape at the end of your run.",
|
||||||
dataSchema: "Specify a format for extracted data in JSON.",
|
dataSchema: "Specify a format for extracted data in JSON.",
|
||||||
@@ -115,6 +115,17 @@ export const helpTooltips = {
|
|||||||
jsonSchema: "Specify a format for the extracted information from the file",
|
jsonSchema: "Specify a format for the extracted information from the file",
|
||||||
},
|
},
|
||||||
url: baseHelpTooltipContent,
|
url: baseHelpTooltipContent,
|
||||||
|
httpRequest: {
|
||||||
|
...baseHelpTooltipContent,
|
||||||
|
url: "The URL to send the HTTP request to. You can use {{ parameter_name }} to reference parameters.",
|
||||||
|
method: "The HTTP method to use for the request.",
|
||||||
|
headers: "HTTP headers to include with the request as JSON object.",
|
||||||
|
body: "Request body as JSON object. Only used for POST, PUT, PATCH methods.",
|
||||||
|
timeout: "Request timeout in seconds.",
|
||||||
|
followRedirects: "Whether to automatically follow HTTP redirects.",
|
||||||
|
continueOnFailure:
|
||||||
|
"Allow the workflow to continue if the HTTP request fails.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const placeholders = {
|
export const placeholders = {
|
||||||
@@ -159,4 +170,11 @@ export const placeholders = {
|
|||||||
...basePlaceholderContent,
|
...basePlaceholderContent,
|
||||||
url: "(required) Navigate to this URL: https://...",
|
url: "(required) Navigate to this URL: https://...",
|
||||||
},
|
},
|
||||||
|
httpRequest: {
|
||||||
|
...basePlaceholderContent,
|
||||||
|
url: "https://api.example.com/endpoint",
|
||||||
|
headers:
|
||||||
|
'{\n "Content-Type": "application/json",\n "Authorization": "Bearer {{ token }}"\n}',
|
||||||
|
body: '{\n "key": "value",\n "parameter": "{{ parameter_name }}"\n}',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { getClient } from "@/api/AxiosClient";
|
||||||
|
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import {
|
||||||
|
ReloadIcon,
|
||||||
|
CodeIcon,
|
||||||
|
CheckIcon,
|
||||||
|
CopyIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onImport: (data: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: string;
|
||||||
|
body: string;
|
||||||
|
timeout: number;
|
||||||
|
followRedirects: boolean;
|
||||||
|
}) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const curlExamples = [
|
||||||
|
{
|
||||||
|
name: "GET Request",
|
||||||
|
curl: `curl -X GET "https://api.example.com/users" \\
|
||||||
|
-H "Authorization: Bearer token123" \\
|
||||||
|
-H "Accept: application/json"`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "POST JSON",
|
||||||
|
curl: `curl -X POST "https://api.example.com/users" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "Authorization: Bearer token123" \\
|
||||||
|
-d '{"name": "John Doe", "email": "john@example.com"}'`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PUT Request",
|
||||||
|
curl: `curl -X PUT "https://api.example.com/users/123" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"name": "Jane Doe"}'`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CurlImportDialog({ onImport, children }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [curlCommand, setCurlCommand] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<{
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: unknown;
|
||||||
|
} | null>(null);
|
||||||
|
const credentialGetter = useCredentialGetter();
|
||||||
|
|
||||||
|
const handleImport = async () => {
|
||||||
|
if (!curlCommand.trim()) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Please enter a curl command",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const client = await getClient(credentialGetter);
|
||||||
|
const response = await client.post("/utilities/curl-to-http", {
|
||||||
|
curl_command: curlCommand.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data;
|
||||||
|
|
||||||
|
onImport({
|
||||||
|
method: data.method || "GET",
|
||||||
|
url: data.url || "",
|
||||||
|
headers: JSON.stringify(data.headers || {}, null, 2),
|
||||||
|
body: JSON.stringify(data.body || {}, null, 2),
|
||||||
|
timeout: data.timeout || 30,
|
||||||
|
followRedirects: data.follow_redirects ?? true,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Success",
|
||||||
|
description: "Curl command imported successfully",
|
||||||
|
variant: "success",
|
||||||
|
});
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
setCurlCommand("");
|
||||||
|
setPreviewData(null);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
).response?.data?.detail ||
|
||||||
|
(error as { message?: string }).message ||
|
||||||
|
"Failed to parse curl command";
|
||||||
|
toast({
|
||||||
|
title: "Import Failed",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!curlCommand.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const client = await getClient(credentialGetter);
|
||||||
|
const response = await client.post("/utilities/curl-to-http", {
|
||||||
|
curl_command: curlCommand.trim(),
|
||||||
|
});
|
||||||
|
setPreviewData(response.data);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
const errorMessage =
|
||||||
|
(
|
||||||
|
error as {
|
||||||
|
response?: { data?: { detail?: string } };
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
).response?.data?.detail ||
|
||||||
|
(error as { message?: string }).message ||
|
||||||
|
"Failed to parse curl command";
|
||||||
|
toast({
|
||||||
|
title: "Preview Failed",
|
||||||
|
description: errorMessage,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
setPreviewData(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyExample = (example: string) => {
|
||||||
|
navigator.clipboard.writeText(example);
|
||||||
|
toast({
|
||||||
|
title: "Copied",
|
||||||
|
description: "Example copied to clipboard",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-4xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<CodeIcon className="h-5 w-5" />
|
||||||
|
Import from cURL
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste your curl command below and we'll automatically populate the
|
||||||
|
HTTP request fields.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
{/* Left side - Input */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium">
|
||||||
|
cURL Command
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Paste your curl command here..."
|
||||||
|
value={curlCommand}
|
||||||
|
onChange={(e) => setCurlCommand(e.target.value)}
|
||||||
|
className="min-h-[200px] font-mono text-sm"
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handlePreview}
|
||||||
|
disabled={loading || !curlCommand.trim()}
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Preview
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || !curlCommand.trim()}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading && (
|
||||||
|
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
Import Request
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
<strong>Supported:</strong> -X, -H, -d, --data, --json, -u,
|
||||||
|
--user, --cookie, --referer, and more.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Examples and Preview */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium">Examples</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{curlExamples.map((example, index) => (
|
||||||
|
<div key={index} className="rounded-lg border p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium">
|
||||||
|
{example.name}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => copyExample(example.curl)}
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
<CopyIcon className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap break-all text-xs text-slate-400">
|
||||||
|
{example.curl}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
{previewData && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||||
|
<CheckIcon className="h-4 w-4 text-green-500" />
|
||||||
|
Preview
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2 rounded-lg border p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
{previewData.method}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{previewData.url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{previewData.headers &&
|
||||||
|
Object.keys(previewData.headers).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium">Headers:</div>
|
||||||
|
<div className="space-y-1 text-xs text-slate-400">
|
||||||
|
{Object.entries(previewData.headers).map(
|
||||||
|
([key, value]) => (
|
||||||
|
<div key={key} className="font-mono">
|
||||||
|
{key}: {value as string}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{previewData.body != null &&
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const bodyStr = JSON.stringify(
|
||||||
|
previewData.body,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium">
|
||||||
|
Body:
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap text-xs text-slate-400">
|
||||||
|
{bodyStr || "{}"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium">
|
||||||
|
Body:
|
||||||
|
</div>
|
||||||
|
<pre className="overflow-x-auto whitespace-pre-wrap text-xs text-slate-400">
|
||||||
|
{"{}"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleImport}
|
||||||
|
disabled={loading || !curlCommand.trim()}
|
||||||
|
>
|
||||||
|
{loading && <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
Import Request
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
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,
|
||||||
|
useEdges,
|
||||||
|
useNodes,
|
||||||
|
useReactFlow,
|
||||||
|
} from "@xyflow/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { EditableNodeTitle } from "../components/EditableNodeTitle";
|
||||||
|
import { NodeActionMenu } from "../NodeActionMenu";
|
||||||
|
import type { HttpRequestNode as HttpRequestNodeType } from "./types";
|
||||||
|
import { HelpTooltip } from "@/components/HelpTooltip";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { placeholders, helpTooltips } from "../../helpContent";
|
||||||
|
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
|
||||||
|
import { WorkflowBlockIcon } from "../WorkflowBlockIcon";
|
||||||
|
import { WorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||||
|
import { AppNode } from "..";
|
||||||
|
import { getAvailableOutputParameterKeys } from "../../workflowEditorUtils";
|
||||||
|
import { ParametersMultiSelect } from "../TaskNode/ParametersMultiSelect";
|
||||||
|
import { useIsFirstBlockInWorkflow } from "../../hooks/useIsFirstNodeInWorkflow";
|
||||||
|
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { CodeIcon, PlusIcon, MagicWandIcon } from "@radix-ui/react-icons";
|
||||||
|
import { CurlImportDialog } from "./CurlImportDialog";
|
||||||
|
import { QuickHeadersDialog } from "./QuickHeadersDialog";
|
||||||
|
import { MethodBadge, UrlValidator, RequestPreview } from "./HttpUtils";
|
||||||
|
|
||||||
|
const httpMethods = [
|
||||||
|
"GET",
|
||||||
|
"POST",
|
||||||
|
"PUT",
|
||||||
|
"DELETE",
|
||||||
|
"PATCH",
|
||||||
|
"HEAD",
|
||||||
|
"OPTIONS",
|
||||||
|
];
|
||||||
|
|
||||||
|
const urlTooltip =
|
||||||
|
"The URL to send the HTTP request to. You can use {{ parameter_name }} to reference parameters.";
|
||||||
|
const methodTooltip = "The HTTP method to use for the request.";
|
||||||
|
const headersTooltip =
|
||||||
|
"HTTP headers to include with the request as JSON object.";
|
||||||
|
const bodyTooltip =
|
||||||
|
"Request body as JSON object. Only used for POST, PUT, PATCH methods.";
|
||||||
|
const timeoutTooltip = "Request timeout in seconds.";
|
||||||
|
const followRedirectsTooltip =
|
||||||
|
"Whether to automatically follow HTTP redirects.";
|
||||||
|
|
||||||
|
function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
|
||||||
|
const { updateNodeData } = useReactFlow();
|
||||||
|
const { editable } = data;
|
||||||
|
const [label, setLabel] = useNodeLabelChangeHandler({
|
||||||
|
id,
|
||||||
|
initialValue: data.label,
|
||||||
|
});
|
||||||
|
const [inputs, setInputs] = useState({
|
||||||
|
method: data.method,
|
||||||
|
url: data.url,
|
||||||
|
headers: data.headers,
|
||||||
|
body: data.body,
|
||||||
|
timeout: data.timeout,
|
||||||
|
followRedirects: data.followRedirects,
|
||||||
|
continueOnFailure: data.continueOnFailure,
|
||||||
|
});
|
||||||
|
const deleteNodeCallback = useDeleteNodeCallback();
|
||||||
|
|
||||||
|
const nodes = useNodes<AppNode>();
|
||||||
|
const edges = useEdges();
|
||||||
|
const outputParameterKeys = getAvailableOutputParameterKeys(nodes, edges, id);
|
||||||
|
|
||||||
|
function handleChange(key: string, value: unknown) {
|
||||||
|
if (!editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setInputs({ ...inputs, [key]: value });
|
||||||
|
updateNodeData(id, { [key]: value });
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCurlImport = (importedData: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: string;
|
||||||
|
body: string;
|
||||||
|
timeout: number;
|
||||||
|
followRedirects: boolean;
|
||||||
|
}) => {
|
||||||
|
const newInputs = {
|
||||||
|
...inputs,
|
||||||
|
method: importedData.method,
|
||||||
|
url: importedData.url,
|
||||||
|
headers: importedData.headers,
|
||||||
|
body: importedData.body,
|
||||||
|
timeout: importedData.timeout,
|
||||||
|
followRedirects: importedData.followRedirects,
|
||||||
|
};
|
||||||
|
setInputs(newInputs);
|
||||||
|
updateNodeData(id, {
|
||||||
|
method: importedData.method,
|
||||||
|
url: importedData.url,
|
||||||
|
headers: importedData.headers,
|
||||||
|
body: importedData.body,
|
||||||
|
timeout: importedData.timeout,
|
||||||
|
followRedirects: importedData.followRedirects,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleQuickHeaders = (headers: Record<string, string>) => {
|
||||||
|
try {
|
||||||
|
const existingHeaders = JSON.parse(inputs.headers || "{}");
|
||||||
|
const mergedHeaders = { ...existingHeaders, ...headers };
|
||||||
|
const newHeadersString = JSON.stringify(mergedHeaders, null, 2);
|
||||||
|
handleChange("headers", newHeadersString);
|
||||||
|
} catch (error) {
|
||||||
|
// If existing headers are invalid, just use the new ones
|
||||||
|
const newHeadersString = JSON.stringify(headers, null, 2);
|
||||||
|
handleChange("headers", newHeadersString);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isFirstWorkflowBlock = useIsFirstBlockInWorkflow({ id });
|
||||||
|
|
||||||
|
const showBodyEditor =
|
||||||
|
inputs.method !== "GET" &&
|
||||||
|
inputs.method !== "HEAD" &&
|
||||||
|
inputs.method !== "DELETE";
|
||||||
|
|
||||||
|
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-[36rem] 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">
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.HttpRequest}
|
||||||
|
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">HTTP Request Block</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* Quick Action Buttons */}
|
||||||
|
<CurlImportDialog onImport={handleCurlImport}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs"
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<CodeIcon className="mr-1 h-3 w-3" />
|
||||||
|
Import cURL
|
||||||
|
</Button>
|
||||||
|
</CurlImportDialog>
|
||||||
|
|
||||||
|
<NodeActionMenu
|
||||||
|
onDelete={() => {
|
||||||
|
deleteNodeCallback(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Method and URL Section */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-32 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Method</Label>
|
||||||
|
<HelpTooltip content={methodTooltip} />
|
||||||
|
</div>
|
||||||
|
<Select
|
||||||
|
value={inputs.method}
|
||||||
|
onValueChange={(value) => handleChange("method", value)}
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="nopan text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MethodBadge method={inputs.method} />
|
||||||
|
</div>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{httpMethods.map((method) => (
|
||||||
|
<SelectItem key={method} value={method}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MethodBadge method={method} />
|
||||||
|
{method}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">URL</Label>
|
||||||
|
<HelpTooltip content={urlTooltip} />
|
||||||
|
</div>
|
||||||
|
{isFirstWorkflowBlock ? (
|
||||||
|
<div className="flex justify-end text-xs text-slate-400">
|
||||||
|
Tip: Use the {"+"} button to add parameters!
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<WorkflowBlockInputTextarea
|
||||||
|
nodeId={id}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleChange("url", value);
|
||||||
|
}}
|
||||||
|
value={inputs.url}
|
||||||
|
placeholder={placeholders["httpRequest"]["url"]}
|
||||||
|
className="nopan text-xs"
|
||||||
|
/>
|
||||||
|
<UrlValidator url={inputs.url} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Headers Section */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Headers</Label>
|
||||||
|
<HelpTooltip content={headersTooltip} />
|
||||||
|
</div>
|
||||||
|
<QuickHeadersDialog onAdd={handleQuickHeaders}>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
disabled={!editable}
|
||||||
|
>
|
||||||
|
<PlusIcon className="mr-1 h-3 w-3" />
|
||||||
|
Quick Headers
|
||||||
|
</Button>
|
||||||
|
</QuickHeadersDialog>
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
className="w-full"
|
||||||
|
language="json"
|
||||||
|
value={inputs.headers}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleChange("headers", value || "{}");
|
||||||
|
}}
|
||||||
|
readOnly={!editable}
|
||||||
|
minHeight="80px"
|
||||||
|
maxHeight="160px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body Section */}
|
||||||
|
{showBodyEditor && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Body</Label>
|
||||||
|
<HelpTooltip content={bodyTooltip} />
|
||||||
|
</div>
|
||||||
|
<CodeEditor
|
||||||
|
className="w-full"
|
||||||
|
language="json"
|
||||||
|
value={inputs.body}
|
||||||
|
onChange={(value) => {
|
||||||
|
handleChange("body", value || "{}");
|
||||||
|
}}
|
||||||
|
readOnly={!editable}
|
||||||
|
minHeight="100px"
|
||||||
|
maxHeight="200px"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Request Preview */}
|
||||||
|
<RequestPreview
|
||||||
|
method={inputs.method}
|
||||||
|
url={inputs.url}
|
||||||
|
headers={inputs.headers}
|
||||||
|
body={inputs.body}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<ParametersMultiSelect
|
||||||
|
availableOutputParameters={outputParameterKeys}
|
||||||
|
parameters={data.parameterKeys}
|
||||||
|
onParametersChange={(parameterKeys) => {
|
||||||
|
updateNodeData(id, { parameterKeys });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="w-32 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">Timeout</Label>
|
||||||
|
<HelpTooltip content={timeoutTooltip} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="300"
|
||||||
|
value={inputs.timeout}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleChange("timeout", parseInt(e.target.value) || 30)
|
||||||
|
}
|
||||||
|
className="nopan text-xs"
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Follow Redirects
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip content={followRedirectsTooltip} />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
Automatically follow HTTP redirects
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={inputs.followRedirects}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange("followRedirects", checked)
|
||||||
|
}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Label className="text-xs text-slate-300">
|
||||||
|
Continue on Failure
|
||||||
|
</Label>
|
||||||
|
<HelpTooltip
|
||||||
|
content={
|
||||||
|
helpTooltips["httpRequest"]["continueOnFailure"]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end">
|
||||||
|
<Switch
|
||||||
|
checked={inputs.continueOnFailure}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange("continueOnFailure", checked)
|
||||||
|
}
|
||||||
|
disabled={!editable}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{/* Tips Section */}
|
||||||
|
<div className="rounded-md bg-slate-800/50 p-3">
|
||||||
|
<div className="space-y-2 text-xs text-slate-400">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MagicWandIcon className="h-3 w-3" />
|
||||||
|
<span className="font-medium">Quick Tips:</span>
|
||||||
|
</div>
|
||||||
|
<ul className="ml-5 list-disc space-y-1">
|
||||||
|
<li>
|
||||||
|
Use "Import cURL" to quickly convert API documentation examples
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Use "Quick Headers" in the headers section to add common
|
||||||
|
authentication and content headers
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
The request will return response data including status, headers,
|
||||||
|
and body
|
||||||
|
</li>
|
||||||
|
<li>Reference response data in later blocks with parameters</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HttpRequestNode };
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
CopyIcon,
|
||||||
|
CheckIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CheckCircledIcon,
|
||||||
|
} from "@radix-ui/react-icons";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "@/components/ui/use-toast";
|
||||||
|
import { cn } from "@/util/utils";
|
||||||
|
|
||||||
|
// HTTP Method Badge Component
|
||||||
|
export function MethodBadge({
|
||||||
|
method,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
method: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const getMethodStyle = (method: string) => {
|
||||||
|
switch (method.toUpperCase()) {
|
||||||
|
case "GET":
|
||||||
|
return "bg-green-100 text-green-800 border-green-300 dark:bg-green-900/20 dark:text-green-400 dark:border-green-800";
|
||||||
|
case "POST":
|
||||||
|
return "bg-blue-100 text-blue-800 border-blue-300 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800";
|
||||||
|
case "PUT":
|
||||||
|
return "bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/20 dark:text-yellow-400 dark:border-yellow-800";
|
||||||
|
case "DELETE":
|
||||||
|
return "bg-red-100 text-red-800 border-red-300 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800";
|
||||||
|
case "PATCH":
|
||||||
|
return "bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900/20 dark:text-purple-400 dark:border-purple-800";
|
||||||
|
case "HEAD":
|
||||||
|
return "bg-gray-100 text-gray-800 border-gray-300 dark:bg-gray-900/20 dark:text-gray-400 dark:border-gray-800";
|
||||||
|
case "OPTIONS":
|
||||||
|
return "bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-900/20 dark:text-cyan-400 dark:border-cyan-800";
|
||||||
|
default:
|
||||||
|
return "bg-slate-100 text-slate-800 border-slate-300 dark:bg-slate-900/20 dark:text-slate-400 dark:border-slate-800";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"border font-mono text-xs font-bold",
|
||||||
|
getMethodStyle(method),
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{method}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL Validation Component
|
||||||
|
export function UrlValidator({ url }: { url: string }) {
|
||||||
|
const isValidUrl = (urlString: string) => {
|
||||||
|
if (!urlString.trim()) return { valid: false, message: "URL is required" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
return { valid: false, message: "URL must use HTTP or HTTPS protocol" };
|
||||||
|
}
|
||||||
|
return { valid: true, message: "Valid URL" };
|
||||||
|
} catch {
|
||||||
|
return { valid: false, message: "Invalid URL format" };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validation = isValidUrl(url);
|
||||||
|
|
||||||
|
if (!url.trim()) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1 text-xs",
|
||||||
|
validation.valid
|
||||||
|
? "text-green-600 dark:text-green-400"
|
||||||
|
: "text-red-600 dark:text-red-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{validation.valid ? (
|
||||||
|
<CheckCircledIcon className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ExclamationTriangleIcon className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
<span>{validation.message}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to Curl Component
|
||||||
|
export function CopyToCurlButton({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: string;
|
||||||
|
body: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const generateCurlCommand = () => {
|
||||||
|
let curl = `curl -X ${method.toUpperCase()}`;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
curl += ` "${url}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and add headers
|
||||||
|
try {
|
||||||
|
const parsedHeaders = JSON.parse(headers || "{}");
|
||||||
|
Object.entries(parsedHeaders).forEach(([key, value]) => {
|
||||||
|
curl += ` \\\n -H "${key}: ${value}"`;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// If headers can't be parsed, skip them
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add body for non-GET requests
|
||||||
|
if (["POST", "PUT", "PATCH"].includes(method.toUpperCase()) && body) {
|
||||||
|
try {
|
||||||
|
const parsedBody = JSON.parse(body);
|
||||||
|
curl += ` \\\n -d '${JSON.stringify(parsedBody)}'`;
|
||||||
|
} catch (error) {
|
||||||
|
// If body can't be parsed, add it as-is
|
||||||
|
curl += ` \\\n -d '${body}'`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return curl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
try {
|
||||||
|
const curlCommand = generateCurlCommand();
|
||||||
|
await navigator.clipboard.writeText(curlCommand);
|
||||||
|
setCopied(true);
|
||||||
|
toast({
|
||||||
|
title: "Copied!",
|
||||||
|
description: "cURL command copied to clipboard",
|
||||||
|
});
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
} catch (error) {
|
||||||
|
toast({
|
||||||
|
title: "Error",
|
||||||
|
description: "Failed to copy cURL command",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCopy}
|
||||||
|
className={cn("h-8 px-2", className)}
|
||||||
|
disabled={!url}
|
||||||
|
>
|
||||||
|
{copied ? (
|
||||||
|
<CheckIcon className="mr-1 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<CopyIcon className="mr-1 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{copied ? "Copied!" : "Copy cURL"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request Preview Component
|
||||||
|
export function RequestPreview({
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: string;
|
||||||
|
body: string;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const hasContent = method && url;
|
||||||
|
|
||||||
|
if (!hasContent) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border bg-slate-50 p-3 dark:bg-slate-900/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MethodBadge method={method} />
|
||||||
|
<span className="font-mono text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{url || "No URL specified"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
className="h-6 text-xs"
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : "Show"} Details
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{/* Headers */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium">Headers:</div>
|
||||||
|
<pre className="overflow-x-auto rounded bg-slate-100 p-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||||
|
{headers || "{}"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body (only for POST, PUT, PATCH) */}
|
||||||
|
{["POST", "PUT", "PATCH"].includes(method.toUpperCase()) && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-1 text-xs font-medium">Body:</div>
|
||||||
|
<pre className="overflow-x-auto rounded bg-slate-100 p-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-400">
|
||||||
|
{body || "{}"}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { PlusIcon } from "@radix-ui/react-icons";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
onAdd: (headers: Record<string, string>) => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const commonHeaders = [
|
||||||
|
{
|
||||||
|
name: "Content-Type",
|
||||||
|
value: "application/json",
|
||||||
|
description: "JSON content",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Content-Type",
|
||||||
|
value: "application/x-www-form-urlencoded",
|
||||||
|
description: "Form data",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Bearer YOUR_TOKEN",
|
||||||
|
description: "Bearer token auth",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Authorization",
|
||||||
|
value: "Basic YOUR_CREDENTIALS",
|
||||||
|
description: "Basic auth",
|
||||||
|
},
|
||||||
|
{ name: "User-Agent", value: "Skyvern/1.0", description: "User agent" },
|
||||||
|
{
|
||||||
|
name: "Accept",
|
||||||
|
value: "application/json",
|
||||||
|
description: "Accept JSON response",
|
||||||
|
},
|
||||||
|
{ name: "Accept", value: "*/*", description: "Accept any response" },
|
||||||
|
{ name: "X-API-Key", value: "YOUR_API_KEY", description: "API key header" },
|
||||||
|
{ name: "Cache-Control", value: "no-cache", description: "No cache" },
|
||||||
|
{
|
||||||
|
name: "Referer",
|
||||||
|
value: "https://example.com",
|
||||||
|
description: "Referer header",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function QuickHeadersDialog({ onAdd, children }: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedHeaders, setSelectedHeaders] = useState<
|
||||||
|
Record<string, string>
|
||||||
|
>({});
|
||||||
|
const [customKey, setCustomKey] = useState("");
|
||||||
|
const [customValue, setCustomValue] = useState("");
|
||||||
|
|
||||||
|
const handleAddCustomHeader = () => {
|
||||||
|
if (customKey.trim() && customValue.trim()) {
|
||||||
|
setSelectedHeaders((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[customKey.trim()]: customValue.trim(),
|
||||||
|
}));
|
||||||
|
setCustomKey("");
|
||||||
|
setCustomValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleHeader = (name: string, value: string) => {
|
||||||
|
setSelectedHeaders((prev) => {
|
||||||
|
const newHeaders = { ...prev };
|
||||||
|
if (newHeaders[name] === value) {
|
||||||
|
delete newHeaders[name];
|
||||||
|
} else {
|
||||||
|
newHeaders[name] = value;
|
||||||
|
}
|
||||||
|
return newHeaders;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddHeaders = () => {
|
||||||
|
if (Object.keys(selectedHeaders).length > 0) {
|
||||||
|
onAdd(selectedHeaders);
|
||||||
|
setSelectedHeaders({});
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<PlusIcon className="h-5 w-5" />
|
||||||
|
Add Common Headers
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Quickly add common HTTP headers to your request.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Common Headers */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium">Common Headers</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
{commonHeaders.map((header, index) => {
|
||||||
|
const isSelected =
|
||||||
|
selectedHeaders[header.name] === header.value;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800 ${
|
||||||
|
isSelected
|
||||||
|
? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
onClick={() =>
|
||||||
|
handleToggleHeader(header.name, header.value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="outline" className="font-mono text-xs">
|
||||||
|
{header.name}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||||
|
{header.value}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && (
|
||||||
|
<Badge variant="default" className="text-xs">
|
||||||
|
Selected
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-500">
|
||||||
|
{header.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Header */}
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium">Custom Header</h4>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="custom-key" className="text-xs">
|
||||||
|
Header Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-key"
|
||||||
|
placeholder="X-Custom-Header"
|
||||||
|
value={customKey}
|
||||||
|
onChange={(e) => setCustomKey(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<Label htmlFor="custom-value" className="text-xs">
|
||||||
|
Header Value
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="custom-value"
|
||||||
|
placeholder="custom-value"
|
||||||
|
value={customValue}
|
||||||
|
onChange={(e) => setCustomValue(e.target.value)}
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddCustomHeader}
|
||||||
|
disabled={!customKey.trim() || !customValue.trim()}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Headers Preview */}
|
||||||
|
{Object.keys(selectedHeaders).length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="mb-3 text-sm font-medium">
|
||||||
|
Selected Headers ({Object.keys(selectedHeaders).length})
|
||||||
|
</h4>
|
||||||
|
<div className="rounded-lg border bg-slate-50 p-3 dark:bg-slate-800">
|
||||||
|
<pre className="text-xs text-slate-600 dark:text-slate-400">
|
||||||
|
{JSON.stringify(selectedHeaders, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddHeaders}
|
||||||
|
disabled={Object.keys(selectedHeaders).length === 0}
|
||||||
|
>
|
||||||
|
Add Headers ({Object.keys(selectedHeaders).length})
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { HttpRequestNode } from "./HttpRequestNode";
|
||||||
|
export type {
|
||||||
|
HttpRequestNode as HttpRequestNodeType,
|
||||||
|
HttpRequestNodeData,
|
||||||
|
} from "./types";
|
||||||
|
export { httpRequestNodeDefaultData, isHttpRequestNode } from "./types";
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Node } from "@xyflow/react";
|
||||||
|
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
||||||
|
import { NodeBaseData } from "../types";
|
||||||
|
|
||||||
|
export type HttpRequestNodeData = NodeBaseData & {
|
||||||
|
method: string;
|
||||||
|
url: string;
|
||||||
|
headers: string; // JSON string representation of headers
|
||||||
|
body: string; // JSON string representation of body
|
||||||
|
timeout: number;
|
||||||
|
followRedirects: boolean;
|
||||||
|
parameterKeys: Array<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HttpRequestNode = Node<HttpRequestNodeData, "http_request">;
|
||||||
|
|
||||||
|
export const httpRequestNodeDefaultData: HttpRequestNodeData = {
|
||||||
|
debuggable: debuggableWorkflowBlockTypes.has("http_request"),
|
||||||
|
label: "",
|
||||||
|
continueOnFailure: false,
|
||||||
|
method: "GET",
|
||||||
|
url: "",
|
||||||
|
headers: "{}",
|
||||||
|
body: "{}",
|
||||||
|
timeout: 30,
|
||||||
|
followRedirects: true,
|
||||||
|
parameterKeys: [],
|
||||||
|
editable: true,
|
||||||
|
model: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isHttpRequestNode(node: Node): node is HttpRequestNode {
|
||||||
|
return node.type === "http_request";
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
EnvelopeClosedIcon,
|
EnvelopeClosedIcon,
|
||||||
ExternalLinkIcon,
|
ExternalLinkIcon,
|
||||||
FileTextIcon,
|
FileTextIcon,
|
||||||
|
GlobeIcon,
|
||||||
ListBulletIcon,
|
ListBulletIcon,
|
||||||
LockOpen1Icon,
|
LockOpen1Icon,
|
||||||
StopwatchIcon,
|
StopwatchIcon,
|
||||||
@@ -79,6 +80,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) {
|
|||||||
case "goto_url": {
|
case "goto_url": {
|
||||||
return <ExternalLinkIcon className={className} />;
|
return <ExternalLinkIcon className={className} />;
|
||||||
}
|
}
|
||||||
|
case "http_request": {
|
||||||
|
return <GlobeIcon className={className} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import { Taskv2Node } from "./Taskv2Node/types";
|
|||||||
import { Taskv2Node as Taskv2NodeComponent } from "./Taskv2Node/Taskv2Node";
|
import { Taskv2Node as Taskv2NodeComponent } from "./Taskv2Node/Taskv2Node";
|
||||||
import { URLNode } from "./URLNode/types";
|
import { URLNode } from "./URLNode/types";
|
||||||
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
|
import { URLNode as URLNodeComponent } from "./URLNode/URLNode";
|
||||||
|
import { HttpRequestNode } from "./HttpRequestNode/types";
|
||||||
|
import { HttpRequestNode as HttpRequestNodeComponent } from "./HttpRequestNode/HttpRequestNode";
|
||||||
|
|
||||||
export type UtilityNode = StartNode | NodeAdderNode;
|
export type UtilityNode = StartNode | NodeAdderNode;
|
||||||
|
|
||||||
@@ -63,7 +65,8 @@ export type WorkflowBlockNode =
|
|||||||
| FileDownloadNode
|
| FileDownloadNode
|
||||||
| PDFParserNode
|
| PDFParserNode
|
||||||
| Taskv2Node
|
| Taskv2Node
|
||||||
| URLNode;
|
| URLNode
|
||||||
|
| HttpRequestNode;
|
||||||
|
|
||||||
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";
|
||||||
@@ -97,4 +100,5 @@ export const nodeTypes = {
|
|||||||
pdfParser: memo(PDFParserNodeComponent),
|
pdfParser: memo(PDFParserNodeComponent),
|
||||||
taskv2: memo(Taskv2NodeComponent),
|
taskv2: memo(Taskv2NodeComponent),
|
||||||
url: memo(URLNodeComponent),
|
url: memo(URLNodeComponent),
|
||||||
|
http_request: memo(HttpRequestNodeComponent),
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -52,4 +52,5 @@ export const workflowBlockTitle: {
|
|||||||
pdf_parser: "PDF Parser",
|
pdf_parser: "PDF Parser",
|
||||||
task_v2: "Task v2",
|
task_v2: "Task v2",
|
||||||
goto_url: "Go to URL",
|
goto_url: "Go to URL",
|
||||||
|
http_request: "HTTP Request",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ const nodeLibraryItems: Array<{
|
|||||||
title: "Navigation Block",
|
title: "Navigation Block",
|
||||||
description: "Navigate on the page",
|
description: "Navigate on the page",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
nodeType: "task",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.Task}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Task Block",
|
||||||
|
description: "Complete multi-step browser automation tasks",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
nodeType: "taskv2",
|
nodeType: "taskv2",
|
||||||
icon: (
|
icon: (
|
||||||
@@ -74,141 +85,7 @@ const nodeLibraryItems: Array<{
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
title: "Extraction Block",
|
title: "Extraction Block",
|
||||||
description: "Extract data from the page",
|
description: "Extract data from a webpage",
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "validation",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.Validation}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Validation Block",
|
|
||||||
description: "Validate the state of the workflow or terminate",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "task",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.Task}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Task Block",
|
|
||||||
description: "Takes actions or extracts information",
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
nodeType: "url",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.URL}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Go to URL Block",
|
|
||||||
description: "Navigates to a URL",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "textPrompt",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.TextPrompt}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Text Prompt Block",
|
|
||||||
description: "Generates AI response",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "sendEmail",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.SendEmail}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Send Email Block",
|
|
||||||
description: "Sends an email",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "loop",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.ForLoop}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "For Loop Block",
|
|
||||||
description: "Repeats nested elements",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "codeBlock",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.Code}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "Code Block",
|
|
||||||
description: "Executes Python code",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "fileParser",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.FileURLParser}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "File Parser Block",
|
|
||||||
description: "Downloads and parses a file",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "pdfParser",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.PDFParser}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "PDF Parser Block",
|
|
||||||
description: "Downloads and parses a PDF file with an optional data schema",
|
|
||||||
},
|
|
||||||
// disabled
|
|
||||||
// {
|
|
||||||
// nodeType: "download",
|
|
||||||
// icon: (
|
|
||||||
// <WorkflowBlockIcon
|
|
||||||
// workflowBlockType={WorkflowBlockTypes.DownloadToS3}
|
|
||||||
// className="size-6"
|
|
||||||
// />
|
|
||||||
// ),
|
|
||||||
// title: "Download Block",
|
|
||||||
// description: "Downloads a file from S3",
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
nodeType: "fileUpload",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.FileUpload}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "File Upload Block",
|
|
||||||
description: "Uploads downloaded files to where you want.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
nodeType: "fileDownload",
|
|
||||||
icon: (
|
|
||||||
<WorkflowBlockIcon
|
|
||||||
workflowBlockType={WorkflowBlockTypes.FileDownload}
|
|
||||||
className="size-6"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
title: "File Download Block",
|
|
||||||
description: "Download a file",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
nodeType: "wait",
|
nodeType: "wait",
|
||||||
@@ -219,7 +96,150 @@ const nodeLibraryItems: Array<{
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
title: "Wait Block",
|
title: "Wait Block",
|
||||||
description: "Wait for some time",
|
description: "Wait for a specified amount of time",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "validation",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.Validation}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Validation Block",
|
||||||
|
description: "Validate completion criteria",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "url",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.URL}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Go to URL Block",
|
||||||
|
description: "Navigate to a specific URL",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "http_request",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.HttpRequest}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "HTTP Request Block",
|
||||||
|
description: "Make HTTP API calls",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "textPrompt",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.TextPrompt}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Text Prompt Block",
|
||||||
|
description: "Process text with LLM",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "codeBlock",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.Code}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Code Block",
|
||||||
|
description: "Execute custom Python code",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "fileDownload",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.FileDownload}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "File Download Block",
|
||||||
|
description: "Download files from a website",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "loop",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.ForLoop}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Loop Block",
|
||||||
|
description: "Repeat blocks for each item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "sendEmail",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.SendEmail}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Send Email Block",
|
||||||
|
description: "Send email notifications",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "fileParser",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.FileURLParser}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "File Parser Block",
|
||||||
|
description: "Parse data from files",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "upload",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.UploadToS3}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Upload to S3 Block",
|
||||||
|
description: "Upload files to AWS S3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "fileUpload",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.FileUpload}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "File Upload Block",
|
||||||
|
description: "Upload files to storage",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "download",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.DownloadToS3}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "Download to S3 Block",
|
||||||
|
description: "Download files to AWS S3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
nodeType: "pdfParser",
|
||||||
|
icon: (
|
||||||
|
<WorkflowBlockIcon
|
||||||
|
workflowBlockType={WorkflowBlockTypes.PDFParser}
|
||||||
|
className="size-6"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: "PDF Parser Block",
|
||||||
|
description: "Extract data from PDF files",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
WorkflowBlockTypes,
|
WorkflowBlockTypes,
|
||||||
WorkflowParameterTypes,
|
WorkflowParameterTypes,
|
||||||
WorkflowParameterValueType,
|
WorkflowParameterValueType,
|
||||||
|
debuggableWorkflowBlockTypes,
|
||||||
type AWSSecretParameter,
|
type AWSSecretParameter,
|
||||||
type OutputParameter,
|
type OutputParameter,
|
||||||
type Parameter,
|
type Parameter,
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
Taskv2BlockYAML,
|
Taskv2BlockYAML,
|
||||||
URLBlockYAML,
|
URLBlockYAML,
|
||||||
FileUploadBlockYAML,
|
FileUploadBlockYAML,
|
||||||
|
HttpRequestBlockYAML,
|
||||||
} from "../types/workflowYamlTypes";
|
} from "../types/workflowYamlTypes";
|
||||||
import {
|
import {
|
||||||
EMAIL_BLOCK_SENDER,
|
EMAIL_BLOCK_SENDER,
|
||||||
@@ -99,7 +101,7 @@ import {
|
|||||||
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
|
import { taskv2NodeDefaultData } from "./nodes/Taskv2Node/types";
|
||||||
import { urlNodeDefaultData } from "./nodes/URLNode/types";
|
import { urlNodeDefaultData } from "./nodes/URLNode/types";
|
||||||
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
|
import { fileUploadNodeDefaultData } from "./nodes/FileUploadNode/types";
|
||||||
import { debuggableWorkflowBlockTypes } from "@/routes/workflows/types/workflowTypes";
|
import { httpRequestNodeDefaultData } from "./nodes/HttpRequestNode/types";
|
||||||
|
|
||||||
export const NEW_NODE_LABEL_PREFIX = "block_";
|
export const NEW_NODE_LABEL_PREFIX = "block_";
|
||||||
|
|
||||||
@@ -529,6 +531,23 @@ function convertToNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "http_request": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "http_request",
|
||||||
|
data: {
|
||||||
|
...commonData,
|
||||||
|
method: block.method,
|
||||||
|
url: block.url ?? "",
|
||||||
|
headers: JSON.stringify(block.headers || {}, null, 2),
|
||||||
|
body: JSON.stringify(block.body || {}, null, 2),
|
||||||
|
timeout: block.timeout,
|
||||||
|
followRedirects: block.follow_redirects,
|
||||||
|
parameterKeys: block.parameters.map((p) => p.key),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +970,17 @@ function createNode(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "http_request": {
|
||||||
|
return {
|
||||||
|
...identifiers,
|
||||||
|
...common,
|
||||||
|
type: "http_request",
|
||||||
|
data: {
|
||||||
|
...httpRequestNodeDefaultData,
|
||||||
|
label,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1233,6 +1263,22 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML {
|
|||||||
url: node.data.url,
|
url: node.data.url,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case "http_request": {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
block_type: "http_request",
|
||||||
|
method: node.data.method,
|
||||||
|
url: node.data.url,
|
||||||
|
headers: JSONParseSafe(node.data.headers) as Record<
|
||||||
|
string,
|
||||||
|
string
|
||||||
|
> | null,
|
||||||
|
body: JSONParseSafe(node.data.body) as Record<string, unknown> | null,
|
||||||
|
timeout: node.data.timeout,
|
||||||
|
follow_redirects: node.data.followRedirects,
|
||||||
|
parameter_keys: node.data.parameterKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
throw new Error("Invalid node type for getWorkflowBlock");
|
throw new Error("Invalid node type for getWorkflowBlock");
|
||||||
}
|
}
|
||||||
@@ -1986,6 +2032,20 @@ function convertBlocksToBlockYAML(
|
|||||||
};
|
};
|
||||||
return blockYaml;
|
return blockYaml;
|
||||||
}
|
}
|
||||||
|
case "http_request": {
|
||||||
|
const blockYaml: HttpRequestBlockYAML = {
|
||||||
|
...base,
|
||||||
|
block_type: "http_request",
|
||||||
|
method: block.method,
|
||||||
|
url: block.url,
|
||||||
|
headers: block.headers,
|
||||||
|
body: block.body,
|
||||||
|
timeout: block.timeout,
|
||||||
|
follow_redirects: block.follow_redirects,
|
||||||
|
parameter_keys: block.parameters.map((p) => p.key),
|
||||||
|
};
|
||||||
|
return blockYaml;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,7 +190,8 @@ export type WorkflowBlock =
|
|||||||
| FileDownloadBlock
|
| FileDownloadBlock
|
||||||
| PDFParserBlock
|
| PDFParserBlock
|
||||||
| Taskv2Block
|
| Taskv2Block
|
||||||
| URLBlock;
|
| URLBlock
|
||||||
|
| HttpRequestBlock;
|
||||||
|
|
||||||
export const WorkflowBlockTypes = {
|
export const WorkflowBlockTypes = {
|
||||||
Task: "task",
|
Task: "task",
|
||||||
@@ -212,6 +213,7 @@ export const WorkflowBlockTypes = {
|
|||||||
PDFParser: "pdf_parser",
|
PDFParser: "pdf_parser",
|
||||||
Taskv2: "task_v2",
|
Taskv2: "task_v2",
|
||||||
URL: "goto_url",
|
URL: "goto_url",
|
||||||
|
HttpRequest: "http_request",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([
|
export const debuggableWorkflowBlockTypes: Set<WorkflowBlockType> = new Set([
|
||||||
@@ -462,6 +464,17 @@ export type URLBlock = WorkflowBlockBase & {
|
|||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpRequestBlock = WorkflowBlockBase & {
|
||||||
|
block_type: "http_request";
|
||||||
|
method: string;
|
||||||
|
url: string | null;
|
||||||
|
headers: Record<string, string> | null;
|
||||||
|
body: Record<string, unknown> | null;
|
||||||
|
timeout: number;
|
||||||
|
follow_redirects: boolean;
|
||||||
|
parameters: Array<WorkflowParameter>;
|
||||||
|
};
|
||||||
|
|
||||||
export type WorkflowDefinition = {
|
export type WorkflowDefinition = {
|
||||||
parameters: Array<Parameter>;
|
parameters: Array<Parameter>;
|
||||||
blocks: Array<WorkflowBlock>;
|
blocks: Array<WorkflowBlock>;
|
||||||
|
|||||||
@@ -120,7 +120,8 @@ export type BlockYAML =
|
|||||||
| FileDownloadBlockYAML
|
| FileDownloadBlockYAML
|
||||||
| PDFParserBlockYAML
|
| PDFParserBlockYAML
|
||||||
| Taskv2BlockYAML
|
| Taskv2BlockYAML
|
||||||
| URLBlockYAML;
|
| URLBlockYAML
|
||||||
|
| HttpRequestBlockYAML;
|
||||||
|
|
||||||
export type BlockYAMLBase = {
|
export type BlockYAMLBase = {
|
||||||
block_type: WorkflowBlockType;
|
block_type: WorkflowBlockType;
|
||||||
@@ -328,3 +329,14 @@ export type URLBlockYAML = BlockYAMLBase & {
|
|||||||
block_type: "goto_url";
|
block_type: "goto_url";
|
||||||
url: string;
|
url: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type HttpRequestBlockYAML = BlockYAMLBase & {
|
||||||
|
block_type: "http_request";
|
||||||
|
method: string;
|
||||||
|
url: string | null;
|
||||||
|
headers: Record<string, string> | null;
|
||||||
|
body: Record<string, unknown> | null;
|
||||||
|
timeout: number;
|
||||||
|
follow_redirects: boolean;
|
||||||
|
parameter_keys?: Array<string> | null;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user