mirror of
https://github.com/Skyvern-AI/skyvern.git
synced 2026-07-09 16:09:13 +00:00
feat(SKY-11487): advanced full-screen YAML editing mode in workflow editor (#7030)
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Some checks are pending
Run tests and pre-commit / Run tests and pre-commit hooks (push) Waiting to run
Run tests and pre-commit / Frontend Lint and Build (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.11) (push) Waiting to run
Run tests and pre-commit / pip Package Smoke Tests (3.13) (push) Waiting to run
Publish Fern Docs / run (push) Waiting to run
Co-authored-by: AronPerez <aperez0295@gmail.com>
This commit is contained in:
parent
720d47d754
commit
d735f172be
32 changed files with 1006 additions and 43 deletions
27
skyvern-frontend/package-lock.json
generated
27
skyvern-frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
|||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/merge": "^6.8.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -364,6 +365,21 @@
|
|||
"@lezer/python": "^1.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-yaml": {
|
||||
"version": "6.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz",
|
||||
"integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.2.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"@lezer/yaml": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
|
||||
|
|
@ -1427,6 +1443,17 @@
|
|||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/yaml": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz",
|
||||
"integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
"@codemirror/lang-html": "^6.4.9",
|
||||
"@codemirror/lang-json": "^6.0.1",
|
||||
"@codemirror/lang-python": "^6.1.6",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/merge": "^6.8.0",
|
||||
"@dagrejs/dagre": "^1.1.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { ViewUpdate } from "@codemirror/view";
|
|||
import { json } from "@codemirror/lang-json";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import { html } from "@codemirror/lang-html";
|
||||
import { yaml } from "@codemirror/lang-yaml";
|
||||
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "@/util/utils";
|
||||
|
|
@ -14,7 +15,9 @@ import {
|
|||
} from "./oversizedDocument";
|
||||
import "./code-mirror-overrides.css";
|
||||
|
||||
function getLanguageExtension(language: "python" | "json" | "html") {
|
||||
function getLanguageExtension(
|
||||
language: "python" | "json" | "html" | "yaml",
|
||||
): Extension {
|
||||
switch (language) {
|
||||
case "python":
|
||||
return python();
|
||||
|
|
@ -22,13 +25,15 @@ function getLanguageExtension(language: "python" | "json" | "html") {
|
|||
return json();
|
||||
case "html":
|
||||
return html();
|
||||
case "yaml":
|
||||
return yaml();
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
language?: "python" | "json" | "html";
|
||||
language?: "python" | "json" | "html" | "yaml";
|
||||
lineWrap?: boolean;
|
||||
readOnly?: boolean;
|
||||
minHeight?: string;
|
||||
|
|
@ -36,6 +41,10 @@ type Props = {
|
|||
className?: string;
|
||||
fontSize?: number;
|
||||
fullHeight?: boolean;
|
||||
// Accessible name applied to the CodeMirror editing surface (the
|
||||
// contenteditable), not the wrapper div.
|
||||
ariaLabel?: string;
|
||||
autoFocus?: boolean;
|
||||
/**
|
||||
* Additional CodeMirror extensions. Useful for per-use-case concerns
|
||||
* like linting — e.g. the error_code_mapping editor passes a linter
|
||||
|
|
@ -67,6 +76,8 @@ function CodeEditorImpl({
|
|||
fontSize = 12,
|
||||
fullHeight = false,
|
||||
extraExtensions,
|
||||
ariaLabel,
|
||||
autoFocus = false,
|
||||
...restProps
|
||||
}: Props) {
|
||||
// `value` is typed `string`, but workflow document panels can pass an
|
||||
|
|
@ -187,8 +198,18 @@ function CodeEditorImpl({
|
|||
if (fullHeight) {
|
||||
exts.push(fullHeightExtension);
|
||||
}
|
||||
if (ariaLabel) {
|
||||
exts.push(EditorView.contentAttributes.of({ "aria-label": ariaLabel }));
|
||||
}
|
||||
return exts;
|
||||
}, [language, deeplyNested, effectiveLineWrap, extraExtensions, fullHeight]);
|
||||
}, [
|
||||
language,
|
||||
deeplyNested,
|
||||
effectiveLineWrap,
|
||||
extraExtensions,
|
||||
fullHeight,
|
||||
ariaLabel,
|
||||
]);
|
||||
|
||||
const style: React.CSSProperties = { fontSize };
|
||||
if (fullHeight) {
|
||||
|
|
@ -260,6 +281,7 @@ function CodeEditorImpl({
|
|||
minHeight={minHeight}
|
||||
maxHeight={maxHeight}
|
||||
readOnly={readOnly}
|
||||
autoFocus={autoFocus}
|
||||
className={cn("cursor-auto", className)}
|
||||
style={style}
|
||||
{...restProps}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ function getWorkflowElements(version: WorkflowVersion) {
|
|||
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
version.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping: version.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
return getElements(
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ function Debugger() {
|
|||
workflow.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
workflow.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping: workflow.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(blocksToRender, settings, true);
|
||||
|
|
|
|||
|
|
@ -21,6 +21,10 @@ import {
|
|||
type WorkflowSaveData,
|
||||
} from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
|
||||
import {
|
||||
commitYamlDraft,
|
||||
useWorkflowYamlEditorStore,
|
||||
} from "@/store/WorkflowYamlEditorStore";
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
import { useWorkflowSettingsStore } from "@/store/WorkflowSettingsStore";
|
||||
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
|
||||
|
|
@ -973,6 +977,11 @@ function FlowRenderer({
|
|||
}, [constructSaveData, readOnly]);
|
||||
|
||||
async function handleSave(): Promise<boolean> {
|
||||
// With the YAML editor open (e.g. the nav-blocker "Save changes" dialog),
|
||||
// persist the parsed draft directly instead of the stale pre-edit canvas.
|
||||
if (useWorkflowYamlEditorStore.getState().active) {
|
||||
return commitYamlDraft(true);
|
||||
}
|
||||
// Validate before saving; block if any workflow errors exist
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
|
|
@ -989,7 +998,7 @@ function FlowRenderer({
|
|||
});
|
||||
return false;
|
||||
}
|
||||
await saveWorkflow.mutateAsync();
|
||||
await saveWorkflow.mutateAsync(undefined);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,7 @@ function WorkflowEditor() {
|
|||
workflow.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
workflow.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping: workflow.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(blocksToRender, settings, !isGlobalWorkflow);
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import {
|
|||
} from "@/components/ui/tooltip";
|
||||
import { statusIsRunningOrQueued } from "@/routes/tasks/types";
|
||||
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
||||
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
|
||||
import { MakeACopyButton } from "./MakeACopyButton";
|
||||
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
|
||||
import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery";
|
||||
|
|
@ -38,16 +39,6 @@ import { useToggleCodeView } from "./hooks/useToggleCodeView";
|
|||
import { useWorkflowHeaderCollapseStore } from "./useWorkflowHeaderCollapseStore";
|
||||
import { WorkflowHeaderCollapseTab } from "./WorkflowHeaderCollapseTab";
|
||||
|
||||
function useIsGlobalWorkflow(): boolean {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
|
||||
return Boolean(
|
||||
globalWorkflows?.some(
|
||||
(w) => w.workflow_permanent_id === workflowPermanentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function GeneratingCodeButton() {
|
||||
const showAllCode = useShowAllCodeStore((s) => s.showAllCode);
|
||||
const toggleCodeView = useToggleCodeView();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,116 @@
|
|||
import { FocusScope } from "@radix-ui/react-focus-scope";
|
||||
import { ReloadIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import {
|
||||
commitYamlDraft,
|
||||
useWorkflowYamlEditorStore,
|
||||
} from "@/store/WorkflowYamlEditorStore";
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
import { CodeEditor } from "../components/CodeEditor";
|
||||
import { YamlModeToggle } from "./YamlModeToggle";
|
||||
|
||||
type Props = {
|
||||
// "fullscreen" (legacy editor): modal overlay covering the whole editor —
|
||||
// dialog semantics + focus trap. "pane" (studio): swaps the Editor pane's
|
||||
// content; sibling panes stay usable, so no modal semantics or trap.
|
||||
variant?: "fullscreen" | "pane";
|
||||
};
|
||||
|
||||
// YAML editing surface, shown while the store is active. Switching back to
|
||||
// Visual commits the draft into the graph; broken YAML blocks the switch.
|
||||
function WorkflowYamlEditor({ variant = "fullscreen" }: Props) {
|
||||
const draft = useWorkflowYamlEditorStore((s) => s.draft);
|
||||
const error = useWorkflowYamlEditorStore((s) => s.error);
|
||||
const committing = useWorkflowYamlEditorStore((s) => s.committing);
|
||||
const setDraft = useWorkflowYamlEditorStore((s) => s.setDraft);
|
||||
const fullscreen = variant === "fullscreen";
|
||||
|
||||
const switchToVisual = () => {
|
||||
void commitYamlDraft(false);
|
||||
};
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
{...(fullscreen
|
||||
? { role: "dialog", "aria-modal": true }
|
||||
: { role: "region" })}
|
||||
aria-label="Workflow YAML editor"
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col bg-slate-elevation1",
|
||||
// The pane variant sits under stage-level overlays (Inputs/Schedule
|
||||
// panels at z-40) but above everything inside the Editor pane.
|
||||
fullscreen ? "z-50" : "z-30",
|
||||
)}
|
||||
onKeyDown={(event) => {
|
||||
// Standard dialog escape hatch; commit-on-switch means Escape
|
||||
// behaves exactly like the Visual toggle (invalid YAML stays open).
|
||||
if (event.key === "Escape" && !committing) {
|
||||
event.stopPropagation();
|
||||
switchToVisual();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-slate-700 bg-slate-elevation2 px-4 py-2">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<span className="truncate text-xs text-slate-500">
|
||||
Editing the workflow definition · switch to Visual to apply, then
|
||||
Save
|
||||
</span>
|
||||
{committing ? (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-slate-400">
|
||||
<ReloadIcon className="size-3 animate-spin" />
|
||||
Applying…
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<YamlModeToggle
|
||||
mode="code"
|
||||
onVisual={switchToVisual}
|
||||
disabled={committing}
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="border-b border-red-900/50 bg-red-950/60 px-4 py-2 text-sm text-red-200"
|
||||
>
|
||||
<strong className="font-semibold">Invalid YAML:</strong> {error}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1">
|
||||
<CodeEditor
|
||||
language="yaml"
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
readOnly={committing}
|
||||
fullHeight
|
||||
lineWrap={false}
|
||||
className="h-full"
|
||||
ariaLabel="Workflow definition YAML editor"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!fullscreen) {
|
||||
return surface;
|
||||
}
|
||||
|
||||
return (
|
||||
// Trap focus inside the editor while it's open (the canvas is visually
|
||||
// covered but still in the tab order); CodeEditor autoFocus lands the
|
||||
// caret, so prevent FocusScope from stealing focus to the toggle.
|
||||
<FocusScope
|
||||
asChild
|
||||
loop
|
||||
trapped
|
||||
onMountAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{surface}
|
||||
</FocusScope>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkflowYamlEditor };
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
MutableRefObject,
|
||||
} from "react";
|
||||
import { nanoid } from "nanoid";
|
||||
import { stringify as convertToYAML } from "yaml";
|
||||
import { parse as parseYAML, stringify as convertToYAML } from "yaml";
|
||||
import {
|
||||
CheckIcon,
|
||||
ChevronRightIcon,
|
||||
|
|
@ -34,12 +34,17 @@ import {
|
|||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { usePostHog } from "posthog-js/react";
|
||||
|
||||
import {
|
||||
useWorkflowYamlEditorStore,
|
||||
isWorkflowYamlDirty,
|
||||
} from "@/store/WorkflowYamlEditorStore";
|
||||
import { getClient } from "@/api/AxiosClient";
|
||||
import { DebugSessionApiResponse, ProxyLocation } from "@/api/types";
|
||||
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
|
||||
import { useMountEffect } from "@/hooks/useMountEffect";
|
||||
import { useBrowserSessionRateLimit } from "../hooks/useBrowserSessionRateLimit";
|
||||
import { useDebugSessionQuery } from "../hooks/useDebugSessionQuery";
|
||||
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
|
||||
import { useBlockScriptsQuery } from "@/routes/workflows/hooks/useBlockScriptsQuery";
|
||||
import { BrowserSessionStream } from "@/routes/browserSessions/BrowserSessionStream";
|
||||
import { useBrowserStreamingMode } from "@/hooks/useRuntimeConfig";
|
||||
|
|
@ -96,7 +101,11 @@ import {
|
|||
BranchContext,
|
||||
useWorkflowPanelStore,
|
||||
} from "@/store/WorkflowPanelStore";
|
||||
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
|
||||
import {
|
||||
useWorkflowHasChangesStore,
|
||||
useWorkflowSave,
|
||||
type WorkflowSaveData,
|
||||
} from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowParametersStore } from "@/store/WorkflowParametersStore";
|
||||
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
|
||||
import { getCode, getOrderedBlockLabels } from "@/routes/workflows/utils";
|
||||
|
|
@ -134,6 +143,9 @@ import {
|
|||
generateNodeLabel,
|
||||
layout,
|
||||
startNode,
|
||||
getWorkflowBlocks,
|
||||
getWorkflowErrors,
|
||||
upgradeWorkflowDefinitionToVersionTwo,
|
||||
} from "./workflowEditorUtils";
|
||||
import { replayPersistedCollapseVisibility } from "./collapse/applyDescendantCollapseVisibility";
|
||||
import { useNodeCollapseStore } from "./collapse/useNodeCollapseStore";
|
||||
|
|
@ -149,7 +161,7 @@ import { WorkflowHeader } from "./WorkflowHeader";
|
|||
import { WorkflowHistoryPanel } from "./panels/WorkflowHistoryPanel";
|
||||
import { WorkflowSchedulePanel } from "./panels/schedulePanel/WorkflowSchedulePanel";
|
||||
import { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
|
||||
import { WorkflowSettings } from "../types/workflowTypes";
|
||||
import { WorkflowDefinition, WorkflowSettings } from "../types/workflowTypes";
|
||||
import { shouldKeepExistingEdgeForInsertion } from "./workflowInsertion";
|
||||
|
||||
import { constructCacheKeyValue, getInitialParameters } from "./utils";
|
||||
|
|
@ -162,6 +174,13 @@ import { WorkflowCopilotButton } from "../copilot/WorkflowCopilotButton";
|
|||
import { resolveCopilotLiveBrowserReady } from "../copilot/browserReadiness";
|
||||
|
||||
import type { WorkflowYAMLConversionResponse } from "../copilot/workflowCopilotTypes";
|
||||
import { WorkflowYamlEditor } from "./WorkflowYamlEditor";
|
||||
import { YamlModeToggle } from "./YamlModeToggle";
|
||||
import { useWorkflowYamlEditorLifecycle } from "./hooks/useWorkflowYamlEditorLifecycle";
|
||||
import {
|
||||
preservedFinallyBlockLabel,
|
||||
workflowVersionFromSaveData,
|
||||
} from "./workflowVersionFromSaveData";
|
||||
import "./workspace-styles.css";
|
||||
|
||||
function getAxiosErrorDetail(error: unknown): string | undefined {
|
||||
|
|
@ -391,6 +410,10 @@ function Workspace({
|
|||
(s) => s.renderedWidth,
|
||||
);
|
||||
const handleOnSave = useSaveWorkflow();
|
||||
const saveWorkflow = useWorkflowSave({ status: "published" });
|
||||
// Global/read-only workflows can't be edited in place (the header offers
|
||||
// "Make a Copy"), so the YAML editor must not open or commit for them.
|
||||
const isGlobalWorkflow = useIsGlobalWorkflow();
|
||||
const postHog = usePostHog();
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
||||
|
|
@ -480,6 +503,13 @@ function Workspace({
|
|||
const [isReloading, setIsReloading] = useState(false);
|
||||
const credentialGetter = useCredentialGetter();
|
||||
const queryClient = useQueryClient();
|
||||
const yamlEditorActive = useWorkflowYamlEditorStore((s) => s.active);
|
||||
const yamlEditorDirty = useWorkflowYamlEditorStore(
|
||||
(s) => s.active && isWorkflowYamlDirty(s),
|
||||
);
|
||||
// hasChanges at the moment YAML mode opened, so reverting a YAML edit
|
||||
// restores the pre-YAML dirty state instead of leaving it stuck true.
|
||||
const yamlEntryHadChangesRef = useRef(false);
|
||||
const [shouldFetchDebugSession, setShouldFetchDebugSession] = useState(false);
|
||||
const blockScriptStore = useBlockScriptStore();
|
||||
const recordingStore = useRecordingStore();
|
||||
|
|
@ -1444,6 +1474,8 @@ function Workspace({
|
|||
workflowData.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
workflowData.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping:
|
||||
workflowData.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(
|
||||
|
|
@ -1481,6 +1513,205 @@ function Workspace({
|
|||
}
|
||||
};
|
||||
|
||||
// Serialize the live workflow_definition to YAML and open the full-screen
|
||||
// editor. Only the definition round-trips; settings stay in the visual panels.
|
||||
const enterYamlMode = () => {
|
||||
if (isGlobalWorkflow) {
|
||||
return;
|
||||
}
|
||||
const saveData = workflowChangesStore.getSaveData?.();
|
||||
if (!saveData) {
|
||||
toast({
|
||||
title: "Cannot edit YAML",
|
||||
description: "The workflow is still loading. Try again in a moment.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The schema `version` and workflow-level settings (finally_block_label,
|
||||
// workflow_system_prompt, error_code_mapping) are intentionally NOT
|
||||
// serialized — the YAML editor edits parameters + blocks; the rest is
|
||||
// preserved from the current workflow on commit.
|
||||
const yaml = convertToYAML({
|
||||
parameters: saveData.parameters,
|
||||
blocks: saveData.blocks,
|
||||
});
|
||||
yamlEntryHadChangesRef.current =
|
||||
useWorkflowHasChangesStore.getState().hasChanges;
|
||||
useWorkflowYamlEditorStore.getState().open(yaml);
|
||||
};
|
||||
|
||||
// Commit-on-switch: reparse the edited YAML into the graph via the Copilot's
|
||||
// non-persisting convert endpoint. Returns false on invalid YAML (stays open).
|
||||
const commitYaml = async (persist: boolean = false): Promise<boolean> => {
|
||||
const yamlStore = useWorkflowYamlEditorStore.getState();
|
||||
// Never rebuild an editable canvas from YAML for a read-only workflow.
|
||||
if (isGlobalWorkflow) {
|
||||
yamlStore.close();
|
||||
return false;
|
||||
}
|
||||
const saveData = workflowChangesStore.getSaveData?.();
|
||||
if (!saveData) {
|
||||
return false;
|
||||
}
|
||||
if (!isWorkflowYamlDirty(yamlStore)) {
|
||||
// The YAML is unchanged, but a save must still persist any pending
|
||||
// pre-YAML (visual-editor) edits — validate the frozen graph the same way
|
||||
// the normal visual save does, so invalid edits can't slip through here.
|
||||
if (persist) {
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
toast({
|
||||
title: "Can not save workflow because of errors:",
|
||||
description: errors.join(" "),
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await saveWorkflow.mutateAsync(undefined);
|
||||
} catch {
|
||||
// Surfaced by the save mutation's own error toast; keep the editor
|
||||
// open and honor the Promise<boolean> contract instead of throwing.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
yamlStore.close();
|
||||
return true;
|
||||
}
|
||||
let parsed: {
|
||||
parameters?: WorkflowSaveData["parameters"];
|
||||
blocks?: WorkflowSaveData["blocks"];
|
||||
};
|
||||
try {
|
||||
parsed = parseYAML(yamlStore.draft);
|
||||
} catch (error) {
|
||||
yamlStore.setError(
|
||||
error instanceof Error ? error.message : "Could not parse YAML.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const client = await getClient(credentialGetter, "sans-api-v1");
|
||||
const response = await client.post<WorkflowYAMLConversionResponse>(
|
||||
"/workflow/copilot/convert-yaml-to-blocks",
|
||||
{
|
||||
workflow_definition_yaml: yamlStore.draft,
|
||||
workflow_id: saveData.workflow.workflow_id,
|
||||
},
|
||||
);
|
||||
let extraHttpHeaders: Record<string, string> | null;
|
||||
let cdpConnectHeaders: Record<string, string> | null;
|
||||
try {
|
||||
extraHttpHeaders = saveData.settings.extraHttpHeaders
|
||||
? parseHeaderJson(saveData.settings.extraHttpHeaders)
|
||||
: null;
|
||||
cdpConnectHeaders = saveData.settings.cdpConnectHeaders
|
||||
? parseHeaderJson(saveData.settings.cdpConnectHeaders)
|
||||
: null;
|
||||
} catch {
|
||||
yamlStore.setError(
|
||||
"Couldn't parse the workflow's HTTP headers — fix them in the visual editor before switching.",
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// Workflow-level settings aren't in the YAML; carry the current values
|
||||
// back so the round-trip preserves them. finally_block_label points at a
|
||||
// top-level block, so drop it if the edit removed/renamed that block —
|
||||
// otherwise the next save fails on a dangling reference.
|
||||
const finallyBlockLabel = preservedFinallyBlockLabel(
|
||||
saveData.settings.finallyBlockLabel,
|
||||
(response.data.workflow_definition.blocks ?? []).map(
|
||||
(block) => block.label,
|
||||
),
|
||||
);
|
||||
const definition: WorkflowDefinition = {
|
||||
...response.data.workflow_definition,
|
||||
// Keep the convert's detected schema version (it upgrades to 2 for
|
||||
// conditional / next_block_label routing) so it matches the converted
|
||||
// graph — the omitted YAML `version` lets the backend detect it.
|
||||
finally_block_label: finallyBlockLabel,
|
||||
workflow_system_prompt: saveData.settings.workflowSystemPrompt ?? null,
|
||||
error_code_mapping: saveData.settings.errorCodeMapping ?? null,
|
||||
};
|
||||
const version = workflowVersionFromSaveData(saveData, definition, {
|
||||
extraHttpHeaders,
|
||||
cdpConnectHeaders,
|
||||
});
|
||||
if (persist) {
|
||||
// Persist the repaired graph, not the raw draft: the convert endpoint
|
||||
// repairs dangling next_block_label links, so run its blocks through the
|
||||
// same getElements -> getWorkflowBlocks normalization the canvas gets.
|
||||
// Reading the response (not the graph) keeps this race-free vs setNodes.
|
||||
const { nodes: repairedNodes, edges: repairedEdges } = getElements(
|
||||
response.data.workflow_definition.blocks ?? [],
|
||||
{ ...saveData.settings, finallyBlockLabel },
|
||||
true,
|
||||
);
|
||||
// Gate the persist the same way the visual save is gated (a parseable
|
||||
// draft can still be an editor-invalid workflow, e.g. an empty
|
||||
// navigation prompt). A plain switch to Visual stays ungated so the
|
||||
// errors can be fixed on the canvas.
|
||||
const repairedErrors = getWorkflowErrors(repairedNodes);
|
||||
if (repairedErrors.length > 0) {
|
||||
toast({
|
||||
title: "Can not save workflow because of errors:",
|
||||
description: repairedErrors.join(" "),
|
||||
variant: "destructive",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// getWorkflowBlocks emits explicit next_block_label routing from the
|
||||
// graph, so run the same version upgrade the visual save runs — a
|
||||
// version-1 payload with routing is rejected by the backend.
|
||||
const { blocks: upgradedBlocks, version: upgradedVersion } =
|
||||
upgradeWorkflowDefinitionToVersionTwo(
|
||||
getWorkflowBlocks(repairedNodes, repairedEdges),
|
||||
response.data.workflow_definition.version ??
|
||||
saveData.workflowDefinitionVersion,
|
||||
);
|
||||
try {
|
||||
await saveWorkflow.mutateAsync({
|
||||
blocks: upgradedBlocks,
|
||||
parameters: parsed?.parameters ?? [],
|
||||
workflowDefinitionVersion: upgradedVersion,
|
||||
settings: { ...saveData.settings, finallyBlockLabel },
|
||||
});
|
||||
} catch {
|
||||
// A persist/network failure is already surfaced by the save
|
||||
// mutation's own error toast — don't relabel it "Invalid YAML" in the
|
||||
// overlay (the outer catch), and keep the editor open with the draft.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
applyWorkflowUpdate(version, { persisted: persist });
|
||||
yamlStore.close();
|
||||
return true;
|
||||
} catch (error) {
|
||||
const detail =
|
||||
error instanceof AxiosError
|
||||
? (error.response?.data?.detail ?? error.message)
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: "Could not convert YAML into workflow blocks.";
|
||||
yamlStore.setError(detail);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
useWorkflowYamlEditorLifecycle(commitYaml);
|
||||
|
||||
// Reflect YAML-draft dirtiness in the unsaved-changes flag so the existing
|
||||
// tab-close / navigation guards protect the draft — two-way, so reverting the
|
||||
// draft restores the dirty state from when YAML mode opened.
|
||||
useEffect(() => {
|
||||
if (yamlEditorActive) {
|
||||
useWorkflowHasChangesStore
|
||||
.getState()
|
||||
.setHasChanges(yamlEntryHadChangesRef.current || yamlEditorDirty);
|
||||
}
|
||||
}, [yamlEditorActive, yamlEditorDirty]);
|
||||
|
||||
const handleSelectState = (selectedVersion: WorkflowVersion) => {
|
||||
// Close panels
|
||||
setWorkflowPanelState({
|
||||
|
|
@ -1520,6 +1751,8 @@ function Workspace({
|
|||
selectedVersion.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
selectedVersion.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping:
|
||||
selectedVersion.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
const elements = getElements(
|
||||
|
|
@ -1779,7 +2012,12 @@ function Workspace({
|
|||
<>
|
||||
{/* infinite canvas and sub panels when not in debug mode */}
|
||||
{!showBrowser && (
|
||||
<div className="relative flex h-full w-full overflow-hidden overflow-x-hidden">
|
||||
<div
|
||||
className="relative flex h-full w-full overflow-hidden overflow-x-hidden"
|
||||
// The YAML surface covers this subtree visually but it would stay
|
||||
// in the tab order; inert removes it (pane variant has no trap).
|
||||
{...(yamlEditorActive ? { inert: "" } : {})}
|
||||
>
|
||||
{/* infinite canvas */}
|
||||
<FlowRenderer
|
||||
nodes={nodes}
|
||||
|
|
@ -2597,6 +2835,17 @@ function Workspace({
|
|||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{!yamlEditorActive && !isGlobalWorkflow ? (
|
||||
<div className="absolute right-4 top-3 z-30">
|
||||
<YamlModeToggle mode="visual" onCode={enterYamlMode} />
|
||||
</div>
|
||||
) : null}
|
||||
{/* Studio: Code mode swaps the Editor pane's content (sibling panes stay
|
||||
usable); legacy keeps the original full-screen modal overlay. */}
|
||||
{yamlEditorActive ? (
|
||||
<WorkflowYamlEditor variant={embedded ? "pane" : "fullscreen"} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,51 @@
|
|||
import { CodeIcon, EyeOpenIcon } from "@radix-ui/react-icons";
|
||||
|
||||
import { cn } from "@/util/utils";
|
||||
|
||||
const segmentClass = (active: boolean) =>
|
||||
cn(
|
||||
"flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors",
|
||||
active
|
||||
? "bg-slate-elevation3 text-slate-100"
|
||||
: "text-slate-400 hover:text-slate-200",
|
||||
);
|
||||
|
||||
type Props = {
|
||||
mode: "visual" | "code";
|
||||
onVisual?: () => void;
|
||||
onCode?: () => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
// Segmented Visual/Code toggle for the workflow editor. "Code" opens the
|
||||
// full-screen YAML editor; "Visual" commits the YAML back into the canvas.
|
||||
export function YamlModeToggle({ mode, onVisual, onCode, disabled }: Props) {
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 rounded-md border border-slate-700 bg-slate-elevation1 p-0.5">
|
||||
{/* The active segment stays focusable (aria-disabled, not disabled) so
|
||||
keyboard users can tab to it and hear the pressed state. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "visual"}
|
||||
aria-disabled={mode === "visual" || undefined}
|
||||
disabled={disabled}
|
||||
onClick={mode === "visual" ? undefined : onVisual}
|
||||
className={segmentClass(mode === "visual")}
|
||||
>
|
||||
<EyeOpenIcon className="size-3" />
|
||||
Visual
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={mode === "code"}
|
||||
aria-disabled={mode === "code" || undefined}
|
||||
disabled={disabled}
|
||||
onClick={mode === "code" ? undefined : onCode}
|
||||
className={segmentClass(mode === "code")}
|
||||
>
|
||||
<CodeIcon className="size-3" />
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,11 +5,12 @@ import { useCallback } from "react";
|
|||
|
||||
import { toast } from "@/components/ui/use-toast";
|
||||
import { useCacheKeyValueStore } from "@/store/CacheKeyValueStore";
|
||||
import {
|
||||
useWorkflowHasChangesStore,
|
||||
useWorkflowSave,
|
||||
} from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowSave } from "@/store/WorkflowHasChangesStore";
|
||||
import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery";
|
||||
import {
|
||||
commitYamlDraft,
|
||||
useWorkflowYamlEditorStore,
|
||||
} from "@/store/WorkflowYamlEditorStore";
|
||||
import { getWorkflowErrors } from "../workflowEditorUtils";
|
||||
import type { AppNode } from "../nodes";
|
||||
|
||||
|
|
@ -17,13 +18,20 @@ export function useSaveWorkflow(): () => Promise<void> {
|
|||
const { workflowPermanentId } = useParams();
|
||||
const reactFlow = useReactFlow<AppNode>();
|
||||
const saveWorkflow = useWorkflowSave({ status: "published" });
|
||||
const workflowChangesStore = useWorkflowHasChangesStore();
|
||||
const setFilter = useCacheKeyValueStore((s) => s.setFilter);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: workflow } = useWorkflowQuery({ workflowPermanentId });
|
||||
const cacheKey = workflow?.cache_key ?? "";
|
||||
|
||||
return useCallback(async () => {
|
||||
// While the YAML editor is open, saving persists the parsed draft directly
|
||||
// rather than the stale pre-edit canvas — committing applies the graph via
|
||||
// async setNodes, so a graph-based save here would race it.
|
||||
if (useWorkflowYamlEditorStore.getState().active) {
|
||||
await commitYamlDraft(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodes = reactFlow.getNodes();
|
||||
const errors = getWorkflowErrors(nodes);
|
||||
if (errors.length > 0) {
|
||||
|
|
@ -41,9 +49,7 @@ export function useSaveWorkflow(): () => Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
await saveWorkflow.mutateAsync();
|
||||
|
||||
workflowChangesStore.setSaidOkToCodeCacheDeletion(false);
|
||||
await saveWorkflow.mutateAsync(undefined);
|
||||
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["cache-key-values", workflowPermanentId, cacheKey],
|
||||
|
|
@ -53,7 +59,6 @@ export function useSaveWorkflow(): () => Promise<void> {
|
|||
}, [
|
||||
reactFlow,
|
||||
saveWorkflow,
|
||||
workflowChangesStore,
|
||||
queryClient,
|
||||
workflowPermanentId,
|
||||
cacheKey,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
import { renderHook } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { useWorkflowYamlEditorStore } from "@/store/WorkflowYamlEditorStore";
|
||||
|
||||
import { useWorkflowYamlEditorLifecycle } from "./useWorkflowYamlEditorLifecycle";
|
||||
|
||||
describe("useWorkflowYamlEditorLifecycle", () => {
|
||||
beforeEach(() => {
|
||||
useWorkflowYamlEditorStore.getState().close();
|
||||
useWorkflowYamlEditorStore.getState().registerCommit(null);
|
||||
});
|
||||
|
||||
test("registers a wrapper that always calls the latest commit closure", async () => {
|
||||
const first = vi.fn().mockResolvedValue(true);
|
||||
const second = vi.fn().mockResolvedValue(false);
|
||||
const { rerender } = renderHook(
|
||||
({ commit }: { commit: (persist?: boolean) => Promise<boolean> }) =>
|
||||
useWorkflowYamlEditorLifecycle(commit),
|
||||
{ initialProps: { commit: first } },
|
||||
);
|
||||
|
||||
await useWorkflowYamlEditorStore.getState().commit?.(true);
|
||||
expect(first).toHaveBeenCalledWith(true);
|
||||
|
||||
rerender({ commit: second });
|
||||
await useWorkflowYamlEditorStore.getState().commit?.(false);
|
||||
expect(second).toHaveBeenCalledWith(false);
|
||||
expect(first).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Pins the unmount cleanup: the store is global, so a dirty draft left
|
||||
// active after unmount would leak into the next workflow's editor and could
|
||||
// be committed there.
|
||||
test("unmount closes the editor so a dirty draft cannot leak across workflows", () => {
|
||||
const { unmount } = renderHook(() =>
|
||||
useWorkflowYamlEditorLifecycle(async () => true),
|
||||
);
|
||||
|
||||
useWorkflowYamlEditorStore.getState().open("blocks: []");
|
||||
useWorkflowYamlEditorStore.getState().setDraft("blocks: [changed]");
|
||||
expect(useWorkflowYamlEditorStore.getState().active).toBe(true);
|
||||
|
||||
unmount();
|
||||
|
||||
const state = useWorkflowYamlEditorStore.getState();
|
||||
expect(state.active).toBe(false);
|
||||
expect(state.draft).toBe("");
|
||||
expect(state.entrySnapshot).toBe("");
|
||||
expect(state.commit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { useWorkflowYamlEditorStore } from "@/store/WorkflowYamlEditorStore";
|
||||
|
||||
type CommitYaml = (persist?: boolean) => Promise<boolean>;
|
||||
|
||||
// Registers a stable wrapper so the YAML-aware save paths and the overlay's
|
||||
// Visual toggle always call the owning editor's latest commit closure. The
|
||||
// store is global, so unmount must close() it — otherwise a dirty draft leaks
|
||||
// into the next workflow's editor (Workspace remounts per workflow id).
|
||||
export function useWorkflowYamlEditorLifecycle(commitYaml: CommitYaml): void {
|
||||
const commitYamlRef = useRef(commitYaml);
|
||||
commitYamlRef.current = commitYaml;
|
||||
const registerCommit = useWorkflowYamlEditorStore((s) => s.registerCommit);
|
||||
useEffect(() => {
|
||||
const commit = (persist?: boolean) => commitYamlRef.current(persist);
|
||||
registerCommit(commit);
|
||||
return () => {
|
||||
registerCommit(null);
|
||||
useWorkflowYamlEditorStore.getState().close();
|
||||
};
|
||||
}, [registerCommit]);
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
};
|
||||
|
||||
function op(label: string): OutputParameter {
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ const startNodeData: WorkflowStartNodeData = {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
label: "__start_block__",
|
||||
showCode: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ const startNodeData: WorkflowStartNodeData = {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
label: "__start_block__",
|
||||
showCode: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export type WorkflowStartNodeData = {
|
|||
sequentialKey: string | null;
|
||||
finallyBlockLabel: string | null;
|
||||
workflowSystemPrompt: string | null;
|
||||
errorCodeMapping: Record<string, string> | null;
|
||||
label: "__start_block__";
|
||||
showCode: boolean;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -431,7 +431,7 @@ function NodeHeader({
|
|||
throw new ValidationFailureError();
|
||||
}
|
||||
|
||||
await saveWorkflow.mutateAsync();
|
||||
await saveWorkflow.mutateAsync(undefined);
|
||||
|
||||
if (!workflowPermanentId) {
|
||||
log.error("Run block: there is no workflowPermanentId");
|
||||
|
|
|
|||
|
|
@ -159,6 +159,7 @@ function getWorkflowElements(version: WorkflowVersion) {
|
|||
finallyBlockLabel: version.workflow_definition?.finally_block_label ?? null,
|
||||
workflowSystemPrompt:
|
||||
version.workflow_definition?.workflow_system_prompt ?? null,
|
||||
errorCodeMapping: version.workflow_definition?.error_code_mapping ?? null,
|
||||
};
|
||||
|
||||
// Deep clone the blocks to ensure complete isolation from main editor
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
};
|
||||
|
||||
function makeOutputParameter(label: string): OutputParameter {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { describe, expect, test } from "vitest";
|
|||
import { ProxyLocation } from "@/api/types";
|
||||
|
||||
import type { AppNode } from "../nodes";
|
||||
import { getElements, getWorkflowBlocks } from "../workflowEditorUtils";
|
||||
import {
|
||||
getElements,
|
||||
getWorkflowBlocks,
|
||||
getWorkflowSettings,
|
||||
} from "../workflowEditorUtils";
|
||||
import {
|
||||
type CodeBlock,
|
||||
type OutputParameter,
|
||||
|
|
@ -54,6 +58,7 @@ const DEFAULT_SETTINGS: WorkflowSettings = {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
};
|
||||
|
||||
function makeOutputParameter(label: string): OutputParameter {
|
||||
|
|
@ -448,4 +453,38 @@ describe("round-trip reorder → save → reload (M1 top-level)", () => {
|
|||
const { validationError } = getElements(malformed, DEFAULT_SETTINGS, false);
|
||||
expect(validationError).toBeNull();
|
||||
});
|
||||
|
||||
// error_code_mapping is not editable in YAML, but it must still ride on the
|
||||
// start node so it's preserved (not cleared) across a load -> save round-trip.
|
||||
test("workflow-level error_code_mapping rides on the start node so it survives a save", () => {
|
||||
const settings: WorkflowSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
errorCodeMapping: { OUT_OF_STOCK: "item unavailable" },
|
||||
};
|
||||
const { nodes } = getElements(buildFiveBlockFixture(), settings, true);
|
||||
const startNode = nodes.find((node) => node.type === "start");
|
||||
expect(
|
||||
(startNode?.data as { errorCodeMapping?: unknown } | undefined)
|
||||
?.errorCodeMapping,
|
||||
).toEqual({ OUT_OF_STOCK: "item unavailable" });
|
||||
});
|
||||
|
||||
// The full recovery leg the save path relies on: workflow-level settings ride
|
||||
// load -> start node -> getWorkflowSettings with zero field loss, so a YAML
|
||||
// commit (which reattaches settings from this readback) cannot drop them.
|
||||
test("workflow-level settings survive the getElements -> getWorkflowSettings round-trip", () => {
|
||||
const settings: WorkflowSettings = {
|
||||
...DEFAULT_SETTINGS,
|
||||
errorCodeMapping: { OUT_OF_STOCK: "item unavailable" },
|
||||
finallyBlockLabel: "B5",
|
||||
workflowSystemPrompt: "always double-check totals",
|
||||
};
|
||||
const { nodes } = getElements(buildFiveBlockFixture(), settings, true);
|
||||
const recovered = getWorkflowSettings(nodes);
|
||||
expect(recovered.errorCodeMapping).toEqual({
|
||||
OUT_OF_STOCK: "item unavailable",
|
||||
});
|
||||
expect(recovered.finallyBlockLabel).toBe("B5");
|
||||
expect(recovered.workflowSystemPrompt).toBe("always double-check totals");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2004,6 +2004,7 @@ function getElements(
|
|||
sequentialKey: settings.sequentialKey,
|
||||
finallyBlockLabel: settings.finallyBlockLabel ?? null,
|
||||
workflowSystemPrompt: settings.workflowSystemPrompt ?? null,
|
||||
errorCodeMapping: settings.errorCodeMapping ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
|
|
@ -3418,6 +3419,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
};
|
||||
const startNodes = nodes.filter(isStartNode);
|
||||
const startNodeWithWorkflowSettings = startNodes.find(
|
||||
|
|
@ -3453,6 +3455,7 @@ function getWorkflowSettings(nodes: Array<AppNode>): WorkflowSettings {
|
|||
sequentialKey: data.sequentialKey,
|
||||
finallyBlockLabel: data.finallyBlockLabel ?? null,
|
||||
workflowSystemPrompt: data.workflowSystemPrompt ?? null,
|
||||
errorCodeMapping: data.errorCodeMapping ?? null,
|
||||
};
|
||||
}
|
||||
return defaultSettings;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ProxyLocation } from "@/api/types";
|
||||
import type { WorkflowSaveData } from "@/store/WorkflowHasChangesStore";
|
||||
|
||||
import type {
|
||||
WorkflowDefinition,
|
||||
WorkflowSettings,
|
||||
} from "../types/workflowTypes";
|
||||
import {
|
||||
preservedFinallyBlockLabel,
|
||||
workflowVersionFromSaveData,
|
||||
} from "./workflowVersionFromSaveData";
|
||||
|
||||
const definition: WorkflowDefinition = {
|
||||
parameters: [],
|
||||
blocks: [],
|
||||
};
|
||||
|
||||
function makeSaveData(
|
||||
settingsOverrides: Partial<WorkflowSettings> = {},
|
||||
): WorkflowSaveData {
|
||||
const settings = {
|
||||
proxyLocation: ProxyLocation.Residential,
|
||||
webhookCallbackUrl: "https://example.test/hook",
|
||||
persistBrowserSession: true,
|
||||
browserProfileId: "bp_1",
|
||||
browserProfileKey: "key_1",
|
||||
model: null,
|
||||
maxScreenshotScrolls: 5,
|
||||
maxElapsedTimeMinutes: 30,
|
||||
extraHttpHeaders: null,
|
||||
cdpConnectHeaders: null,
|
||||
runWith: "agent",
|
||||
codeVersion: null,
|
||||
scriptCacheKey: "cache_1",
|
||||
aiFallback: true,
|
||||
runSequentially: false,
|
||||
sequentialKey: null,
|
||||
finallyBlockLabel: null,
|
||||
workflowSystemPrompt: null,
|
||||
errorCodeMapping: null,
|
||||
...settingsOverrides,
|
||||
} as WorkflowSettings;
|
||||
return {
|
||||
parameters: [],
|
||||
blocks: [],
|
||||
workflowDefinitionVersion: 2,
|
||||
title: "My Workflow",
|
||||
settings,
|
||||
workflow: {
|
||||
workflow_id: "w_1",
|
||||
organization_id: "org_1",
|
||||
workflow_permanent_id: "wpid_1",
|
||||
version: 3,
|
||||
description: "desc",
|
||||
is_saved_task: false,
|
||||
is_template: false,
|
||||
totp_verification_url: null,
|
||||
totp_identifier: null,
|
||||
status: "published",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
modified_at: "2026-01-02T00:00:00Z",
|
||||
deleted_at: null,
|
||||
adaptive_caching: true,
|
||||
folder_id: "f_1",
|
||||
import_error: "prior import failed",
|
||||
},
|
||||
} as unknown as WorkflowSaveData;
|
||||
}
|
||||
|
||||
describe("workflowVersionFromSaveData", () => {
|
||||
test("carries identity and title, and uses the passed definition", () => {
|
||||
const version = workflowVersionFromSaveData(makeSaveData(), definition, {
|
||||
extraHttpHeaders: null,
|
||||
cdpConnectHeaders: null,
|
||||
});
|
||||
expect(version.workflow_id).toBe("w_1");
|
||||
expect(version.workflow_permanent_id).toBe("wpid_1");
|
||||
expect(version.title).toBe("My Workflow");
|
||||
expect(version.version).toBe(3);
|
||||
expect(version.workflow_definition).toBe(definition);
|
||||
// import_error is carried over, not reset (would otherwise clear after a
|
||||
// YAML commit on a workflow with a prior import error).
|
||||
expect(version.import_error).toBe("prior import failed");
|
||||
});
|
||||
|
||||
test("maps editor settings onto the version", () => {
|
||||
const version = workflowVersionFromSaveData(makeSaveData(), definition, {
|
||||
extraHttpHeaders: { a: "b" },
|
||||
cdpConnectHeaders: null,
|
||||
});
|
||||
expect(version.proxy_location).toBe(ProxyLocation.Residential);
|
||||
expect(version.persist_browser_session).toBe(true);
|
||||
expect(version.browser_profile_id).toBe("bp_1");
|
||||
expect(version.cache_key).toBe("cache_1");
|
||||
expect(version.extra_http_headers).toEqual({ a: "b" });
|
||||
expect(version.adaptive_caching).toBe(true);
|
||||
});
|
||||
|
||||
test("code_version is null for agent runs and defaults to 2 for code runs", () => {
|
||||
const agent = workflowVersionFromSaveData(makeSaveData(), definition, {
|
||||
extraHttpHeaders: null,
|
||||
cdpConnectHeaders: null,
|
||||
});
|
||||
expect(agent.run_with).toBe("agent");
|
||||
expect(agent.code_version).toBeNull();
|
||||
|
||||
const code = workflowVersionFromSaveData(
|
||||
makeSaveData({ runWith: "code", codeVersion: null }),
|
||||
definition,
|
||||
{ extraHttpHeaders: null, cdpConnectHeaders: null },
|
||||
);
|
||||
expect(code.run_with).toBe("code");
|
||||
expect(code.code_version).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("preservedFinallyBlockLabel", () => {
|
||||
test("keeps the label when its block still exists", () => {
|
||||
expect(
|
||||
preservedFinallyBlockLabel("cleanup", ["step_1", "cleanup", "step_2"]),
|
||||
).toBe("cleanup");
|
||||
});
|
||||
|
||||
test("drops the label when the block was removed or renamed", () => {
|
||||
expect(preservedFinallyBlockLabel("cleanup", ["step_1", "step_2"])).toBe(
|
||||
null,
|
||||
);
|
||||
expect(preservedFinallyBlockLabel("cleanup", [])).toBe(null);
|
||||
});
|
||||
|
||||
test("returns null for an unset finally_block_label", () => {
|
||||
expect(preservedFinallyBlockLabel(null, ["step_1"])).toBe(null);
|
||||
expect(preservedFinallyBlockLabel(undefined, ["step_1"])).toBe(null);
|
||||
expect(preservedFinallyBlockLabel("", ["step_1"])).toBe(null);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { WorkflowSaveData } from "@/store/WorkflowHasChangesStore";
|
||||
|
||||
import type { WorkflowVersion } from "../hooks/useWorkflowVersionsQuery";
|
||||
import type { WorkflowDefinition } from "../types/workflowTypes";
|
||||
|
||||
// finally_block_label references a top-level block. After a YAML edit that
|
||||
// removed or renamed that block, reattaching the old label would leave a
|
||||
// dangling reference and the save would fail — so keep it only when the block
|
||||
// still exists among the committed top-level blocks.
|
||||
export function preservedFinallyBlockLabel(
|
||||
finallyBlockLabel: string | null | undefined,
|
||||
topLevelBlockLabels: Iterable<string>,
|
||||
): string | null {
|
||||
if (!finallyBlockLabel) {
|
||||
return null;
|
||||
}
|
||||
return new Set(topLevelBlockLabels).has(finallyBlockLabel)
|
||||
? finallyBlockLabel
|
||||
: null;
|
||||
}
|
||||
|
||||
// Builds a WorkflowVersion from the live editor state, swapping in a freshly
|
||||
// parsed workflow_definition — so YAML mode only ever changes the definition.
|
||||
// NOTE: every WorkflowVersion field is mapped by hand below. Adding a field to
|
||||
// the type without adding it here silently drops it on a YAML commit (a
|
||||
// ghost-reset on next reload), with no type error to catch the omission.
|
||||
export function workflowVersionFromSaveData(
|
||||
saveData: WorkflowSaveData,
|
||||
workflowDefinition: WorkflowDefinition,
|
||||
headers: {
|
||||
extraHttpHeaders: Record<string, string> | null;
|
||||
cdpConnectHeaders: Record<string, string> | null;
|
||||
},
|
||||
): WorkflowVersion {
|
||||
const { settings, workflow } = saveData;
|
||||
return {
|
||||
workflow_id: workflow.workflow_id,
|
||||
organization_id: workflow.organization_id,
|
||||
is_saved_task: workflow.is_saved_task ?? false,
|
||||
is_template: workflow.is_template ?? false,
|
||||
title: saveData.title,
|
||||
workflow_permanent_id: workflow.workflow_permanent_id,
|
||||
version: workflow.version ?? 0,
|
||||
description: workflow.description ?? "",
|
||||
workflow_definition: workflowDefinition,
|
||||
proxy_location: settings.proxyLocation,
|
||||
webhook_callback_url: settings.webhookCallbackUrl,
|
||||
extra_http_headers: headers.extraHttpHeaders,
|
||||
cdp_connect_headers: headers.cdpConnectHeaders,
|
||||
persist_browser_session: settings.persistBrowserSession,
|
||||
browser_profile_id: settings.browserProfileId,
|
||||
browser_profile_key: settings.browserProfileKey,
|
||||
model: settings.model,
|
||||
totp_verification_url: workflow.totp_verification_url,
|
||||
totp_identifier: workflow.totp_identifier ?? null,
|
||||
max_screenshot_scrolls: settings.maxScreenshotScrolls,
|
||||
max_elapsed_time_minutes: settings.maxElapsedTimeMinutes ?? null,
|
||||
status: workflow.status,
|
||||
created_at: workflow.created_at,
|
||||
modified_at: workflow.modified_at,
|
||||
deleted_at: workflow.deleted_at ?? null,
|
||||
run_with: settings.runWith,
|
||||
cache_key: settings.scriptCacheKey,
|
||||
ai_fallback: settings.aiFallback,
|
||||
adaptive_caching: workflow.adaptive_caching ?? false,
|
||||
code_version:
|
||||
settings.runWith === "code" ? (settings.codeVersion ?? 2) : null,
|
||||
run_sequentially: settings.runSequentially,
|
||||
sequential_key: settings.sequentialKey,
|
||||
folder_id: workflow.folder_id ?? null,
|
||||
import_error: workflow.import_error ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { useParams } from "react-router-dom";
|
||||
|
||||
import { useGlobalWorkflowsQuery } from "./useGlobalWorkflowsQuery";
|
||||
|
||||
// Global (read-only) workflows can't be edited in place — the header offers
|
||||
// "Make a Copy" instead. Editing surfaces gate on this.
|
||||
export function useIsGlobalWorkflow(): boolean {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
|
||||
return Boolean(
|
||||
globalWorkflows?.some(
|
||||
(w) => w.workflow_permanent_id === workflowPermanentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -34,21 +34,11 @@ import { EditableNodeTitle } from "../editor/nodes/components/EditableNodeTitle"
|
|||
import { EditorOverflowMenu } from "../editor/header/EditorOverflowMenu";
|
||||
import { MakeACopyButton } from "../editor/MakeACopyButton";
|
||||
import { useSaveWorkflow } from "../editor/hooks/useSaveWorkflow";
|
||||
import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery";
|
||||
import { useIsGlobalWorkflow } from "../hooks/useIsGlobalWorkflow";
|
||||
import { useWorkflowRunWithWorkflowQuery } from "../hooks/useWorkflowRunWithWorkflowQuery";
|
||||
import { runOutcomeFromStatus } from "./runProjections";
|
||||
import { useStudioRunId } from "./useStudioRunId";
|
||||
|
||||
function useIsGlobalWorkflow(): boolean {
|
||||
const { workflowPermanentId } = useParams();
|
||||
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
|
||||
return Boolean(
|
||||
globalWorkflows?.some(
|
||||
(w) => w.workflow_permanent_id === workflowPermanentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function TitleSection({ editable = true }: { editable?: boolean }) {
|
||||
const { title, setTitle } = useWorkflowTitleStore();
|
||||
const setHasChanges = useWorkflowHasChangesStore((s) => s.setHasChanges);
|
||||
|
|
|
|||
|
|
@ -643,6 +643,7 @@ export type WorkflowDefinition = {
|
|||
blocks: Array<WorkflowBlock>;
|
||||
finally_block_label?: string | null;
|
||||
workflow_system_prompt?: string | null;
|
||||
error_code_mapping?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type WorkflowApiResponse = {
|
||||
|
|
@ -701,6 +702,7 @@ export type WorkflowSettings = {
|
|||
sequentialKey: string | null;
|
||||
finallyBlockLabel: string | null;
|
||||
workflowSystemPrompt: string | null;
|
||||
errorCodeMapping: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type WorkflowModel = JsonObjectExtendable<{ model_name: string }>;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ export type WorkflowDefinitionYAML = {
|
|||
blocks: Array<BlockYAML>;
|
||||
finally_block_label?: string | null;
|
||||
workflow_system_prompt?: string | null;
|
||||
error_code_mapping?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
export type ParameterYAML =
|
||||
|
|
|
|||
|
|
@ -92,17 +92,22 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||
saidOkToCodeCacheDeletion,
|
||||
setHasChanges,
|
||||
setSaveIsPending,
|
||||
setSaidOkToCodeCacheDeletion,
|
||||
setShowConfirmCodeCacheDeletion,
|
||||
} = useWorkflowHasChangesStore();
|
||||
|
||||
const saveWorkflowMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const saveData = getSaveData();
|
||||
mutationFn: async (override?: Partial<SaveData>) => {
|
||||
const base = getSaveData();
|
||||
|
||||
if (!saveData) {
|
||||
if (!base) {
|
||||
setHasChanges(false);
|
||||
return;
|
||||
}
|
||||
// YAML-mode saves pass the parsed draft (blocks/parameters/version, plus
|
||||
// a corrected finally_block_label) so we persist the edit directly
|
||||
// instead of the graph, which lags a commit's async setNodes.
|
||||
const saveData: SaveData = override ? { ...base, ...override } : base;
|
||||
|
||||
const client = await getClient(credentialGetter);
|
||||
const extraHttpHeaders: Record<string, string> = {};
|
||||
|
|
@ -210,6 +215,7 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||
finally_block_label: saveData.settings.finallyBlockLabel ?? undefined,
|
||||
workflow_system_prompt:
|
||||
saveData.settings.workflowSystemPrompt ?? undefined,
|
||||
error_code_mapping: saveData.settings.errorCodeMapping ?? undefined,
|
||||
},
|
||||
is_saved_task: saveData.workflow.is_saved_task,
|
||||
status: opts?.status ?? saveData.workflow.status,
|
||||
|
|
@ -235,6 +241,11 @@ const useWorkflowSave = (opts?: WorkflowSaveOpts) => {
|
|||
);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// The confirmation authorizes exactly one save. Resetting here covers
|
||||
// every persist path (top bar, nav blocker, YAML mode), so a later save
|
||||
// can't silently delete cached code without re-prompting.
|
||||
setSaidOkToCodeCacheDeletion(false);
|
||||
|
||||
const saveData = getSaveData();
|
||||
|
||||
if (!saveData) {
|
||||
|
|
|
|||
57
skyvern-frontend/src/store/WorkflowYamlEditorStore.test.ts
Normal file
57
skyvern-frontend/src/store/WorkflowYamlEditorStore.test.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
isWorkflowYamlDirty,
|
||||
useWorkflowYamlEditorStore,
|
||||
} from "./WorkflowYamlEditorStore";
|
||||
|
||||
describe("WorkflowYamlEditorStore", () => {
|
||||
beforeEach(() => {
|
||||
// close() intentionally preserves the registered commit, so reset it
|
||||
// separately to keep tests isolated.
|
||||
useWorkflowYamlEditorStore.getState().close();
|
||||
useWorkflowYamlEditorStore.getState().registerCommit(null);
|
||||
});
|
||||
|
||||
test("open activates the editor and snapshots the draft", () => {
|
||||
useWorkflowYamlEditorStore.getState().open("title: hello");
|
||||
const state = useWorkflowYamlEditorStore.getState();
|
||||
expect(state.active).toBe(true);
|
||||
expect(state.draft).toBe("title: hello");
|
||||
expect(state.entrySnapshot).toBe("title: hello");
|
||||
expect(state.error).toBeNull();
|
||||
expect(isWorkflowYamlDirty(state)).toBe(false);
|
||||
});
|
||||
|
||||
test("editing the draft after open marks it dirty", () => {
|
||||
useWorkflowYamlEditorStore.getState().open("blocks: []");
|
||||
useWorkflowYamlEditorStore.getState().setDraft("blocks: [a]");
|
||||
expect(isWorkflowYamlDirty(useWorkflowYamlEditorStore.getState())).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("setDraft clears a previously surfaced error", () => {
|
||||
useWorkflowYamlEditorStore.getState().open("a: 1");
|
||||
useWorkflowYamlEditorStore.getState().setError("could not parse");
|
||||
expect(useWorkflowYamlEditorStore.getState().error).toBe("could not parse");
|
||||
useWorkflowYamlEditorStore.getState().setDraft("a: 2");
|
||||
expect(useWorkflowYamlEditorStore.getState().error).toBeNull();
|
||||
});
|
||||
|
||||
test("close resets session state but preserves the registered commit", () => {
|
||||
const commit = async () => true;
|
||||
useWorkflowYamlEditorStore.getState().registerCommit(commit);
|
||||
useWorkflowYamlEditorStore.getState().open("a: 1");
|
||||
useWorkflowYamlEditorStore.getState().setDraft("a: 2");
|
||||
useWorkflowYamlEditorStore.getState().close();
|
||||
const state = useWorkflowYamlEditorStore.getState();
|
||||
expect(state.active).toBe(false);
|
||||
expect(state.draft).toBe("");
|
||||
expect(state.entrySnapshot).toBe("");
|
||||
expect(state.committing).toBe(false);
|
||||
// Workspace registers commit once on mount; close must not drop it or a
|
||||
// second open would have no way to reparse.
|
||||
expect(state.commit).toBe(commit);
|
||||
});
|
||||
});
|
||||
79
skyvern-frontend/src/store/WorkflowYamlEditorStore.ts
Normal file
79
skyvern-frontend/src/store/WorkflowYamlEditorStore.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import { create } from "zustand";
|
||||
|
||||
type WorkflowYamlEditorState = {
|
||||
active: boolean;
|
||||
draft: string;
|
||||
// YAML captured when the editor opened. Used to short-circuit the commit when
|
||||
// nothing was edited (the graph already matches, so no reparse is needed).
|
||||
entrySnapshot: string;
|
||||
error: string | null;
|
||||
committing: boolean;
|
||||
// Reparses the draft into the graph and closes on success; returns false on
|
||||
// invalid YAML so callers abort. With persist=true it also saves the draft
|
||||
// (for the top-bar/nav "Save" paths). Survives close() — registered on mount.
|
||||
commit: ((persist?: boolean) => Promise<boolean>) | null;
|
||||
open: (yaml: string) => void;
|
||||
setDraft: (yaml: string) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setCommitting: (committing: boolean) => void;
|
||||
registerCommit: (
|
||||
commit: ((persist?: boolean) => Promise<boolean>) | null,
|
||||
) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
export const useWorkflowYamlEditorStore = create<WorkflowYamlEditorState>(
|
||||
(set) => ({
|
||||
active: false,
|
||||
draft: "",
|
||||
entrySnapshot: "",
|
||||
error: null,
|
||||
committing: false,
|
||||
commit: null,
|
||||
open: (yaml) =>
|
||||
set({
|
||||
active: true,
|
||||
draft: yaml,
|
||||
entrySnapshot: yaml,
|
||||
error: null,
|
||||
committing: false,
|
||||
}),
|
||||
setDraft: (yaml) => set({ draft: yaml, error: null }),
|
||||
setError: (error) => set({ error }),
|
||||
setCommitting: (committing) => set({ committing }),
|
||||
registerCommit: (commit) => set({ commit }),
|
||||
close: () =>
|
||||
set({
|
||||
active: false,
|
||||
draft: "",
|
||||
entrySnapshot: "",
|
||||
error: null,
|
||||
committing: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export function isWorkflowYamlDirty(state: {
|
||||
draft: string;
|
||||
entrySnapshot: string;
|
||||
}): boolean {
|
||||
return state.draft !== state.entrySnapshot;
|
||||
}
|
||||
|
||||
// Shared entry point for the commit-on-switch flow used by the overlay's Visual
|
||||
// toggle, the top-bar save, and the nav-blocker "Save changes" dialog. Guards
|
||||
// against a re-entrant commit and toggles the committing flag around it.
|
||||
// Returns false when a commit is already running, none is registered, or the
|
||||
// draft is invalid.
|
||||
export async function commitYamlDraft(persist: boolean): Promise<boolean> {
|
||||
const store = useWorkflowYamlEditorStore.getState();
|
||||
if (store.committing || !store.commit) {
|
||||
return false;
|
||||
}
|
||||
store.setCommitting(true);
|
||||
try {
|
||||
return await store.commit(persist);
|
||||
} finally {
|
||||
store.setCommitting(false);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue