feat(dashboard): make built-in workflows read-only in the node editor

Built-in (builtin:*) workflows are read-only at the store layer; reflect
that in the editor so users aren't misled into editing changes that can't
save. For a built-in: the node palette is disabled, the inspector fields are
wrapped in a disabled fieldset, Save/Delete are replaced by a "Read-only
built-in" label, and a "Duplicate to edit" action clones it into an editable
user workflow. handleSave/handleDeleteWorkflow also early-return for built-ins
as defense-in-depth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 15:19:27 -07:00
parent 0435fefd86
commit bd8f0fd23c
2 changed files with 74 additions and 8 deletions

View File

@@ -159,9 +159,26 @@
.wf-editor-actions {
display: flex;
align-items: center;
gap: var(--space-xs);
}
.wf-editor-readonly-note {
font-size: 0.75rem;
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
/* Neutralize the fieldset wrapper so it only gates interactivity, not layout. */
.wf-inspector-fields {
display: contents;
border: none;
margin: 0;
padding: 0;
min-inline-size: 0;
}
.wf-editor-save {
background: var(--todo);
border-color: var(--todo);

View File

@@ -16,7 +16,7 @@ import {
} from "@xyflow/react";
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle } from "lucide-react";
import type { WorkflowDefinition } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import { getErrorMessage, isBuiltinWorkflowId } from "@fusion/core";
import {
fetchWorkflows,
createWorkflow,
@@ -87,6 +87,7 @@ function InnerEditor({
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
const loadWorkflows = useCallback(async () => {
setLoading(true);
@@ -187,6 +188,7 @@ function InnerEditor({
const handleDeleteWorkflow = useCallback(async () => {
if (!activeWorkflow) return;
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
if (!window.confirm(`Delete workflow "${activeWorkflow.name}"?`)) return;
try {
await deleteWorkflow(activeWorkflow.id, projectId);
@@ -198,8 +200,29 @@ function InnerEditor({
}
}, [activeWorkflow, projectId, addToast]);
const handleDuplicate = useCallback(async () => {
if (!activeWorkflow) return;
try {
const created = await createWorkflow(
{
name: `${activeWorkflow.name} (copy)`,
description: activeWorkflow.description,
ir: activeWorkflow.ir,
layout: activeWorkflow.layout,
},
projectId,
);
setWorkflows((ws) => [...ws, created]);
setActiveId(created.id);
addToast(`Duplicated to "${created.name}" — editable`, "success");
} catch (err) {
addToast(getErrorMessage(err) || "Failed to duplicate workflow", "error");
}
}, [activeWorkflow, projectId, addToast]);
const handleSave = useCallback(async () => {
if (!activeWorkflow) return;
if (isBuiltinWorkflowId(activeWorkflow.id)) return; // built-ins are read-only
setSaving(true);
setValidationError(null);
try {
@@ -294,18 +317,37 @@ function InnerEditor({
<div className="wf-editor-toolbar">
<div className="wf-editor-palette">
{PALETTE.map(({ kind, label, icon: Icon, presetConfig }) => (
<button key={label} className="wf-palette-btn" onClick={() => addNode(kind, label, presetConfig)}>
<button
key={label}
className="wf-palette-btn"
onClick={() => addNode(kind, label, presetConfig)}
disabled={isBuiltin}
title={isBuiltin ? "Built-in workflows are read-only — duplicate to edit" : undefined}
>
<Icon size={13} /> {label}
</button>
))}
</div>
<div className="wf-editor-actions">
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> Delete
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
</button>
{isBuiltin ? (
<>
<span className="wf-editor-readonly-note" role="status">
Read-only built-in
</span>
<button className="wf-editor-save" onClick={handleDuplicate}>
<Plus size={13} /> Duplicate to edit
</button>
</>
) : (
<>
<button className="wf-editor-delete" onClick={handleDeleteWorkflow}>
<Trash2 size={13} /> Delete
</button>
<button className="wf-editor-save" onClick={handleSave} disabled={saving}>
{saving ? <Loader2 size={13} className="wf-spin" /> : <Save size={13} />} Save
</button>
</>
)}
</div>
</div>
@@ -343,6 +385,12 @@ function InnerEditor({
{selectedNode && selectedNode.data.kind !== "start" && selectedNode.data.kind !== "end" && (
<aside className="wf-editor-inspector">
<h3>Node</h3>
{isBuiltin && (
<p className="wf-inspector-note wf-inspector-note--info">
Read-only built-in — duplicate the workflow to edit nodes.
</p>
)}
<fieldset className="wf-inspector-fields" disabled={isBuiltin}>
<label className="wf-field">
<span>Name</span>
<input
@@ -549,6 +597,7 @@ function InnerEditor({
Steps before this marker run pre-merge; steps after run post-merge.
</p>
)}
</fieldset>
</aside>
)}
</div>