feat(dashboard): add optional-steps authoring panel to the node editor
New WorkflowOptionalStepsPanel (sibling to Fields/Settings) lets authors add/remove optional steps and set each one's defaultOn, with unknown ids shown as muted removable rows. Wire optionalSteps state through both load paths (incl. the fragment path, which also dropped settings), every flowToIr/serializeGraph call site, and the save handler deps — fixing a stale-closure that dropped defaultOn edits on save. Extract the shared phaseBadge helper. Mobile gets an Optional steps tab too.
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, Bell, LayoutGrid, Workflow, Download, Upload, ChevronDown, ChevronRight, ChevronLeft, Library, Sparkles, Maximize2, Minimize2 } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate } from "@fusion/core";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation, WorkflowStepTemplate, WorkflowOptionalStep } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
fetchWorkflows,
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
columnsOf,
|
||||
fieldsOf,
|
||||
settingsOf,
|
||||
optionalStepsOf,
|
||||
columnsToBandNodes,
|
||||
reconcileNodeColumns,
|
||||
strictColumnForY,
|
||||
@@ -87,6 +88,7 @@ import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
|
||||
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
|
||||
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
|
||||
import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import { WorkflowOptionalStepsPanel } from "./WorkflowOptionalStepsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
@@ -98,7 +100,7 @@ import {
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "optional-steps" | "columns" | "actions";
|
||||
|
||||
function builtinSeamPrompt(config: Record<string, unknown> | undefined): string {
|
||||
const seam = typeof config?.seam === "string" ? config.seam : "";
|
||||
@@ -165,6 +167,7 @@ function serializeGraph(
|
||||
columns: WorkflowIrColumn[],
|
||||
fields: WorkflowFieldDefinition[],
|
||||
settings: WorkflowSettingDefinition[],
|
||||
optionalSteps: WorkflowOptionalStep[],
|
||||
): string {
|
||||
const { ir, layout } = flowToIr(
|
||||
name,
|
||||
@@ -173,6 +176,7 @@ function serializeGraph(
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
return JSON.stringify({ name, description, ir, layout });
|
||||
}
|
||||
@@ -740,6 +744,7 @@ function InnerEditor({
|
||||
// VALUES live per-project in the workflow_settings table (KTD-2) and are
|
||||
// managed by the panel's Values tab, not this declaration array.
|
||||
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([]);
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>([]);
|
||||
// Ref to the settings panel so a `?panel=settings` deep link can scroll it
|
||||
// into view on mount (U6/U9 redirect stubs).
|
||||
const settingsPanelRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -778,6 +783,7 @@ function InnerEditor({
|
||||
const columnsCollapsedStorageKey = "fusion:wf-sidebar-columns-collapsed";
|
||||
const fieldsCollapsedStorageKey = "fusion:wf-sidebar-fields-collapsed";
|
||||
const settingsCollapsedStorageKey = "fusion:wf-sidebar-settings-collapsed";
|
||||
const optionalStepsCollapsedStorageKey = "fusion:wf-sidebar-optional-steps-collapsed";
|
||||
const [columnsCollapsed, setColumnsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(columnsCollapsedStorageKey) === "1";
|
||||
@@ -799,6 +805,13 @@ function InnerEditor({
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const [optionalStepsCollapsed, setOptionalStepsCollapsed] = useState<boolean>(() => {
|
||||
try {
|
||||
return localStorage.getItem(optionalStepsCollapsedStorageKey) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(columnsCollapsedStorageKey, columnsCollapsed ? "1" : "0");
|
||||
@@ -820,6 +833,13 @@ function InnerEditor({
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [settingsCollapsed]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(optionalStepsCollapsedStorageKey, optionalStepsCollapsed ? "1" : "0");
|
||||
} catch {
|
||||
// localStorage unavailable (private mode / SSR): non-fatal.
|
||||
}
|
||||
}, [optionalStepsCollapsed]);
|
||||
// React Flow instance for programmatic viewport control (auto-layout on load).
|
||||
const { setViewport } = useReactFlow();
|
||||
// Wrapper around <ReactFlow> so keyboard deletion can return focus to the
|
||||
@@ -1009,10 +1029,10 @@ function InnerEditor({
|
||||
if (isBuiltin) return false;
|
||||
if (!activeWorkflow || loadedSnapshotRef.current === null) return false;
|
||||
return (
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings) !==
|
||||
serializeGraph(name, description, nodes, edges, columns, fields, settings, optionalSteps) !==
|
||||
loadedSnapshotRef.current
|
||||
);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings]);
|
||||
}, [isBuiltin, activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -1160,6 +1180,7 @@ function InnerEditor({
|
||||
setColumns([]);
|
||||
setFields([]);
|
||||
setSettings([]);
|
||||
setOptionalSteps([]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
loadedSnapshotRef.current = null;
|
||||
@@ -1169,6 +1190,7 @@ function InnerEditor({
|
||||
const loadedColumns = columnsOf(activeWorkflow);
|
||||
const loadedFields = fieldsOf(activeWorkflow);
|
||||
const loadedSettings = settingsOf(activeWorkflow);
|
||||
const loadedOptionalSteps = optionalStepsOf(activeWorkflow);
|
||||
// Auto-layout on load: compute tidy positions and apply them before the
|
||||
// first render so nodes are visible in the top-left viewport.
|
||||
const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns);
|
||||
@@ -1178,6 +1200,7 @@ function InnerEditor({
|
||||
setColumns(loadedColumns);
|
||||
setFields(loadedFields);
|
||||
setSettings(loadedSettings);
|
||||
setOptionalSteps(loadedOptionalSteps);
|
||||
setName(activeWorkflow.name);
|
||||
setDescription(activeWorkflow.description ?? "");
|
||||
setEditingName(false);
|
||||
@@ -1192,6 +1215,7 @@ function InnerEditor({
|
||||
loadedColumns,
|
||||
loadedFields,
|
||||
loadedSettings,
|
||||
loadedOptionalSteps,
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
@@ -1495,6 +1519,11 @@ function InnerEditor({
|
||||
setEdges(flow.edges);
|
||||
setColumns(columnsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setFields(fieldsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
// Hydrate settings + optionalSteps on the fragment/generate path too — it
|
||||
// previously dropped both, which silently lost the declarations on the next
|
||||
// save (the round-trip data loss U2 fixes for the primary load path).
|
||||
setSettings(settingsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setOptionalSteps(optionalStepsOf({ ...targetWorkflow, ir: result.ir }));
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(null);
|
||||
setValidationError(null);
|
||||
@@ -1806,6 +1835,7 @@ function InnerEditor({
|
||||
columns.length ? columns : undefined,
|
||||
fields.length ? fields : undefined,
|
||||
settings.length ? settings : undefined,
|
||||
optionalSteps.length ? optionalSteps : undefined,
|
||||
);
|
||||
// Include name/description in the PATCH only when they changed from the
|
||||
// loaded workflow (KTD-10 inline rename/description persist here).
|
||||
@@ -1823,6 +1853,7 @@ function InnerEditor({
|
||||
columns,
|
||||
fields,
|
||||
settings,
|
||||
optionalSteps,
|
||||
);
|
||||
setName(updated.name);
|
||||
setDescription(updated.description ?? "");
|
||||
@@ -1892,7 +1923,7 @@ function InnerEditor({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
}, [activeWorkflow, name, description, nodes, edges, columns, fields, settings, optionalSteps, unplaced, blockingViolationCount, projectId, addToast, t]);
|
||||
|
||||
// Stamp the shared error-state badge onto offending nodes: unplaced step
|
||||
// nodes and any node the server flagged (seam-in-branch). One component
|
||||
@@ -2454,6 +2485,26 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="wf-sidebar-section" data-testid="wf-sidebar-optional-steps-section">
|
||||
<button
|
||||
type="button"
|
||||
className="wf-sidebar-section-toggle"
|
||||
aria-expanded={!optionalStepsCollapsed}
|
||||
data-testid="wf-sidebar-optional-steps-toggle"
|
||||
onClick={() => setOptionalStepsCollapsed((c) => !c)}
|
||||
>
|
||||
{optionalStepsCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
|
||||
<span>{t("workflowOptionalSteps.title", "Optional steps")}</span>
|
||||
</button>
|
||||
{!optionalStepsCollapsed && (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
@@ -2576,6 +2627,7 @@ function InnerEditor({
|
||||
["add", t("workflowNodes.mobileAdd", "Add")],
|
||||
["settings", t("workflowSettings.title", "Settings")],
|
||||
["fields", t("workflowFields.title", "Fields")],
|
||||
["optional-steps", t("workflowOptionalSteps.title", "Optional steps")],
|
||||
["columns", t("workflowColumns.title", "Columns")],
|
||||
["actions", t("workflowNodes.mobileActions", "Actions")],
|
||||
] as Array<[MobileWorkflowPanel, string]>).map(([panel, label]) => (
|
||||
@@ -2754,6 +2806,16 @@ function InnerEditor({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "optional-steps" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
onChange={setOptionalSteps}
|
||||
readOnly={isBuiltin}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobilePanel === "columns" && (
|
||||
<div className="wf-mobile-destination">
|
||||
<WorkflowColumnPanel
|
||||
|
||||
107
packages/dashboard/app/components/WorkflowOptionalStepsPanel.css
Normal file
107
packages/dashboard/app/components/WorkflowOptionalStepsPanel.css
Normal file
@@ -0,0 +1,107 @@
|
||||
/* WorkflowOptionalStepsPanel — sibling of WorkflowFieldsPanel; mirrors its layout
|
||||
* so the optional-steps panel reads consistently alongside Fields/Settings. */
|
||||
|
||||
.wf-optional-steps-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.wf-optional-steps-header h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-hint,
|
||||
.wf-optional-steps-empty {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-steps-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.wf-optional-step-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm, 6px);
|
||||
}
|
||||
|
||||
.wf-optional-step-item.is-unknown {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.wf-optional-step-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.wf-optional-step-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-name--unknown {
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.wf-optional-step-description {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.wf-optional-step-default {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.wf-optional-step-remove:hover:not(:disabled) {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.wf-optional-steps-add {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.wf-optional-steps-add-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
172
packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx
Normal file
172
packages/dashboard/app/components/WorkflowOptionalStepsPanel.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* WorkflowOptionalStepsPanel — the workflow editor's optional-step authoring
|
||||
* surface. Sibling to {@link WorkflowFieldsPanel} / WorkflowSettingsPanel: lives
|
||||
* alongside the canvas in {@link WorkflowNodeEditor} and mutates the IR's
|
||||
* `optionalSteps` array through the same state/save flow (preserved across the
|
||||
* round-trip by `flowToIr`).
|
||||
*
|
||||
* A declaration is just `{ templateId, defaultOn? }`. Display metadata
|
||||
* (name/description/phase) is resolved from the built-in step-template catalog at
|
||||
* render time — never duplicated into the IR — so the resolver stays the single
|
||||
* source of truth. Unknown/stale template ids render a muted, still-removable row
|
||||
* rather than being silently dropped.
|
||||
*/
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { WORKFLOW_STEP_TEMPLATES, type WorkflowOptionalStep, type WorkflowStepTemplate } from "@fusion/core";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import "./WorkflowOptionalStepsPanel.css";
|
||||
|
||||
interface WorkflowOptionalStepsPanelProps {
|
||||
optionalSteps: WorkflowOptionalStep[];
|
||||
onChange: (next: WorkflowOptionalStep[]) => void;
|
||||
readOnly: boolean;
|
||||
/** Plugin-contributed templates, merged into the catalog when available. */
|
||||
pluginTemplates?: WorkflowStepTemplate[];
|
||||
}
|
||||
|
||||
export function WorkflowOptionalStepsPanel({
|
||||
optionalSteps,
|
||||
onChange,
|
||||
readOnly,
|
||||
pluginTemplates = [],
|
||||
}: WorkflowOptionalStepsPanelProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const templatesById = useMemo(() => {
|
||||
const map = new Map<string, WorkflowStepTemplate>();
|
||||
for (const tpl of [...WORKFLOW_STEP_TEMPLATES, ...pluginTemplates]) map.set(tpl.id, tpl);
|
||||
return map;
|
||||
}, [pluginTemplates]);
|
||||
|
||||
const declaredIds = useMemo(() => new Set(optionalSteps.map((s) => s.templateId)), [optionalSteps]);
|
||||
|
||||
// Catalog entries not already declared — the "Add optional step" picker source.
|
||||
const available = useMemo(
|
||||
() => [...templatesById.values()].filter((tpl) => !declaredIds.has(tpl.id)),
|
||||
[templatesById, declaredIds],
|
||||
);
|
||||
|
||||
const addStep = useCallback(
|
||||
(templateId: string) => {
|
||||
if (!templateId || declaredIds.has(templateId)) return;
|
||||
onChange([...optionalSteps, { templateId, defaultOn: false }]);
|
||||
},
|
||||
[optionalSteps, onChange, declaredIds],
|
||||
);
|
||||
|
||||
const removeStep = useCallback(
|
||||
(templateId: string) => onChange(optionalSteps.filter((s) => s.templateId !== templateId)),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
const toggleDefaultOn = useCallback(
|
||||
(templateId: string, defaultOn: boolean) =>
|
||||
onChange(optionalSteps.map((s) => (s.templateId === templateId ? { ...s, defaultOn } : s))),
|
||||
[optionalSteps, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="wf-optional-steps-panel" data-testid="wf-optional-steps-panel">
|
||||
<header className="wf-optional-steps-header">
|
||||
<h3>{t("workflowOptionalSteps.title", "Optional steps")}</h3>
|
||||
<p className="wf-optional-steps-hint">
|
||||
{t(
|
||||
"workflowOptionalSteps.hint",
|
||||
"Steps a task can toggle on or off. Default sets the initial state for new tasks.",
|
||||
)}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{optionalSteps.length === 0 ? (
|
||||
<p className="wf-optional-steps-empty">
|
||||
{t("workflowOptionalSteps.empty", "No optional steps. Add one to let tasks opt in or out.")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="wf-optional-steps-list">
|
||||
{optionalSteps.map((step) => {
|
||||
const tpl = templatesById.get(step.templateId);
|
||||
const defaultOn = step.defaultOn ?? tpl?.defaultOn ?? false;
|
||||
return (
|
||||
<li
|
||||
key={step.templateId}
|
||||
className={`wf-optional-step-item${tpl ? "" : " is-unknown"}`}
|
||||
data-testid={`wf-optional-step-${step.templateId}`}
|
||||
>
|
||||
<div className="wf-optional-step-head">
|
||||
<div className="wf-optional-step-title">
|
||||
{tpl ? (
|
||||
<>
|
||||
<span className="wf-optional-step-name">{tpl.name}</span>
|
||||
{phaseBadge(tpl.phase ?? "pre-merge", step.templateId, "wf-optional-step-phase", t)}
|
||||
</>
|
||||
) : (
|
||||
<span className="wf-optional-step-name wf-optional-step-name--unknown">
|
||||
{t("workflowOptionalSteps.unknown", "Unknown step ({{id}})", { id: step.templateId })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="wf-optional-step-remove"
|
||||
aria-label={t("workflowOptionalSteps.remove", "Remove optional step")}
|
||||
disabled={readOnly}
|
||||
onClick={() => removeStep(step.templateId)}
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
{tpl?.description && (
|
||||
<p className="wf-optional-step-description">{tpl.description}</p>
|
||||
)}
|
||||
<label className="wf-optional-step-default">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={defaultOn}
|
||||
disabled={readOnly}
|
||||
aria-label={t("workflowOptionalSteps.defaultOnFor", "Default on for {{name}}", {
|
||||
name: tpl?.name ?? step.templateId,
|
||||
})}
|
||||
onChange={(e) => toggleDefaultOn(step.templateId, e.target.checked)}
|
||||
/>
|
||||
<span>{t("workflowOptionalSteps.defaultOn", "Default on")}</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{available.length > 0 && (
|
||||
<div className="wf-optional-steps-add">
|
||||
{/* Picker resets to placeholder after each add (value stays ""). */}
|
||||
<label className="wf-optional-steps-add-label" htmlFor="wf-optional-steps-add-select">
|
||||
<Plus size={13} /> {t("workflowOptionalSteps.add", "Add optional step")}
|
||||
</label>
|
||||
<select
|
||||
id="wf-optional-steps-add-select"
|
||||
data-testid="wf-optional-steps-add-select"
|
||||
value=""
|
||||
disabled={readOnly}
|
||||
onChange={(e) => {
|
||||
addStep(e.target.value);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("workflowOptionalSteps.addPlaceholder", "Select a step…")}
|
||||
</option>
|
||||
{available.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default WorkflowOptionalStepsPanel;
|
||||
@@ -15,6 +15,7 @@ import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, Wor
|
||||
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
|
||||
import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, fetchWorkflowOptionalSteps, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
import { phaseBadge } from "./workflow-phase-badge";
|
||||
import { useAgentLogs } from "../hooks/useAgentLogs";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { irToFlow } from "./workflow-flow-mapping";
|
||||
@@ -134,17 +135,8 @@ function getOutputPreview(output: string): string {
|
||||
return `${lines.length} lines`;
|
||||
}
|
||||
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: ReturnType<typeof useTranslation>["t"]): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span
|
||||
className={`phase-badge ${phaseClass}`}
|
||||
data-testid={`${prefix}-${id}`}
|
||||
>
|
||||
{phase === "post-merge" ? t("app:workflow.postMerge", "Post-merge") : t("app:workflow.preMerge", "Pre-merge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// phaseBadge moved to ./workflow-phase-badge (shared with the optional-steps panel
|
||||
// and the optional-steps dropdown). Imported above.
|
||||
|
||||
function getWorkflowName(
|
||||
selectedWorkflowId: string | null,
|
||||
|
||||
@@ -164,6 +164,14 @@ function v2Def(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function v2DefWithOptional(): WorkflowDefinition {
|
||||
const base = v2Def();
|
||||
return {
|
||||
...base,
|
||||
ir: { ...(base.ir as object), optionalSteps: [{ templateId: "browser-verification" }] } as WorkflowDefinition["ir"],
|
||||
};
|
||||
}
|
||||
|
||||
function builtinDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "builtin:coding",
|
||||
@@ -742,6 +750,34 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(start?.column).toBe("done");
|
||||
});
|
||||
|
||||
it("hydrates declared optional steps and preserves them through a dirty save (round-trip)", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2DefWithOptional()]);
|
||||
vi.mocked(updateWorkflow).mockImplementation(async (_id, updates) => ({
|
||||
...v2DefWithOptional(),
|
||||
...(updates as object),
|
||||
}));
|
||||
vi.mocked(compileWorkflow).mockResolvedValue({ steps: [] });
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
await screen.findByText("Save");
|
||||
// The declared optional step is hydrated into the panel (optionalStepsOf).
|
||||
const row = await screen.findByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
|
||||
// Toggling defaultOn must mark the editor dirty (serializeGraph threading) so
|
||||
// the Save button enables and persists the change.
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
fireEvent.click(screen.getByText("Save").closest("button")!);
|
||||
|
||||
await waitFor(() => expect(updateWorkflow).toHaveBeenCalled());
|
||||
const [, updates] = vi.mocked(updateWorkflow).mock.calls[0];
|
||||
const ir = (updates as { ir: WorkflowDefinition["ir"] }).ir as {
|
||||
optionalSteps?: { templateId: string; defaultOn?: boolean }[];
|
||||
};
|
||||
expect(ir.optionalSteps).toEqual([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("renders the start inspector without the entry-column select for v1 workflows", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, within } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import type { WorkflowOptionalStep } from "@fusion/core";
|
||||
import { WorkflowOptionalStepsPanel } from "../WorkflowOptionalStepsPanel";
|
||||
|
||||
// Controlled host mirroring how WorkflowNodeEditor drives the panel.
|
||||
function Host({
|
||||
initial,
|
||||
readOnly = false,
|
||||
onState,
|
||||
}: {
|
||||
initial: WorkflowOptionalStep[];
|
||||
readOnly?: boolean;
|
||||
onState?: (s: WorkflowOptionalStep[]) => void;
|
||||
}) {
|
||||
const [optionalSteps, setOptionalSteps] = useState<WorkflowOptionalStep[]>(initial);
|
||||
return (
|
||||
<WorkflowOptionalStepsPanel
|
||||
optionalSteps={optionalSteps}
|
||||
readOnly={readOnly}
|
||||
onChange={(next) => {
|
||||
setOptionalSteps(next);
|
||||
onState?.(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("WorkflowOptionalStepsPanel", () => {
|
||||
it("renders the empty state and an add picker when no steps are declared", () => {
|
||||
render(<Host initial={[]} />);
|
||||
expect(screen.getByText(/No optional steps/i)).toBeTruthy();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
// browser-verification is in the catalog and not yet declared → available.
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("adds a step from the picker (defaultOn false) and removes it from the picker", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[]} onState={onState} />);
|
||||
fireEvent.change(screen.getByTestId("wf-optional-steps-add-select"), {
|
||||
target: { value: "browser-verification" },
|
||||
});
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: false }]);
|
||||
// The declared row is shown with the resolved template name…
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect(within(row).getByText("Browser Verification")).toBeTruthy();
|
||||
// …and the picker no longer offers it.
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).queryByRole("option", { name: "Browser Verification" })).toBeNull();
|
||||
});
|
||||
|
||||
it("toggles defaultOn for a declared step", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "browser-verification", defaultOn: false }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("checkbox"));
|
||||
expect(onState).toHaveBeenCalledWith([{ templateId: "browser-verification", defaultOn: true }]);
|
||||
});
|
||||
|
||||
it("removes a declared step and returns it to the picker", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(screen.queryByTestId("wf-optional-step-browser-verification")).toBeNull();
|
||||
const select = screen.getByTestId("wf-optional-steps-add-select") as HTMLSelectElement;
|
||||
expect(within(select).getByRole("option", { name: "Browser Verification" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an unknown/stale templateId as a muted, still-removable row", () => {
|
||||
const onState = vi.fn();
|
||||
render(<Host initial={[{ templateId: "does-not-exist" }]} onState={onState} />);
|
||||
const row = screen.getByTestId("wf-optional-step-does-not-exist");
|
||||
expect(row.className).toContain("is-unknown");
|
||||
expect(within(row).getByText(/Unknown step/i)).toBeTruthy();
|
||||
fireEvent.click(within(row).getByRole("button", { name: /Remove optional step/i }));
|
||||
expect(onState).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("disables editing when readOnly", () => {
|
||||
render(<Host initial={[{ templateId: "browser-verification" }]} readOnly />);
|
||||
const row = screen.getByTestId("wf-optional-step-browser-verification");
|
||||
expect((within(row).getByRole("checkbox") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((within(row).getByRole("button", { name: /Remove optional step/i }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
23
packages/dashboard/app/components/workflow-phase-badge.tsx
Normal file
23
packages/dashboard/app/components/workflow-phase-badge.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Shared phase chip for workflow steps (pre-merge / post-merge). Extracted from
|
||||
* WorkflowResultsTab so the node-editor optional-steps panel and the optional-step
|
||||
* dropdown render an identical badge without duplicating markup.
|
||||
*/
|
||||
import type { ReactNode } from "react";
|
||||
import type { useTranslation } from "react-i18next";
|
||||
|
||||
export function phaseBadge(
|
||||
phase: "pre-merge" | "post-merge",
|
||||
id: string,
|
||||
prefix: string,
|
||||
t: ReturnType<typeof useTranslation>["t"],
|
||||
): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span className={`phase-badge ${phaseClass}`} data-testid={`${prefix}-${id}`}>
|
||||
{phase === "post-merge"
|
||||
? t("app:workflow.postMerge", "Post-merge")
|
||||
: t("app:workflow.preMerge", "Pre-merge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user