mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(editor): highlight run-blocking blocks and gate the Run CTA (SKY-10569) (#7139)
This commit is contained in:
parent
d723de621d
commit
ba2ec32e15
20 changed files with 779 additions and 74 deletions
|
|
@ -58,12 +58,7 @@ import { parseHeaderJson } from "@/util/secretHeaders";
|
|||
|
||||
import { MAX_SCREENSHOT_SCROLLS_DEFAULT } from "./editor/nodes/Taskv2Node/types";
|
||||
import { getLabelForWorkflowParameterType } from "./editor/workflowEditorUtils";
|
||||
import {
|
||||
WorkflowApiResponse,
|
||||
WorkflowBlock,
|
||||
WorkflowParameter,
|
||||
isNestedLoopWorkflowBlock,
|
||||
} from "./types/workflowTypes";
|
||||
import { WorkflowApiResponse, WorkflowParameter } from "./types/workflowTypes";
|
||||
import { WorkflowParameterInput } from "./WorkflowParameterInput";
|
||||
import { BrowserProfileSelector } from "./components/BrowserProfileSelector";
|
||||
import { TestWebhookDialog } from "@/components/TestWebhookDialog";
|
||||
|
|
@ -72,31 +67,7 @@ import {
|
|||
parseJsonWorkflowParameterValue,
|
||||
validateJsonWorkflowParameterValue,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Recursively finds all login blocks that don't have any credential parameters selected.
|
||||
* Checks nested blocks inside for_loop blocks as well.
|
||||
*/
|
||||
function getLoginBlocksWithoutCredentials(
|
||||
blocks: Array<WorkflowBlock>,
|
||||
): Array<{ label: string }> {
|
||||
const result: Array<{ label: string }> = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (block.block_type === "login") {
|
||||
// Login block requires at least one parameter (credential) to be selected
|
||||
if (!block.parameters || block.parameters.length === 0) {
|
||||
result.push({ label: block.label });
|
||||
}
|
||||
}
|
||||
|
||||
if (isNestedLoopWorkflowBlock(block) && block.loop_blocks) {
|
||||
result.push(...getLoginBlocksWithoutCredentials(block.loop_blocks));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
import { getLoginBlocksWithoutCredentials } from "./runValidation";
|
||||
|
||||
/**
|
||||
* Validates the workflow for issues that would prevent it from running.
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ import { ZoomInControl } from "./controls/ZoomInControl";
|
|||
import { ZoomOutControl } from "./controls/ZoomOutControl";
|
||||
import { blockTypeFromNode } from "./nodes/blockTypeFromNode";
|
||||
import { OPEN_WORKFLOW_SETTINGS_EVENT } from "./nodes/StartNode/types";
|
||||
import { useLocateBlockStore } from "./runValidation/useLocateBlockStore";
|
||||
import {
|
||||
ParametersState,
|
||||
parameterIsSkyvernCredential,
|
||||
|
|
@ -636,6 +637,54 @@ function FlowRenderer({
|
|||
};
|
||||
}, [centerOffsetX, reactFlowInstance]);
|
||||
|
||||
// Locate runs under fitViewInProgressRef so the pan constraint can't clobber the move; read-only canvases ignore it.
|
||||
const locateRequest = useLocateBlockStore((state) => state.request);
|
||||
const clearLocate = useLocateBlockStore((state) => state.clearLocate);
|
||||
const locateGuardTimerRef = useRef<number | null>(null);
|
||||
useEffect(() => {
|
||||
if (readOnly || !locateRequest) {
|
||||
return;
|
||||
}
|
||||
const { nodeId } = locateRequest;
|
||||
if (!reactFlowInstance.getNode(nodeId)) {
|
||||
clearLocate();
|
||||
return;
|
||||
}
|
||||
const LOCATE_PAN_DURATION_MS = 500;
|
||||
const LOCATE_GUARD_BUFFER_MS = 50;
|
||||
setSelectedBlockId(nodeId);
|
||||
fitViewInProgressRef.current = true;
|
||||
reactFlowInstance.fitView({
|
||||
nodes: [{ id: nodeId }],
|
||||
maxZoom: 1,
|
||||
duration: LOCATE_PAN_DURATION_MS,
|
||||
});
|
||||
// Deliberately not cleared in this effect's cleanup: clearLocate() re-runs the effect, which would drop the guard mid-pan.
|
||||
if (locateGuardTimerRef.current !== null) {
|
||||
window.clearTimeout(locateGuardTimerRef.current);
|
||||
}
|
||||
locateGuardTimerRef.current = window.setTimeout(() => {
|
||||
fitViewInProgressRef.current = false;
|
||||
locateGuardTimerRef.current = null;
|
||||
}, LOCATE_PAN_DURATION_MS + LOCATE_GUARD_BUFFER_MS);
|
||||
clearLocate();
|
||||
}, [
|
||||
locateRequest,
|
||||
readOnly,
|
||||
reactFlowInstance,
|
||||
setSelectedBlockId,
|
||||
clearLocate,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (locateGuardTimerRef.current !== null) {
|
||||
window.clearTimeout(locateGuardTimerRef.current);
|
||||
}
|
||||
clearLocate();
|
||||
};
|
||||
}, [clearLocate]);
|
||||
|
||||
// Canvas zoom + fit-view keyboard shortcuts. Gated to non-read-only
|
||||
// canvases for the same reason as the Escape handler above. Skip when
|
||||
// the user is typing in any text field so Cmd+- in a textarea still
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ import { EditorOverflowMenu } from "./header/EditorOverflowMenu";
|
|||
import { useIsGeneratingCode } from "./hooks/useIsGeneratingCode";
|
||||
import { useSaveWorkflow } from "./hooks/useSaveWorkflow";
|
||||
import { useToggleCodeView } from "./hooks/useToggleCodeView";
|
||||
import { getRunBlockingTooltipText } from "./runValidation/runBlockingCopy";
|
||||
import { useRunValidationStore } from "./runValidation/useRunValidationStore";
|
||||
import { useWorkflowHeaderCollapseStore } from "./useWorkflowHeaderCollapseStore";
|
||||
import { WorkflowHeaderCollapseTab } from "./WorkflowHeaderCollapseTab";
|
||||
|
||||
|
|
@ -202,18 +204,44 @@ function RunButton() {
|
|||
const { workflowPermanentId } = useParams();
|
||||
const closeWorkflowPanel = useWorkflowPanelStore((s) => s.closeWorkflowPanel);
|
||||
const isRecording = useRecordingStore().isRecording;
|
||||
const blockingBlocks = useRunValidationStore((s) => s.blockingBlocks);
|
||||
const hasBlockingBlocks = blockingBlocks.length > 0;
|
||||
|
||||
const handleClick = () => {
|
||||
closeWorkflowPanel();
|
||||
navigate(`/agents/${workflowPermanentId}/run`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button disabled={isRecording} size="lg" onClick={handleClick}>
|
||||
const button = (
|
||||
<Button
|
||||
disabled={isRecording || hasBlockingBlocks}
|
||||
size="lg"
|
||||
onClick={handleClick}
|
||||
>
|
||||
<PlayIcon className="mr-2 h-6 w-6" />
|
||||
Run
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!hasBlockingBlocks) {
|
||||
return button;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
{/* Disabled buttons swallow pointer events; the focusable span keeps the tooltip reachable. */}
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} className="inline-flex">
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{getRunBlockingTooltipText(blockingBlocks)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorActionToolbar() {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,16 @@ import {
|
|||
} from "./workflowEditorUtils";
|
||||
import { replayPersistedCollapseVisibility } from "./collapse/applyDescendantCollapseVisibility";
|
||||
import { useNodeCollapseStore } from "./collapse/useNodeCollapseStore";
|
||||
import { useSyncRunValidationStore } from "./runValidation/useSyncRunValidationStore";
|
||||
import {
|
||||
RUN_BLOCKING_SURFACE_TOP,
|
||||
RUN_BLOCKING_SURFACE_TOP_VAR,
|
||||
RunBlockingSurface,
|
||||
WORKFLOW_EDITOR_HEADER_HEIGHT,
|
||||
WORKFLOW_EDITOR_HEADER_HEIGHT_VAR,
|
||||
WORKFLOW_EDITOR_HEADER_TOP,
|
||||
WORKFLOW_EDITOR_HEADER_TOP_VAR,
|
||||
} from "./runValidation/RunBlockingSurface";
|
||||
import {
|
||||
BLOCK_SIDEBAR_WIDTH_VAR,
|
||||
HEADER_RIGHT_INSET_CLOSED,
|
||||
|
|
@ -436,6 +446,7 @@ function Workspace({
|
|||
const postHog = usePostHog();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
useSyncRunValidationStore(nodes);
|
||||
const {
|
||||
undo: undoWorkflowEdit,
|
||||
redo: redoWorkflowEdit,
|
||||
|
|
@ -1897,6 +1908,9 @@ function Workspace({
|
|||
[BLOCK_SIDEBAR_WIDTH_VAR]: embedded
|
||||
? "0px"
|
||||
: `${renderedBlockSidebarWidth}px`,
|
||||
[WORKFLOW_EDITOR_HEADER_TOP_VAR]: WORKFLOW_EDITOR_HEADER_TOP,
|
||||
[WORKFLOW_EDITOR_HEADER_HEIGHT_VAR]: WORKFLOW_EDITOR_HEADER_HEIGHT,
|
||||
[RUN_BLOCKING_SURFACE_TOP_VAR]: RUN_BLOCKING_SURFACE_TOP,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
|
@ -1986,7 +2000,8 @@ function Workspace({
|
|||
{!embedded && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-6 top-8 z-40 h-20 transition-all duration-300 ease-out",
|
||||
"absolute left-6 top-[var(--workflow-editor-header-top)] z-40",
|
||||
"h-[var(--workflow-editor-header-height)] transition-all duration-300 ease-out",
|
||||
headerEffectiveSidebarOpen
|
||||
? HEADER_RIGHT_INSET_OPEN
|
||||
: HEADER_RIGHT_INSET_CLOSED,
|
||||
|
|
@ -2001,6 +2016,8 @@ function Workspace({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<RunBlockingSurface enabled={!workflowPanelState.data?.showComparison} />
|
||||
|
||||
{/* comparison view (takes precedence over both browser and non-browser modes) */}
|
||||
{workflowPanelState.data?.showComparison &&
|
||||
workflowPanelState.data?.version1 &&
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ import { PdfFillNode as PdfFillNodeComponent } from "./PdfFillNode/PdfFillNode";
|
|||
import { withSortableBlock } from "../sortable/withSortableBlock";
|
||||
import { withCollapsible } from "../collapse/withCollapsible";
|
||||
import { withSelectableBlock } from "../selection/withSelectableBlock";
|
||||
import { withRunValidationHighlight } from "../runValidation/withRunValidationHighlight";
|
||||
|
||||
export type UtilityNode = StartNode | NodeAdderNode;
|
||||
|
||||
|
|
@ -108,12 +109,18 @@ export type AppNode = UtilityNode | WorkflowBlockNode;
|
|||
// withSortableBlock - registers `useSortable({ id })`; the inner tree
|
||||
// must mount in both open and collapsed states so
|
||||
// drag pickup works on either
|
||||
// withRunValidationHighlight - amber outline + badge around the
|
||||
// selectable card, inside the drag registration
|
||||
// withSelectableBlock - reads `useSortable.isDragging` to suppress
|
||||
// selection on drag pickup
|
||||
// withCollapsible - leaf wrapper for body chrome
|
||||
function wrapBlock<P extends NodeProps>(Component: ComponentType<P>) {
|
||||
return memo(
|
||||
withSortableBlock(withSelectableBlock(withCollapsible(Component))),
|
||||
withSortableBlock(
|
||||
withRunValidationHighlight(
|
||||
withSelectableBlock(withCollapsible(Component)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { RunBlockingSurface } from "./RunBlockingSurface";
|
||||
import { useLocateBlockStore } from "./useLocateBlockStore";
|
||||
import { useRunValidationStore } from "./useRunValidationStore";
|
||||
|
||||
function renderAt(search: string) {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[`/workflows/x/build${search}`]}>
|
||||
<RunBlockingSurface />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("RunBlockingSurface", () => {
|
||||
beforeEach(() => {
|
||||
useRunValidationStore.getState().setBlockingBlocks([]);
|
||||
useLocateBlockStore.getState().clearLocate();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useRunValidationStore.getState().setBlockingBlocks([]);
|
||||
useLocateBlockStore.getState().clearLocate();
|
||||
});
|
||||
|
||||
test("renders nothing when no blocks are run-blocking", () => {
|
||||
const { container } = renderAt("");
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test("renders nothing when disabled", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
|
||||
const { container } = render(
|
||||
<MemoryRouter>
|
||||
<RunBlockingSurface enabled={false} />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
test("lists every blocking block", () => {
|
||||
useRunValidationStore.getState().setBlockingBlocks([
|
||||
{ id: "n2", label: "block_2" },
|
||||
{ id: "n3", label: "block_3" },
|
||||
]);
|
||||
renderAt("");
|
||||
expect(screen.getByText("2 blocks need fixing")).toBeTruthy();
|
||||
expect(screen.getByText("block_2")).toBeTruthy();
|
||||
expect(screen.getByText("block_3")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("clicking a block requests locate with its node id", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
|
||||
renderAt("");
|
||||
fireEvent.click(screen.getByText("block_2"));
|
||||
expect(useLocateBlockStore.getState().request?.nodeId).toBe("n2");
|
||||
});
|
||||
|
||||
test("keeps obsolete variant query params on the final panel", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n2", label: "block_2" }]);
|
||||
renderAt("?rbv=b");
|
||||
expect(screen.getByText("1 block needs fixing")).toBeTruthy();
|
||||
expect(screen.getByText("block_2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import { Crosshair2Icon, LockClosedIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
|
||||
import { useLocateBlockStore } from "./useLocateBlockStore";
|
||||
import { useRunValidationStore } from "./useRunValidationStore";
|
||||
|
||||
// The only run-blocking rule today is a login block missing a credential.
|
||||
const BLOCK_REASON = "Login · needs a credential";
|
||||
const RUN_BLOCKING_PANEL_GAP_BELOW_HEADER = "1.75rem";
|
||||
const RUN_BLOCKING_PANEL_WIDTH_CLASS = "w-[19rem]";
|
||||
|
||||
export const WORKFLOW_EDITOR_HEADER_TOP_VAR = "--workflow-editor-header-top";
|
||||
export const WORKFLOW_EDITOR_HEADER_HEIGHT_VAR =
|
||||
"--workflow-editor-header-height";
|
||||
export const RUN_BLOCKING_SURFACE_TOP_VAR = "--run-blocking-surface-top";
|
||||
export const WORKFLOW_EDITOR_HEADER_TOP = "2rem";
|
||||
export const WORKFLOW_EDITOR_HEADER_HEIGHT = "5rem";
|
||||
export const RUN_BLOCKING_SURFACE_TOP = `calc(${[
|
||||
`var(${WORKFLOW_EDITOR_HEADER_TOP_VAR})`,
|
||||
`var(${WORKFLOW_EDITOR_HEADER_HEIGHT_VAR})`,
|
||||
RUN_BLOCKING_PANEL_GAP_BELOW_HEADER,
|
||||
].join(" + ")})`;
|
||||
|
||||
type ListProps = {
|
||||
blocks: Array<RunBlockingBlock>;
|
||||
onLocate: (nodeId: string) => void;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function RunBlockingRow({
|
||||
block,
|
||||
onLocate,
|
||||
}: {
|
||||
block: RunBlockingBlock;
|
||||
onLocate: (nodeId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onLocate(block.id)}
|
||||
className="group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left transition-colors hover:bg-slate-elevation5 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-amber-400"
|
||||
>
|
||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md border border-amber-500/40 bg-amber-500/15 text-amber-400">
|
||||
<LockClosedIcon className="size-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-slate-50">
|
||||
{block.label}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-slate-400">
|
||||
{BLOCK_REASON}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-1 text-xs text-slate-400 group-hover:text-slate-200">
|
||||
Locate
|
||||
<Crosshair2Icon className="size-3.5" />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function RunBlockingList({ blocks, onLocate, className }: ListProps) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-0.5", className)}>
|
||||
{blocks.map((block) => (
|
||||
<RunBlockingRow key={block.id} block={block} onLocate={onLocate} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function blockCountText(count: number): string {
|
||||
return `${count} block${count === 1 ? "" : "s"} need${count === 1 ? "s" : ""} fixing`;
|
||||
}
|
||||
|
||||
function RunBlockingPanel({ blocks, onLocate }: ListProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-6 top-[var(--run-blocking-surface-top,8.75rem)] z-50",
|
||||
RUN_BLOCKING_PANEL_WIDTH_CLASS,
|
||||
"rounded-xl border border-slate-700 bg-slate-elevation3 p-3 shadow-2xl",
|
||||
)}
|
||||
>
|
||||
<div className="px-1 pb-2">
|
||||
<p className="text-sm font-semibold text-slate-100">
|
||||
{blockCountText(blocks.length)}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">
|
||||
Resolve these before you can run
|
||||
</p>
|
||||
</div>
|
||||
<RunBlockingList blocks={blocks} onLocate={onLocate} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function RunBlockingSurface({ enabled = true }: Props = {}) {
|
||||
const blocks = useRunValidationStore((state) => state.blockingBlocks);
|
||||
const locate = useLocateBlockStore((state) => state.requestLocate);
|
||||
|
||||
if (!enabled || blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <RunBlockingPanel blocks={blocks} onLocate={locate} />;
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import type { AppNode } from "../nodes";
|
||||
import { getRunBlockingBlocks } from "./getRunBlockingBlocks";
|
||||
|
||||
function loginNode(
|
||||
id: string,
|
||||
label: string,
|
||||
parameterKeys: Array<string>,
|
||||
): AppNode {
|
||||
return {
|
||||
id,
|
||||
type: "login",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label, parameterKeys },
|
||||
} as unknown as AppNode;
|
||||
}
|
||||
|
||||
function taskNode(id: string, label: string): AppNode {
|
||||
return {
|
||||
id,
|
||||
type: "task",
|
||||
position: { x: 0, y: 0 },
|
||||
data: { label },
|
||||
} as unknown as AppNode;
|
||||
}
|
||||
|
||||
describe("getRunBlockingBlocks", () => {
|
||||
it("returns id + label for login blocks with no credential", () => {
|
||||
expect(getRunBlockingBlocks([loginNode("n1", "block_2", [])])).toEqual([
|
||||
{ id: "n1", label: "block_2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not flag login blocks that have a credential", () => {
|
||||
expect(
|
||||
getRunBlockingBlocks([loginNode("n1", "block_2", ["cred_param"])]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns every offending login block, preserving node identity", () => {
|
||||
const nodes = [
|
||||
loginNode("n1", "block_2", []),
|
||||
taskNode("n2", "block_1"),
|
||||
loginNode("n3", "block_3", []),
|
||||
loginNode("n4", "block_4", ["cred"]),
|
||||
];
|
||||
expect(getRunBlockingBlocks(nodes)).toEqual([
|
||||
{ id: "n1", label: "block_2" },
|
||||
{ id: "n3", label: "block_3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never flags non-login blocks", () => {
|
||||
expect(getRunBlockingBlocks([taskNode("n1", "block_1")])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { isLoginNode } from "../nodes/LoginNode/types";
|
||||
import type { AppNode } from "../nodes";
|
||||
import { isLoginBlockMissingCredentials } from "../../runValidation";
|
||||
|
||||
export type RunBlockingBlock = { id: string; label: string };
|
||||
|
||||
// Login blocks missing a credential block a run (never a save); nested logins appear flat here, so no recursion.
|
||||
export function getRunBlockingBlocks(
|
||||
nodes: Array<AppNode>,
|
||||
): Array<RunBlockingBlock> {
|
||||
return nodes
|
||||
.filter(isLoginNode)
|
||||
.filter((node) =>
|
||||
isLoginBlockMissingCredentials({
|
||||
block_type: "login",
|
||||
label: node.data.label,
|
||||
parameter_keys: node.data.parameterKeys,
|
||||
}),
|
||||
)
|
||||
.map((node) => ({ id: node.id, label: node.data.label }));
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getRunBlockingTooltipText } from "./runBlockingCopy";
|
||||
|
||||
describe("getRunBlockingTooltipText", () => {
|
||||
it("describes the empty fallback contract", () => {
|
||||
expect(getRunBlockingTooltipText([])).toBe(
|
||||
"Select credentials for login blocks before running.",
|
||||
);
|
||||
});
|
||||
|
||||
it("describes one blocking login block", () => {
|
||||
expect(getRunBlockingTooltipText([{ id: "n1", label: "block_2" }])).toBe(
|
||||
'Select a credential for the login block "block_2" before running.',
|
||||
);
|
||||
});
|
||||
|
||||
it("lists multiple blocking login blocks", () => {
|
||||
expect(
|
||||
getRunBlockingTooltipText([
|
||||
{ id: "n1", label: "block_2" },
|
||||
{ id: "n3", label: "block_3" },
|
||||
]),
|
||||
).toBe(
|
||||
"Select credentials for these login blocks before running: block_2, block_3.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
|
||||
|
||||
export function getRunBlockingTooltipText(
|
||||
blocks: Array<RunBlockingBlock>,
|
||||
): string {
|
||||
if (blocks.length === 0) {
|
||||
return "Select credentials for login blocks before running.";
|
||||
}
|
||||
if (blocks.length === 1) {
|
||||
return `Select a credential for the login block "${blocks[0]?.label}" before running.`;
|
||||
}
|
||||
return `Select credentials for these login blocks before running: ${blocks.map((block) => block.label).join(", ")}.`;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
// outline (not ring-*) coexists with the blue selection ring; the badge keeps the cue non-color-only.
|
||||
export const RUN_BLOCKING_OUTLINE_CLASSES =
|
||||
"outline outline-2 outline-offset-2 outline-amber-500";
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
// Fulfilled by FlowRenderer under its pan-constraint guard, so locate works in pan-locked browser/debug mode.
|
||||
type LocateRequest = {
|
||||
nodeId: string;
|
||||
// Bumped per request so locating the same block twice still re-fires the effect.
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
type LocateBlockStore = {
|
||||
request: LocateRequest | null;
|
||||
requestLocate: (nodeId: string) => void;
|
||||
clearLocate: () => void;
|
||||
};
|
||||
|
||||
export const useLocateBlockStore = create<LocateBlockStore>((set, get) => ({
|
||||
request: null,
|
||||
requestLocate: (nodeId) =>
|
||||
set({ request: { nodeId, nonce: (get().request?.nonce ?? 0) + 1 } }),
|
||||
clearLocate: () => set({ request: null }),
|
||||
}));
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
import type { RunBlockingBlock } from "./getRunBlockingBlocks";
|
||||
|
||||
type RunValidationStore = {
|
||||
/** Blocks that must be fixed before the workflow can run. */
|
||||
blockingBlocks: Array<RunBlockingBlock>;
|
||||
blockingBlockIds: ReadonlySet<string>;
|
||||
setBlockingBlocks: (blocks: Array<RunBlockingBlock>) => void;
|
||||
};
|
||||
|
||||
function blockKey(block: RunBlockingBlock): string {
|
||||
return `${block.id}:${block.label}`;
|
||||
}
|
||||
|
||||
function sameBlocks(
|
||||
a: Array<RunBlockingBlock>,
|
||||
b: Array<RunBlockingBlock>,
|
||||
): boolean {
|
||||
if (a.length !== b.length) {
|
||||
return false;
|
||||
}
|
||||
const aKeys = new Set(a.map(blockKey));
|
||||
return (
|
||||
aKeys.size === b.length && b.every((block) => aKeys.has(blockKey(block)))
|
||||
);
|
||||
}
|
||||
|
||||
function blockIdSet(blocks: Array<RunBlockingBlock>): ReadonlySet<string> {
|
||||
return new Set(blocks.map((block) => block.id));
|
||||
}
|
||||
|
||||
export const useRunValidationStore = create<RunValidationStore>((set, get) => ({
|
||||
blockingBlocks: [],
|
||||
blockingBlockIds: new Set(),
|
||||
setBlockingBlocks: (blocks) => {
|
||||
if (sameBlocks(get().blockingBlocks, blocks)) {
|
||||
return;
|
||||
}
|
||||
set({ blockingBlocks: blocks, blockingBlockIds: blockIdSet(blocks) });
|
||||
},
|
||||
}));
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
import type { AppNode } from "../nodes";
|
||||
import { getRunBlockingBlocks } from "./getRunBlockingBlocks";
|
||||
import { useRunValidationStore } from "./useRunValidationStore";
|
||||
|
||||
// Mirrors run-blocking blocks from the live canvas into the shared store; resets on unmount.
|
||||
export function useSyncRunValidationStore(nodes: Array<AppNode>): void {
|
||||
const setBlockingBlocks = useRunValidationStore((s) => s.setBlockingBlocks);
|
||||
|
||||
useEffect(() => {
|
||||
setBlockingBlocks(getRunBlockingBlocks(nodes));
|
||||
}, [nodes, setBlockingBlocks]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => setBlockingBlocks([]);
|
||||
}, [setBlockingBlocks]);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
import { withRunValidationHighlight } from "./withRunValidationHighlight";
|
||||
import { useRunValidationStore } from "./useRunValidationStore";
|
||||
|
||||
function NodeBody() {
|
||||
return <div data-testid="node-body">node</div>;
|
||||
}
|
||||
|
||||
const Wrapped = withRunValidationHighlight(
|
||||
NodeBody as ComponentType<NodeProps>,
|
||||
);
|
||||
|
||||
function nodeProps(id: string, label: string): NodeProps {
|
||||
return { id, data: { label } } as unknown as NodeProps;
|
||||
}
|
||||
|
||||
const BADGE_LABEL = /needs a credential/i;
|
||||
|
||||
describe("withRunValidationHighlight", () => {
|
||||
beforeEach(() => {
|
||||
useRunValidationStore.getState().setBlockingBlocks([]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useRunValidationStore.getState().setBlockingBlocks([]);
|
||||
});
|
||||
|
||||
test("flags a block whose node id is run-blocking", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
|
||||
const { container } = render(<Wrapped {...nodeProps("n1", "block_2")} />);
|
||||
expect(screen.getByLabelText(BADGE_LABEL)).toBeTruthy();
|
||||
expect(container.querySelector('[data-run-blocking="true"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
test("does not flag a healthy block", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
|
||||
const { container } = render(<Wrapped {...nodeProps("n2", "block_1")} />);
|
||||
expect(screen.queryByLabelText(BADGE_LABEL)).toBeNull();
|
||||
expect(container.querySelector('[data-run-blocking="true"]')).toBeNull();
|
||||
});
|
||||
|
||||
test("matches on node id, not label", () => {
|
||||
useRunValidationStore
|
||||
.getState()
|
||||
.setBlockingBlocks([{ id: "n1", label: "block_2" }]);
|
||||
const { container } = render(<Wrapped {...nodeProps("n9", "block_2")} />);
|
||||
expect(container.querySelector('[data-run-blocking="true"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
|
||||
import type { NodeProps } from "@xyflow/react";
|
||||
import type { ComponentType } from "react";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import { RUN_BLOCKING_OUTLINE_CLASSES } from "./runValidationClasses";
|
||||
import { useRunValidationStore } from "./useRunValidationStore";
|
||||
|
||||
// The wrapper div is always present so toggling the highlight never remounts the node.
|
||||
function withRunValidationHighlight<P extends NodeProps>(
|
||||
Component: ComponentType<P>,
|
||||
): ComponentType<P> {
|
||||
function RunValidationHighlight(props: P) {
|
||||
const needsAttention = useRunValidationStore((state) =>
|
||||
state.blockingBlockIds.has(props.id),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-run-blocking={needsAttention ? "true" : undefined}
|
||||
className={cn(
|
||||
"rounded-lg",
|
||||
needsAttention && ["relative", RUN_BLOCKING_OUTLINE_CLASSES],
|
||||
)}
|
||||
>
|
||||
{needsAttention ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
tabIndex={0}
|
||||
className="absolute -right-2.5 -top-2.5 z-20 flex size-6 items-center justify-center rounded-full border border-amber-300 bg-amber-500 text-slate-950 shadow-md"
|
||||
role="img"
|
||||
aria-label="This login block needs a credential before it can run"
|
||||
>
|
||||
<ExclamationTriangleIcon className="size-3.5" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
This login block needs a credential selected before it can run.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
<Component {...props} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
RunValidationHighlight.displayName = `withRunValidationHighlight(${Component.displayName ?? Component.name ?? "Component"})`;
|
||||
return RunValidationHighlight;
|
||||
}
|
||||
|
||||
export { withRunValidationHighlight };
|
||||
46
skyvern-frontend/src/routes/workflows/runValidation.test.ts
Normal file
46
skyvern-frontend/src/routes/workflows/runValidation.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getLoginBlocksWithoutCredentials } from "./runValidation";
|
||||
|
||||
describe("getLoginBlocksWithoutCredentials", () => {
|
||||
it("finds persisted login blocks without credential parameters", () => {
|
||||
expect(
|
||||
getLoginBlocksWithoutCredentials([
|
||||
{ block_type: "login", label: "block_1", parameters: [] },
|
||||
{
|
||||
block_type: "login",
|
||||
label: "block_2",
|
||||
parameters: [{ key: "cred_param" }],
|
||||
},
|
||||
]),
|
||||
).toEqual([{ label: "block_1" }]);
|
||||
});
|
||||
|
||||
it("finds editor login blocks without credential parameter keys", () => {
|
||||
expect(
|
||||
getLoginBlocksWithoutCredentials([
|
||||
{ block_type: "login", label: "block_1", parameter_keys: [] },
|
||||
{
|
||||
block_type: "login",
|
||||
label: "block_2",
|
||||
parameter_keys: ["cred_param"],
|
||||
},
|
||||
]),
|
||||
).toEqual([{ label: "block_1" }]);
|
||||
});
|
||||
|
||||
it("walks nested loop blocks", () => {
|
||||
expect(
|
||||
getLoginBlocksWithoutCredentials([
|
||||
{
|
||||
block_type: "for_loop",
|
||||
label: "loop_1",
|
||||
loop_blocks: [
|
||||
{ block_type: "task", label: "block_1" },
|
||||
{ block_type: "login", label: "block_2", parameters: [] },
|
||||
],
|
||||
},
|
||||
]),
|
||||
).toEqual([{ label: "block_2" }]);
|
||||
});
|
||||
});
|
||||
47
skyvern-frontend/src/routes/workflows/runValidation.ts
Normal file
47
skyvern-frontend/src/routes/workflows/runValidation.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
export type RunValidationBlock = {
|
||||
block_type: string;
|
||||
label: string;
|
||||
parameters?: Array<unknown> | null;
|
||||
parameter_keys?: Array<unknown> | null;
|
||||
loop_blocks?: Array<RunValidationBlock> | null;
|
||||
};
|
||||
|
||||
export type LoginBlockWithoutCredentials = { label: string };
|
||||
|
||||
function loginBlockHasCredential(block: RunValidationBlock): boolean {
|
||||
if ("parameters" in block) {
|
||||
return (block.parameters?.length ?? 0) > 0;
|
||||
}
|
||||
return (block.parameter_keys?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function isLoginBlockMissingCredentials(
|
||||
block: RunValidationBlock,
|
||||
): boolean {
|
||||
return block.block_type === "login" && !loginBlockHasCredential(block);
|
||||
}
|
||||
|
||||
function isNestedLoopRunValidationBlock(block: RunValidationBlock): boolean {
|
||||
return (
|
||||
(block.block_type === "for_loop" || block.block_type === "while_loop") &&
|
||||
Array.isArray(block.loop_blocks)
|
||||
);
|
||||
}
|
||||
|
||||
export function getLoginBlocksWithoutCredentials(
|
||||
blocks: Array<RunValidationBlock>,
|
||||
): Array<LoginBlockWithoutCredentials> {
|
||||
const result: Array<LoginBlockWithoutCredentials> = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
if (isLoginBlockMissingCredentials(block)) {
|
||||
result.push({ label: block.label });
|
||||
}
|
||||
|
||||
if (isNestedLoopRunValidationBlock(block)) {
|
||||
result.push(...getLoginBlocksWithoutCredentials(block.loop_blocks ?? []));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
|
@ -22,6 +22,12 @@ import {
|
|||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useRecordingStore } from "@/store/useRecordingStore";
|
||||
|
|
@ -36,6 +42,8 @@ import { EditorOverflowMenu } from "../editor/header/EditorOverflowMenu";
|
|||
import { MakeACopyButton } from "../editor/MakeACopyButton";
|
||||
import { useSaveWorkflow } from "../editor/hooks/useSaveWorkflow";
|
||||
import { useToggleHistoryPanel } from "../editor/hooks/useToggleHistoryPanel";
|
||||
import { getRunBlockingTooltipText } from "../editor/runValidation/runBlockingCopy";
|
||||
import { useRunValidationStore } from "../editor/runValidation/useRunValidationStore";
|
||||
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
|
||||
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
|
||||
import { runOutcomeFromStatus } from "./runProjections";
|
||||
|
|
@ -140,6 +148,8 @@ export function RunStopButton() {
|
|||
const queryClient = useQueryClient();
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const isRecording = useRecordingStore((s) => s.isRecording);
|
||||
const blockingBlocks = useRunValidationStore((s) => s.blockingBlocks);
|
||||
const hasBlockingBlocks = blockingBlocks.length > 0;
|
||||
const { data: workflowRun } = useWorkflowRunWithWorkflowQuery(
|
||||
runId ? { workflowRunId: runId } : undefined,
|
||||
);
|
||||
|
|
@ -185,6 +195,31 @@ export function RunStopButton() {
|
|||
// form round-trip carries nothing.
|
||||
`/agents/${workflowPermanentId}/run`,
|
||||
);
|
||||
const runButton = (onClick?: () => void) => (
|
||||
<Button
|
||||
size="default"
|
||||
className="h-8 border border-transparent px-3"
|
||||
disabled={isRecording || hasBlockingBlocks}
|
||||
onClick={onClick}
|
||||
>
|
||||
<PlayIcon className="mr-2 size-4" /> Run agent
|
||||
</Button>
|
||||
);
|
||||
const blockedRunButton = (button: ReactNode) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
{/* Disabled buttons swallow pointer events; the focusable span keeps the tooltip reachable. */}
|
||||
<TooltipTrigger asChild>
|
||||
<span tabIndex={0} className="inline-flex">
|
||||
{button}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-xs">
|
||||
{getRunBlockingTooltipText(blockingBlocks)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
if (running && activeRunId) {
|
||||
const stopDialog = (
|
||||
|
|
@ -228,51 +263,46 @@ export function RunStopButton() {
|
|||
if (!isBlockRun) {
|
||||
return stopDialog;
|
||||
}
|
||||
const blockRunButton = runButton();
|
||||
return (
|
||||
<>
|
||||
{stopDialog}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="default"
|
||||
className="h-8 border border-transparent px-3"
|
||||
disabled={isRecording}
|
||||
>
|
||||
<PlayIcon className="mr-2 size-4" /> Run agent
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start a full run?</DialogTitle>
|
||||
<DialogDescription>
|
||||
A block run is still executing. It will keep running — you can
|
||||
watch it in the Browser pane while the Overview pane switches to
|
||||
the new full run.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Not now</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={startFullRun}>Start full run</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{hasBlockingBlocks ? (
|
||||
blockedRunButton(blockRunButton)
|
||||
) : (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>{blockRunButton}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Start a full run?</DialogTitle>
|
||||
<DialogDescription>
|
||||
A block run is still executing. It will keep running — you can
|
||||
watch it in the Browser pane while the Overview pane switches
|
||||
to the new full run.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary">Not now</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button onClick={startFullRun}>Start full run</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="default"
|
||||
className="h-8 border border-transparent px-3"
|
||||
disabled={isRecording}
|
||||
onClick={startFullRun}
|
||||
>
|
||||
<PlayIcon className="mr-2 size-4" /> Run agent
|
||||
</Button>
|
||||
);
|
||||
|
||||
const primaryRunButton = runButton(startFullRun);
|
||||
|
||||
if (!hasBlockingBlocks) {
|
||||
return primaryRunButton;
|
||||
}
|
||||
|
||||
return blockedRunButton(primaryRunButton);
|
||||
}
|
||||
|
||||
export function StudioTopBar() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue