feat(dashboard): workflow editor onboarding and empty states
This commit is contained in:
@@ -121,6 +121,52 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* No-workflow onboarding panel (R9): icon + heading + explanation + create CTA. */
|
||||
.wf-editor-onboard {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.wf-editor-onboard-icon {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-editor-onboard-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.wf-editor-onboard-text {
|
||||
margin: 0;
|
||||
max-width: 36ch;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wf-editor-onboard-cta {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Trivial-graph palette hint (R9): non-blocking banner over the canvas. */
|
||||
.wf-trivial-hint {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
max-width: min(90%, 42ch);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--ws-info) 8%, var(--bg-secondary));
|
||||
border: 1px solid var(--ws-info);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--ws-info);
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wf-editor-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type Edge as FlowEdge,
|
||||
} from "@xyflow/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid } from "lucide-react";
|
||||
import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid, Workflow } from "lucide-react";
|
||||
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import {
|
||||
@@ -149,6 +149,23 @@ const PALETTE: Array<{ kind: WorkflowEditorNodeKind; label: string; icon: typeof
|
||||
{ kind: "code", label: "Code", icon: Code2, presetConfig: { source: "" } },
|
||||
];
|
||||
|
||||
// Node kinds a user authors from the palette. Structural/derived nodes
|
||||
// (start/end and column bands — which map to data.kind "start") are excluded, so
|
||||
// a fresh start→end graph counts as trivial. Used by the palette-hint (R9).
|
||||
const USER_NODE_KINDS: ReadonlySet<WorkflowEditorNodeKind> = new Set<WorkflowEditorNodeKind>([
|
||||
"prompt",
|
||||
"script",
|
||||
"gate",
|
||||
"code",
|
||||
"hold",
|
||||
"split",
|
||||
"join",
|
||||
"foreach",
|
||||
"step-review",
|
||||
"parse-steps",
|
||||
"merge",
|
||||
]);
|
||||
|
||||
/** Local create-workflow dialog (KTD-7). Built on the shared `.modal` primitives
|
||||
* (precedent: NewTaskModal). Owns its own name/description/error state; the
|
||||
* parent supplies an async `onCreate` that performs the createWorkflow call and
|
||||
@@ -324,6 +341,15 @@ function InnerEditor({
|
||||
const activeWorkflow = useMemo(() => workflows.find((w) => w.id === activeId), [workflows, activeId]);
|
||||
const isBuiltin = !!activeWorkflow && isBuiltinWorkflowId(activeWorkflow.id);
|
||||
|
||||
// Trivial-graph palette hint (R9): a user-owned workflow whose graph carries no
|
||||
// user-authored node yet (everything is start/end/column-band — column bands map
|
||||
// to data.kind "start"). Disappears as soon as any user node exists; never shows
|
||||
// for built-ins.
|
||||
const isTrivialUserGraph = useMemo(() => {
|
||||
if (!activeWorkflow || isBuiltin) return false;
|
||||
return !nodes.some((n) => USER_NODE_KINDS.has(n.data.kind));
|
||||
}, [activeWorkflow, isBuiltin, nodes]);
|
||||
|
||||
// Trait catalog (for client-side composition validation; the panel fetches its
|
||||
// own copy for the picker, but the editor needs the flags to validate).
|
||||
useEffect(() => {
|
||||
@@ -1227,6 +1253,14 @@ function InnerEditor({
|
||||
)}
|
||||
|
||||
<div className="wf-editor-canvas" ref={canvasRef} tabIndex={-1}>
|
||||
{isTrivialUserGraph && (
|
||||
<div className="wf-trivial-hint" role="status" data-testid="wf-trivial-hint">
|
||||
{t(
|
||||
"workflowNodes.trivialGraphHint",
|
||||
"This workflow only runs start → end. Add steps from the palette above to build it out.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<WorkflowEditorCatalogContext.Provider value={catalogs}>
|
||||
<ReactFlow
|
||||
nodes={nodesForRender}
|
||||
@@ -1263,8 +1297,24 @@ function InnerEditor({
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="wf-editor-empty wf-editor-canvas-empty">
|
||||
{t("workflows.selectOrCreate", "Select or create a workflow to start editing.")}
|
||||
<div className="wf-editor-empty wf-editor-canvas-empty wf-editor-onboard">
|
||||
<Workflow className="wf-editor-onboard-icon" size={40} aria-hidden />
|
||||
<h3 className="wf-editor-onboard-title">
|
||||
{t("workflows.emptyTitle", "No workflow selected")}
|
||||
</h3>
|
||||
<p className="wf-editor-onboard-text">
|
||||
{t(
|
||||
"workflows.emptyDescription",
|
||||
"Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
)}
|
||||
</p>
|
||||
<button
|
||||
className="wf-editor-save wf-editor-onboard-cta"
|
||||
data-testid="wf-empty-create"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus size={14} /> {t("workflows.newWorkflow", "New workflow")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -136,7 +136,8 @@ describe("WorkflowNodeEditor", () => {
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
expect(await screen.findByText("Workflows")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText(/No workflows yet/i)).toBeInTheDocument());
|
||||
expect(screen.getByText(/Select or create a workflow/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/No workflow selected/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId("wf-empty-create")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
@@ -1007,3 +1008,72 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
|
||||
expect(screen.getByTestId("wf-workflow-name")).toHaveTextContent("Edited");
|
||||
});
|
||||
});
|
||||
|
||||
// ── U6: empty / onboarding states ───────────────────────────────────────────
|
||||
|
||||
// A user-owned workflow whose graph is only start→end (no user-authored nodes).
|
||||
function trivialUserDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-TRIVIAL",
|
||||
name: "Trivial",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v1",
|
||||
name: "Trivial",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [{ from: "start", to: "end", condition: "success" }],
|
||||
},
|
||||
layout: { start: { x: 0, y: 0 }, end: { x: 240, y: 0 } },
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("WorkflowNodeEditor — U6 empty/onboarding states", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
|
||||
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("no-workflow empty state CTA opens the create dialog", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
const cta = await screen.findByTestId("wf-empty-create");
|
||||
expect(screen.queryByTestId("wf-create-dialog")).not.toBeInTheDocument();
|
||||
fireEvent.click(cta);
|
||||
expect(await screen.findByTestId("wf-create-dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the trivial-graph palette hint for a user-owned start→end workflow", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([trivialUserDef()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
// Wait for hydration (the palette appears for editable workflows).
|
||||
await screen.findByText("Save");
|
||||
expect(await screen.findByTestId("wf-trivial-hint")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the trivial-graph hint once a user node exists", async () => {
|
||||
// v2Def() carries a "prompt" step → a user-authored node.
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
await screen.findByText("Save");
|
||||
await screen.findByTestId("wf-column-panel");
|
||||
expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never renders the trivial-graph hint for a built-in workflow", async () => {
|
||||
// Built-in that is itself trivial (start→end only) — must still not show the hint.
|
||||
const builtinTrivial: WorkflowDefinition = { ...trivialUserDef(), id: "builtin:trivial", name: "Built-in" };
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinTrivial]);
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
expect(await screen.findByTestId("wf-readonly-banner")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("wf-trivial-hint")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "Click to edit description",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
"emptyTitle": "No workflow selected",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
"saved": "Workflow saved",
|
||||
"savedNotCompilable": "Workflow saved but cannot be compiled",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"selectOrCreate": "Select or create a workflow to start editing."
|
||||
"saveFailed": "Failed to save workflow"
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "Las ramas se ejecutan de forma concurrente desde este nodo. No se permiten uniones de ejecución y fusión dentro de una rama.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": ""
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"selectOrCreate": ""
|
||||
"saveFailed": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "Les branches s’exécutent simultanément depuis ce nœud. Les jointures d’exécution et de fusion ne sont pas autorisées dans une branche.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": ""
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"selectOrCreate": ""
|
||||
"saveFailed": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "분기는 이 노드에서 동시에 실행됩니다. 분기 내에서는 실행 및 병합 이음새가 허용되지 않습니다.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": ""
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"selectOrCreate": ""
|
||||
"saveFailed": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "分支从此节点并发运行。分支内不允许执行和合并接缝。",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": ""
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"selectOrCreate": ""
|
||||
"saveFailed": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
@@ -6807,7 +6807,8 @@
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "分支從此節點並行執行。分支內不允許執行與合併接縫。",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": ""
|
||||
},
|
||||
"workflows": {
|
||||
"clickToEditDescription": "",
|
||||
@@ -6829,13 +6830,14 @@
|
||||
"discardMessage": "",
|
||||
"discardTitle": "",
|
||||
"duplicateToCustomize": "",
|
||||
"emptyDescription": "",
|
||||
"emptyTitle": "",
|
||||
"nameLabel": "",
|
||||
"newWorkflow": "",
|
||||
"readOnlyBuiltin": "",
|
||||
"saved": "",
|
||||
"savedNotCompilable": "",
|
||||
"saveFailed": "",
|
||||
"selectOrCreate": ""
|
||||
"saveFailed": ""
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "",
|
||||
|
||||
8
packages/i18n/src/resources.d.ts
vendored
8
packages/i18n/src/resources.d.ts
vendored
@@ -6809,7 +6809,8 @@ export default interface Resources {
|
||||
"reviewPlan": "Plan review",
|
||||
"reviewType": "Review type",
|
||||
"splitNote": "Branches run concurrently from this node. Execute and merge seams are not allowed inside a branch.",
|
||||
"stepExecuteLabel": "Step execute"
|
||||
"stepExecuteLabel": "Step execute",
|
||||
"trivialGraphHint": "This workflow only runs start → end. Add steps from the palette above to build it out."
|
||||
},
|
||||
"workflowSelector": {
|
||||
"switchActiveMessage": "This task has an active session. Switching workflows aborts it and re-homes the card into the new workflow's entry column. Continue?",
|
||||
@@ -6837,13 +6838,14 @@ export default interface Resources {
|
||||
"discardMessage": "You have unsaved changes to this workflow. Discard them?",
|
||||
"discardTitle": "Discard unsaved changes?",
|
||||
"duplicateToCustomize": "Duplicate to customize",
|
||||
"emptyDescription": "Workflows orchestrate the steps and gates that run around task execution. Create one to start arranging that flow.",
|
||||
"emptyTitle": "No workflow selected",
|
||||
"nameLabel": "Workflow name",
|
||||
"newWorkflow": "New workflow",
|
||||
"readOnlyBuiltin": "Read-only built-in workflow",
|
||||
"saveFailed": "Failed to save workflow",
|
||||
"saved": "Workflow saved",
|
||||
"savedNotCompilable": "Workflow saved but cannot be compiled",
|
||||
"selectOrCreate": "Select or create a workflow to start editing."
|
||||
"savedNotCompilable": "Workflow saved but cannot be compiled"
|
||||
},
|
||||
"workspace": {
|
||||
"projectRoot": "Project Root",
|
||||
|
||||
Reference in New Issue
Block a user