Jon/sky 5820 make browser task block flippable with code (#3165)

This commit is contained in:
Jonathan Dobson
2025-08-11 19:57:08 -04:00
committed by GitHub
parent e5106124e3
commit 039fce0bb3
16 changed files with 663 additions and 339 deletions

View File

@@ -0,0 +1,100 @@
import { ExitIcon } from "@radix-ui/react-icons";
import { Handle } from "@xyflow/react";
import { Position } from "@xyflow/react";
import { WorkflowBlockType } from "@/routes/workflows/types/workflowTypes";
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
import { cn } from "@/util/utils";
import { CodeEditor } from "./CodeEditor";
import { workflowBlockTitle } from "../editor/nodes/types";
import { WorkflowBlockIcon } from "../editor/nodes/WorkflowBlockIcon";
function BlockCodeEditor({
blockLabel,
blockType,
script,
onClick,
}: {
blockLabel: string;
blockType: WorkflowBlockType;
script: string | undefined;
onClick?: (e: React.MouseEvent) => void;
}) {
const blockTitle = workflowBlockTitle[blockType];
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
return (
<div className="h-full">
<Handle
type="source"
position={Position.Bottom}
id="a"
className="opacity-0"
/>
<Handle
type="target"
position={Position.Top}
id="b"
className="opacity-0"
/>
<div
className={cn(
"transform-origin-center flex h-full w-[30rem] flex-col space-y-4 rounded-lg border border-slate-600 bg-slate-elevation3 px-6 py-4 transition-all",
)}
onClick={(e) => {
onClick?.(e);
}}
>
<header className="!mt-0 flex h-[2.75rem] justify-between gap-2">
<div className="flex w-full gap-2">
<div className="relative flex h-[2.75rem] w-[2.75rem] items-center justify-center overflow-hidden rounded border border-slate-600">
<WorkflowBlockIcon
workflowBlockType={blockType}
className="size-6"
/>
<div className="absolute -left-3 top-8 flex h-4 w-16 origin-top-left -rotate-45 transform items-center justify-center bg-yellow-400">
<span className="text-xs font-bold text-black">code</span>
</div>
</div>
<div className="flex flex-col gap-1">
{blockLabel}
<span className="text-xs text-slate-400">{blockTitle}</span>
</div>
<div className="ml-auto flex w-[2.75rem] items-center justify-center rounded hover:bg-slate-800">
<ExitIcon
onClick={() => {
toggleScriptForNodeCallback({
label: blockLabel,
show: false,
});
}}
className="size-5 cursor-pointer"
/>
</div>
</div>
</header>
{script ? (
<div className="h-full flex-1 overflow-y-hidden">
<CodeEditor
key="static"
className="nopan nowheel h-full overflow-y-scroll"
language="python"
value={script}
lineWrap={false}
readOnly
fontSize={10}
/>
</div>
) : (
<div className="flex h-full w-full items-center justify-center bg-slate-950">
No script defined
</div>
)}
</div>
</div>
);
}
export { BlockCodeEditor };

View File

@@ -20,6 +20,7 @@ type Props = {
value: string;
onChange?: (value: string) => void;
language?: "python" | "json" | "html";
lineWrap?: boolean;
readOnly?: boolean;
minHeight?: string;
maxHeight?: string;
@@ -33,13 +34,14 @@ function CodeEditor({
minHeight,
maxHeight,
language,
lineWrap = true,
className,
readOnly = false,
fontSize = 12,
}: Props) {
const extensions = language
? [getLanguageExtension(language), EditorView.lineWrapping]
: [EditorView.lineWrapping];
? [getLanguageExtension(language), lineWrap ? EditorView.lineWrapping : []]
: [lineWrap ? EditorView.lineWrapping : []];
return (
<CodeMirror

View File

@@ -10,7 +10,7 @@ import {
import { toast } from "@/components/ui/use-toast";
import { useOnChange } from "@/hooks/useOnChange";
import { useShouldNotifyWhenClosingTab } from "@/hooks/useShouldNotifyWhenClosingTab";
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { BlockActionContext } from "@/store/BlockActionContext";
import { useDebugStore } from "@/store/useDebugStore";
import {
useWorkflowHasChangesStore,
@@ -543,6 +543,37 @@ function FlowRenderer({
doLayout(newNodesWithUpdatedParameters, newEdges);
}
function toggleScript({
id,
label,
show,
}: {
id?: string;
label?: string;
show: boolean;
}) {
if (id) {
const node = nodes.find((node) => node.id === id);
if (!node || !isWorkflowBlockNode(node)) {
return;
}
node.data.showCode = show;
} else if (label) {
const node = nodes.find(
(node) => "label" in node.data && node.data.label === label,
);
if (!node || !isWorkflowBlockNode(node)) {
return;
}
node.data.showCode = show;
}
doLayout(nodes, edges);
}
const editorElementRef = useRef<HTMLDivElement>(null);
useAutoPan(editorElementRef, nodes);
@@ -642,7 +673,12 @@ function FlowRenderer({
<WorkflowParametersStateContext.Provider
value={[parameters, setParameters]}
>
<DeleteNodeCallbackContext.Provider value={deleteNode}>
<BlockActionContext.Provider
value={{
deleteNodeCallback: deleteNode,
toggleScriptForNodeCallback: toggleScript,
}}
>
<ReactFlow
ref={editorElementRef}
nodes={nodes}
@@ -772,7 +808,7 @@ function FlowRenderer({
</Panel>
)}
</ReactFlow>
</DeleteNodeCallbackContext.Provider>
</BlockActionContext.Provider>
</WorkflowParametersStateContext.Provider>
</>
);

View File

@@ -24,6 +24,8 @@ import { toast } from "@/components/ui/use-toast";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useMountEffect } from "@/hooks/useMountEffect";
import { statusIsFinalized } from "@/routes/tasks/types.ts";
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { useSidebarStore } from "@/store/SidebarStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
@@ -47,12 +49,18 @@ function WorkflowDebugger() {
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
const blockScriptStore = useBlockScriptStore();
const { data: workflowRun } = useWorkflowRunQuery();
const { data: workflow } = useWorkflowQuery({
workflowPermanentId,
});
const { data: blockScripts } = useBlockScriptsQuery({
workflowPermanentId,
});
const { data: debugSession } = useDebugSessionQuery({
workflowPermanentId,
enabled: shouldFetchDebugSession && !!workflowPermanentId,
@@ -80,6 +88,11 @@ function WorkflowDebugger() {
}
});
useEffect(() => {
blockScriptStore.setScripts(blockScripts ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockScripts]);
const afterCycleBrowser = () => {
setOpenDialogue(false);
setShowPowerButton(false);

View File

@@ -1,4 +1,7 @@
import { useEffect } from "react";
import { useMountEffect } from "@/hooks/useMountEffect";
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import { useSidebarStore } from "@/store/SidebarStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { ReactFlowProvider } from "@xyflow/react";
@@ -17,6 +20,7 @@ function WorkflowEditor() {
return state.setCollapsed;
});
const workflowChangesStore = useWorkflowHasChangesStore();
const blockScriptStore = useBlockScriptStore();
const { data: workflow, isLoading } = useWorkflowQuery({
workflowPermanentId,
@@ -25,11 +29,20 @@ function WorkflowEditor() {
const { data: globalWorkflows, isLoading: isGlobalWorkflowsLoading } =
useGlobalWorkflowsQuery();
const { data: blockScripts } = useBlockScriptsQuery({
workflowPermanentId,
});
useMountEffect(() => {
setCollapsed(true);
workflowChangesStore.setHasChanges(false);
});
useEffect(() => {
blockScriptStore.setScripts(blockScripts ?? {});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [blockScripts]);
if (isLoading || isGlobalWorkflowsLoading) {
return (
<div className="flex h-screen w-full items-center justify-center">

View File

@@ -1,3 +1,5 @@
import { useEffect } from "react";
import { Flippable } from "@/components/Flippable";
import { HelpTooltip } from "@/components/HelpTooltip";
import {
Accordion,
@@ -12,7 +14,9 @@ import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { WorkflowBlockInput } from "@/components/WorkflowBlockInput";
import { WorkflowBlockInputTextarea } from "@/components/WorkflowBlockInputTextarea";
import { BlockCodeEditor } from "@/routes/workflows/components/BlockCodeEditor";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { useBlockScriptStore } from "@/store/BlockScriptStore";
import {
Handle,
NodeProps,
@@ -40,7 +44,10 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
const { blockLabel: urlBlockLabel } = useParams();
const debugStore = useDebugStore();
const { updateNodeData } = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front");
const blockScriptStore = useBlockScriptStore();
const { editable, debuggable, label } = data;
const script = blockScriptStore.scripts[label];
const thisBlockIsPlaying =
urlBlockLabel !== undefined && urlBlockLabel === label;
const elideFromDebugging = debugStore.isDebugMode && !debuggable;
@@ -76,359 +83,382 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
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={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
editable={editable}
disabled={elideFromDebugging}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={type}
/>
<div
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
})}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={helpTooltips["navigation"]["url"]} />
</div>
{isFirstWorkflowBlock ? (
<div className="flex justify-end text-xs text-slate-400">
Tip: Use the {"+"} button to add parameters!
</div>
) : null}
</div>
useEffect(() => {
setFacing(data.showCode ? "back" : "front");
}, [data.showCode]);
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);
}}
value={inputs.url}
placeholder={placeholders["navigation"]["url"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Prompt</Label>
<HelpTooltip
content={helpTooltips["navigation"]["navigationGoal"]}
return (
<Flippable facing={facing} preserveFrontsideHeight={true}>
<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={cn(
"transform-origin-center w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
{
"pointer-events-none bg-slate-950 outline outline-2 outline-slate-300":
thisBlockIsPlaying,
},
)}
>
<NodeHeader
blockLabel={label}
editable={editable}
disabled={elideFromDebugging}
nodeId={id}
totpIdentifier={inputs.totpIdentifier}
totpUrl={inputs.totpVerificationUrl}
type={type}
/>
<div
className={cn("space-y-4", {
"opacity-50": thisBlockIsPlaying,
})}
>
<div className="space-y-2">
<div className="flex justify-between">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">URL</Label>
<HelpTooltip content={helpTooltips["navigation"]["url"]} />
</div>
{isFirstWorkflowBlock ? (
<div className="flex justify-end text-xs text-slate-400">
Tip: Use the {"+"} button to add parameters!
</div>
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);
}}
value={inputs.url}
placeholder={placeholders["navigation"]["url"]}
className="nopan text-xs"
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("navigationGoal", value);
}}
value={inputs.navigationGoal}
placeholder={placeholders["navigation"]["navigationGoal"]}
className="nopan text-xs"
/>
</div>
<div className="rounded-md bg-slate-800 p-2">
<div className="space-y-1 text-xs text-slate-400">
Tip: Try to phrase your prompt as a goal with an explicit
completion criteria. While executing, Skyvern will take as many
actions as necessary to accomplish the goal. Use words like
"Complete" or "Terminate" to help Skyvern identify when it's
finished or when it should give up.
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
Navigation Goal
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["navigationGoal"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("navigationGoal", value);
}}
value={inputs.navigationGoal}
placeholder={placeholders["navigation"]["navigationGoal"]}
className="nopan text-xs"
/>
</div>
<div className="rounded-md bg-slate-800 p-2">
<div className="space-y-1 text-xs text-slate-400">
Tip: Try to phrase your prompt as a goal with an explicit
completion criteria. While executing, Skyvern will take as many
actions as necessary to accomplish the goal. Use words like
"Complete" or "Terminate" to help Skyvern identify when it's
finished or when it should give up.
</div>
</div>
</div>
</div>
<Separator />
<Accordion
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
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="space-y-2">
<ParametersMultiSelect
availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys });
}}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-slate-300">
Complete if...
</Label>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("completeCriterion", value);
}}
value={inputs.completeCriterion}
className="nopan text-xs"
/>
</div>
<Separator />
<ModelSelector
className="nopan w-52 text-xs"
value={inputs.model}
onChange={(value) => {
handleChange("model", value);
}}
/>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Engine
</Label>
</div>
<RunEngineSelector
value={inputs.engine}
onChange={(value) => {
handleChange("engine", value);
}}
className="nopan w-52 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Max Steps Override
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["maxStepsOverride"]}
<Separator />
<Accordion
className={cn({
"pointer-events-none opacity-50": thisBlockIsPlaying,
})}
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="space-y-2">
<ParametersMultiSelect
availableOutputParameters={outputParameterKeys}
parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys });
}}
/>
</div>
<Input
type="number"
placeholder={placeholders["navigation"]["maxStepsOverride"]}
<div className="space-y-2">
<Label className="text-xs text-slate-300">
Complete if...
</Label>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("completeCriterion", value);
}}
value={inputs.completeCriterion}
className="nopan text-xs"
/>
</div>
<Separator />
<ModelSelector
className="nopan w-52 text-xs"
min="0"
value={inputs.maxStepsOverride ?? ""}
onChange={(event) => {
const value =
event.target.value === ""
? null
: Number(event.target.value);
handleChange("maxStepsOverride", value);
value={inputs.model}
onChange={(value) => {
handleChange("model", value);
}}
/>
</div>
<div className="space-y-2">
<div className="flex gap-4">
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
Engine
</Label>
</div>
<RunEngineSelector
value={inputs.engine}
onChange={(value) => {
handleChange("engine", value);
}}
className="nopan w-52 text-xs"
/>
</div>
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Max Steps Override
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["errorCodeMapping"]}
content={helpTooltips["navigation"]["maxStepsOverride"]}
/>
</div>
<Checkbox
checked={inputs.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
handleChange(
"errorCodeMapping",
checked
? JSON.stringify(errorMappingExampleValue, null, 2)
: "null",
);
}}
/>
</div>
{inputs.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={inputs.errorCodeMapping}
onChange={(value) => {
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">
Include Action History
</Label>
<HelpTooltip
content={
helpTooltips["navigation"][
"includeActionHistoryInVerification"
]
<Input
type="number"
placeholder={
placeholders["navigation"]["maxStepsOverride"]
}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.includeActionHistoryInVerification}
onCheckedChange={(checked) => {
handleChange(
"includeActionHistoryInVerification",
checked,
);
className="nopan w-52 text-xs"
min="0"
value={inputs.maxStepsOverride ?? ""}
onChange={(event) => {
const value =
event.target.value === ""
? null
: Number(event.target.value);
handleChange("maxStepsOverride", value);
}}
/>
</div>
</div>
<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={helpTooltips["navigation"]["continueOnFailure"]}
/>
<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={
helpTooltips["navigation"]["errorCodeMapping"]
}
/>
</div>
<Checkbox
checked={inputs.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
handleChange(
"errorCodeMapping",
checked
? JSON.stringify(
errorMappingExampleValue,
null,
2,
)
: "null",
);
}}
/>
</div>
{inputs.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={inputs.errorCodeMapping}
onChange={(value) => {
handleChange("errorCodeMapping", value);
}}
className="nowheel nopan"
fontSize={8}
/>
</div>
)}
</div>
<div className="w-52">
<Switch
checked={inputs.continueOnFailure}
onCheckedChange={(checked) => {
handleChange("continueOnFailure", checked);
<Separator />
<div className="flex items-center justify-between">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Include Action History
</Label>
<HelpTooltip
content={
helpTooltips["navigation"][
"includeActionHistoryInVerification"
]
}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.includeActionHistoryInVerification}
onCheckedChange={(checked) => {
handleChange(
"includeActionHistoryInVerification",
checked,
);
}}
/>
</div>
</div>
<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={
helpTooltips["navigation"]["continueOnFailure"]
}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.continueOnFailure}
onCheckedChange={(checked) => {
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={helpTooltips["navigation"]["cacheActions"]}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.cacheActions}
onCheckedChange={(checked) => {
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={
helpTooltips["navigation"]["completeOnDownload"]
}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.allowDownloads}
onCheckedChange={(checked) => {
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={helpTooltips["navigation"]["fileSuffix"]}
/>
</div>
<WorkflowBlockInput
nodeId={id}
type="text"
placeholder={placeholders["navigation"]["downloadSuffix"]}
className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""}
onChange={(value) => {
handleChange("downloadSuffix", value);
}}
/>
</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={helpTooltips["navigation"]["cacheActions"]}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.cacheActions}
onCheckedChange={(checked) => {
handleChange("cacheActions", checked);
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["totpIdentifier"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("totpIdentifier", value);
}}
value={inputs.totpIdentifier ?? ""}
placeholder={placeholders["navigation"]["totpIdentifier"]}
className="nopan text-xs"
/>
</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={helpTooltips["navigation"]["completeOnDownload"]}
/>
</div>
<div className="w-52">
<Switch
checked={inputs.allowDownloads}
onCheckedChange={(checked) => {
handleChange("allowDownloads", checked);
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Verification URL
</Label>
<HelpTooltip
content={helpTooltips["task"]["totpVerificationUrl"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("totpVerificationUrl", value);
}}
value={inputs.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs"
/>
</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={helpTooltips["navigation"]["fileSuffix"]}
/>
</div>
<WorkflowBlockInput
nodeId={id}
type="text"
placeholder={placeholders["navigation"]["downloadSuffix"]}
className="nopan w-52 text-xs"
value={inputs.downloadSuffix ?? ""}
onChange={(value) => {
handleChange("downloadSuffix", value);
}}
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Identifier
</Label>
<HelpTooltip
content={helpTooltips["navigation"]["totpIdentifier"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("totpIdentifier", value);
}}
value={inputs.totpIdentifier ?? ""}
placeholder={placeholders["navigation"]["totpIdentifier"]}
className="nopan text-xs"
/>
</div>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">
2FA Verification URL
</Label>
<HelpTooltip
content={helpTooltips["task"]["totpVerificationUrl"]}
/>
</div>
<WorkflowBlockInputTextarea
nodeId={id}
onChange={(value) => {
handleChange("totpVerificationUrl", value);
}}
value={inputs.totpVerificationUrl ?? ""}
placeholder={placeholders["task"]["totpVerificationUrl"]}
className="nopan text-xs"
/>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
</div>
</div>
<BlockCodeEditor blockLabel={label} blockType={type} script={script} />
</Flippable>
);
}

View File

@@ -7,12 +7,14 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { OrgWalled } from "@/components/Orgwalled";
type Props = {
onDelete: () => void;
onShowScript?: () => void;
};
function NodeActionMenu({ onDelete }: Props) {
function NodeActionMenu({ onDelete, onShowScript }: Props) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -28,6 +30,17 @@ function NodeActionMenu({ onDelete }: Props) {
>
Delete Block
</DropdownMenuItem>
<OrgWalled className="p-0">
{onShowScript && (
<DropdownMenuItem
onSelect={() => {
onShowScript();
}}
>
Show Script
</DropdownMenuItem>
)}
</OrgWalled>
</DropdownMenuContent>
</DropdownMenu>
);

View File

@@ -12,6 +12,7 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler";
import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback";
import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback";
import { useDebugSessionQuery } from "@/routes/workflows/hooks/useDebugSessionQuery";
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
import {
@@ -145,6 +146,7 @@ function NodeHeader({
});
const blockTitle = workflowBlockTitle[type];
const deleteNodeCallback = useDeleteNodeCallback();
const toggleScriptForNodeCallback = useToggleScriptForNodeCallback();
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -411,6 +413,9 @@ function NodeHeader({
onDelete={() => {
deleteNodeCallback(nodeId);
}}
onShowScript={() =>
toggleScriptForNodeCallback({ id: nodeId, show: true })
}
/>
</div>
</div>

View File

@@ -7,6 +7,7 @@ export type NodeBaseData = {
continueOnFailure: boolean;
editable: boolean;
model: WorkflowModel | null;
showCode?: boolean;
};
export const errorMappingExampleValue = {

View File

@@ -0,0 +1,37 @@
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { useQuery } from "@tanstack/react-query";
import { ScriptBlocksResponse } from "../types/scriptTypes";
type Props = {
cacheKey?: string;
cacheKeyValue?: string;
workflowPermanentId?: string;
};
function useBlockScriptsQuery({
cacheKey,
cacheKeyValue,
workflowPermanentId,
}: Props) {
const credentialGetter = useCredentialGetter();
return useQuery<{ [blockName: string]: string }>({
queryKey: ["block-scripts", workflowPermanentId, cacheKey, cacheKeyValue],
queryFn: async () => {
const client = await getClient(credentialGetter, "sans-api-v1");
const result = await client
.post<ScriptBlocksResponse>(`/scripts/${workflowPermanentId}/blocks`, {
cache_key: cacheKey ?? "",
cache_key_value: cacheKeyValue ?? "",
})
.then((response) => response.data);
return result.blocks;
},
enabled: !!workflowPermanentId,
});
}
export { useBlockScriptsQuery };

View File

@@ -1,12 +1,12 @@
import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { BlockActionContext } from "@/store/BlockActionContext";
import { useContext } from "react";
function useDeleteNodeCallback() {
const deleteNodeCallback = useContext(DeleteNodeCallbackContext);
const deleteNodeCallback = useContext(BlockActionContext)?.deleteNodeCallback;
if (!deleteNodeCallback) {
throw new Error(
"useDeleteNodeCallback must be used within a DeleteNodeCallbackProvider",
"useDeleteNodeCallback must be used within a BlockActionContextProvider",
);
}

View File

@@ -0,0 +1,17 @@
import { BlockActionContext } from "@/store/BlockActionContext";
import { useContext } from "react";
function useToggleScriptForNodeCallback() {
const toggleScriptForNodeCallback =
useContext(BlockActionContext)?.toggleScriptForNodeCallback;
if (!toggleScriptForNodeCallback) {
throw new Error(
"useToggleScriptForNodeCallback must be used within a BlockActionContextProvider",
);
}
return toggleScriptForNodeCallback;
}
export { useToggleScriptForNodeCallback };

View File

@@ -0,0 +1,3 @@
export type ScriptBlocksResponse = {
blocks: { [blockName: string]: string };
};

View File

@@ -0,0 +1,18 @@
import { createContext } from "react";
type DeleteNodeCallback = (id: string) => void;
type ToggleScriptForNodeCallback = (opts: {
id?: string;
label?: string;
show: boolean;
}) => void;
const BlockActionContext = createContext<
| {
deleteNodeCallback: DeleteNodeCallback;
toggleScriptForNodeCallback?: ToggleScriptForNodeCallback;
}
| undefined
>(undefined);
export { BlockActionContext };

View File

@@ -0,0 +1,45 @@
/**
* A store to hold the scripts for individual blocks in a workflow. As each
* workflow has uniquely (and differently) labelled blocks, and those labels
* are block identity, we'll eschew strong typing for this, and use a loose
* object literal instead.
*/
import { create } from "zustand";
interface BlockScriptStore {
scriptId?: string;
scripts: { [k: string]: string };
// --
setScript: (blockId: string, script: string) => void;
setScripts: (scripts: { [k: string]: string }) => void;
reset: () => void;
}
const useBlockScriptStore = create<BlockScriptStore>((set) => {
return {
scriptId: undefined,
scripts: {},
// --
setScript: (blockId: string, script: string) => {
set((state) => ({
scripts: {
...state.scripts,
[blockId]: script,
},
}));
},
setScripts: (scripts: { [k: string]: string }) => {
set(() => ({
scripts,
}));
},
reset: () => {
set({
scripts: {},
});
},
};
});
export { useBlockScriptStore };

View File

@@ -1,9 +0,0 @@
import { createContext } from "react";
type DeleteNodeCallback = (id: string) => void;
const DeleteNodeCallbackContext = createContext<DeleteNodeCallback | undefined>(
undefined,
);
export { DeleteNodeCallbackContext };