FN-6524: add simple editor step reordering
Enable custom workflow authors to reorder simple editor steps from the mobile graph outline. - Add move up/down controls for editable outline rows while keeping built-in workflows read-only. - Swap sibling React Flow positions within columns or template parents so persisted order follows existing graph ordering. - Cover reorder availability, behavior, styling, and documentation updates. Files changed: docs/dashboard-guide.md | 2 +- .../app/components/MobileWorkflowGraphView.css | 17 ++- .../app/components/MobileWorkflowGraphView.tsx | 134 +++++++++++++++------ .../app/components/WorkflowNodeEditor.tsx | 20 ++- .../__tests__/MobileWorkflowGraphView.css.test.ts | 8 +- .../__tests__/MobileWorkflowGraphView.test.tsx | 123 ++++++++++++++++++- .../__tests__/workflow-mobile-graph.test.ts | 75 +++++++++++- .../app/components/workflow-mobile-graph.ts | 41 +++++++ 8 files changed, 372 insertions(+), 48 deletions(-) Fusion-Task-Id: FN-6524 Fusion-Task-Lineage: 25ffa5aa-bdfb-4ee3-9365-07029f5bbbf6
This commit is contained in:
@@ -128,7 +128,7 @@ Behavior:
|
||||
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.
|
||||
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
|
||||
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
|
||||
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop.
|
||||
- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.
|
||||
- Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split.
|
||||
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
|
||||
|
||||
|
||||
@@ -105,7 +105,12 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:WorkflowSimpleEditor 2026-06-17-03:02:
|
||||
Simple-editor step order is editable on touch and compact desktop surfaces, so move controls use the same minimum touch target and focus/active treatment as existing outline buttons without creating hidden shells for read-only or structural rows.
|
||||
*/
|
||||
.mobile-wf-node-expand,
|
||||
.mobile-wf-node-move,
|
||||
.mobile-wf-connect-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -122,21 +127,30 @@
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand {
|
||||
.mobile-wf-node-expand,
|
||||
.mobile-wf-node-move {
|
||||
width: var(--wf-editor-touch-target);
|
||||
}
|
||||
|
||||
.mobile-wf-node-move:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: var(--opacity-disabled, 0.5);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.mobile-wf-connect-button {
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:hover,
|
||||
.mobile-wf-node-move:not(:disabled):hover,
|
||||
.mobile-wf-connect-button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:focus-visible,
|
||||
.mobile-wf-node-move:focus-visible,
|
||||
.mobile-wf-connect-button:focus-visible,
|
||||
.mobile-wf-connect-select:focus-visible {
|
||||
outline: none;
|
||||
@@ -144,6 +158,7 @@
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:active,
|
||||
.mobile-wf-node-move:not(:disabled):active,
|
||||
.mobile-wf-connect-button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { MobileWorkflowNodeSummary } from "./workflow-mobile-graph";
|
||||
import type { MobileWorkflowNodeSummary, WorkflowNodeReorderDirection } from "./workflow-mobile-graph";
|
||||
import "./MobileWorkflowGraphView.css";
|
||||
|
||||
interface MobileWorkflowGraphViewProps {
|
||||
@@ -11,6 +11,17 @@ interface MobileWorkflowGraphViewProps {
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
onCreateConnection?: (source: string, target: string) => void;
|
||||
canReorder?: boolean;
|
||||
onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void;
|
||||
}
|
||||
|
||||
function reorderAvailability(rows: MobileWorkflowNodeSummary[], index: number) {
|
||||
const row = rows[index];
|
||||
if (!row?.editable) return { up: false, down: false };
|
||||
return {
|
||||
up: rows[index - 1]?.editable === true,
|
||||
down: rows[index + 1]?.editable === true,
|
||||
};
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
@@ -21,6 +32,10 @@ function NodeRow({
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
onCreateConnection,
|
||||
canReorder,
|
||||
onMoveNode,
|
||||
canMoveUp,
|
||||
canMoveDown,
|
||||
}: {
|
||||
row: MobileWorkflowNodeSummary;
|
||||
depth: number;
|
||||
@@ -29,6 +44,10 @@ function NodeRow({
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
onCreateConnection?: (source: string, target: string) => void;
|
||||
canReorder?: boolean;
|
||||
onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const hasChildren = row.children.length > 0;
|
||||
@@ -37,6 +56,7 @@ function NodeRow({
|
||||
const selected = selectedNodeId === row.id;
|
||||
const connectionTargets = row.connectionTargets ?? [];
|
||||
const canCreateConnection = !!onCreateConnection && row.editable && connectionTargets.length > 0;
|
||||
const showReorderControls = !!canReorder && !!onMoveNode && row.editable;
|
||||
|
||||
return (
|
||||
<div className="mobile-wf-node-group">
|
||||
@@ -58,17 +78,43 @@ function NodeRow({
|
||||
</span>
|
||||
{row.editable ? <Pencil size={14} aria-hidden /> : null}
|
||||
</button>
|
||||
{hasChildren ? (
|
||||
{hasChildren || showReorderControls ? (
|
||||
<div className="mobile-wf-node-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-wf-node-expand"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{showReorderControls ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon mobile-wf-node-move"
|
||||
data-testid={`mobile-wf-node-move-up-${row.id}`}
|
||||
aria-label={t("workflowNodes.mobileMoveUp", "Move up")}
|
||||
disabled={!canMoveUp}
|
||||
onClick={() => onMoveNode?.(row.id, "up")}
|
||||
>
|
||||
<ArrowUp size={16} aria-hidden />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon mobile-wf-node-move"
|
||||
data-testid={`mobile-wf-node-move-down-${row.id}`}
|
||||
aria-label={t("workflowNodes.mobileMoveDown", "Move down")}
|
||||
disabled={!canMoveDown}
|
||||
onClick={() => onMoveNode?.(row.id, "down")}
|
||||
>
|
||||
<ArrowDown size={16} aria-hidden />
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-wf-node-expand"
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
|
||||
onClick={() => setExpanded((value) => !value)}
|
||||
>
|
||||
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -146,18 +192,25 @@ function NodeRow({
|
||||
)}
|
||||
{hasChildren && expanded ? (
|
||||
<div className="mobile-wf-node-children">
|
||||
{row.children.map((child) => (
|
||||
<NodeRow
|
||||
key={child.id}
|
||||
row={child}
|
||||
depth={depth + 1}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
/>
|
||||
))}
|
||||
{row.children.map((child, index) => {
|
||||
const move = reorderAvailability(row.children, index);
|
||||
return (
|
||||
<NodeRow
|
||||
key={child.id}
|
||||
row={child}
|
||||
depth={depth + 1}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
canReorder={canReorder}
|
||||
onMoveNode={onMoveNode}
|
||||
canMoveUp={move.up}
|
||||
canMoveDown={move.down}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -171,6 +224,8 @@ export function MobileWorkflowGraphView({
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
onCreateConnection,
|
||||
canReorder,
|
||||
onMoveNode,
|
||||
}: MobileWorkflowGraphViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (rows.length === 0) {
|
||||
@@ -183,18 +238,25 @@ export function MobileWorkflowGraphView({
|
||||
|
||||
return (
|
||||
<div className="mobile-wf-graph" data-testid="mobile-wf-graph">
|
||||
{rows.map((row) => (
|
||||
<NodeRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={0}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
/>
|
||||
))}
|
||||
{rows.map((row, index) => {
|
||||
const move = reorderAvailability(rows, index);
|
||||
return (
|
||||
<NodeRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
depth={0}
|
||||
selectedNodeId={selectedNodeId}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
canReorder={canReorder}
|
||||
onMoveNode={onMoveNode}
|
||||
canMoveUp={move.up}
|
||||
canMoveDown={move.down}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,12 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
import { buildMobileWorkflowGraph, type MobileWorkflowConnectionTarget } from "./workflow-mobile-graph";
|
||||
import {
|
||||
buildMobileWorkflowGraph,
|
||||
reorderWorkflowNode,
|
||||
type MobileWorkflowConnectionTarget,
|
||||
type WorkflowNodeReorderDirection,
|
||||
} from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
@@ -1278,6 +1283,17 @@ function InnerEditor({
|
||||
[createConnectionEdge],
|
||||
);
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowSimpleEditor 2026-06-17-03:08:
|
||||
* Custom-workflow simple editors need read-only-safe reordering without a canvas drag gesture. Swap sibling node positions through the shared mobile graph helper so built-ins stay gated, selection remains untouched, and the existing IR save path persists the new position-derived order.
|
||||
*/
|
||||
const onMoveSimpleNode = useCallback(
|
||||
(nodeId: string, direction: WorkflowNodeReorderDirection) => {
|
||||
setNodes((ns) => reorderWorkflowNode(ns, nodeId, direction));
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
// Dragging a step node into a column band sets node.column (position-based
|
||||
// hit testing against the ordered bands — see workflow-flow-mapping).
|
||||
const onNodeDragStop = useCallback(
|
||||
@@ -2575,6 +2591,8 @@ function InnerEditor({
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
|
||||
canReorder={!isBuiltin}
|
||||
onMoveNode={onMoveSimpleNode}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -47,12 +47,14 @@ describe("MobileWorkflowGraphView CSS contract", () => {
|
||||
const nodeMainActiveRule = findRule([graphCss], /\.mobile-wf-node-main:active\s*\{[^}]*\}/);
|
||||
expect(nodeMainActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/);
|
||||
|
||||
const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover\s*\{[^}]*\}/);
|
||||
const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover,\s*\.mobile-wf-node-move:not\(:disabled\):hover,\s*\.mobile-wf-connect-button:hover\s*\{[^}]*\}/);
|
||||
expect(nodeExpandHoverRule).toMatch(/background\s*:\s*var\(--bg-tertiary\)\s*;/);
|
||||
const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible\s*\{[^}]*\}/);
|
||||
const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible,\s*\.mobile-wf-node-move:focus-visible,\s*\.mobile-wf-connect-button:focus-visible,\s*\.mobile-wf-connect-select:focus-visible\s*\{[^}]*\}/);
|
||||
expect(nodeExpandFocusRule).toMatch(/box-shadow\s*:\s*var\(--focus-ring-strong\)\s*;/);
|
||||
const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active\s*\{[^}]*\}/);
|
||||
const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active,\s*\.mobile-wf-node-move:not\(:disabled\):active,\s*\.mobile-wf-connect-button:active\s*\{[^}]*\}/);
|
||||
expect(nodeExpandActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/);
|
||||
const nodeMoveSizeRule = findRule([graphCss], /\.mobile-wf-node-expand,\s*\.mobile-wf-node-move\s*\{[^}]*\}/);
|
||||
expect(nodeMoveSizeRule).toMatch(/width\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
|
||||
|
||||
const edgeChipHoverRule = findRule([graphCss], /\.mobile-wf-edge-chip:hover\s*\{[^}]*\}/);
|
||||
expect(edgeChipHoverRule).toMatch(/background\s*:\s*var\(--bg-secondary\)\s*;/);
|
||||
|
||||
@@ -13,6 +13,15 @@ const rows: MobileWorkflowNodeSummary[] = [
|
||||
outgoing: [{ id: "e1", source: "start", target: "prompt", targetLabel: "Prompt", label: "success" }],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "prompt",
|
||||
label: "Prompt",
|
||||
kind: "prompt",
|
||||
summary: "Draft prompt",
|
||||
editable: true,
|
||||
outgoing: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "loop",
|
||||
label: "Review loop",
|
||||
@@ -22,18 +31,38 @@ const rows: MobileWorkflowNodeSummary[] = [
|
||||
outgoing: [],
|
||||
children: [
|
||||
{
|
||||
id: "loop::child",
|
||||
label: "Loop step",
|
||||
id: "loop::child-a",
|
||||
label: "Loop step A",
|
||||
kind: "prompt",
|
||||
summary: "Not configured",
|
||||
editable: true,
|
||||
parentId: "loop",
|
||||
templateLocalId: "child",
|
||||
templateLocalId: "child-a",
|
||||
outgoing: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: "loop::child-b",
|
||||
label: "Loop step B",
|
||||
kind: "script",
|
||||
summary: "Not configured",
|
||||
editable: true,
|
||||
parentId: "loop",
|
||||
templateLocalId: "child-b",
|
||||
outgoing: [],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "end",
|
||||
label: "End",
|
||||
kind: "end",
|
||||
summary: "",
|
||||
editable: false,
|
||||
outgoing: [],
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
describe("MobileWorkflowGraphView", () => {
|
||||
@@ -68,8 +97,92 @@ describe("MobileWorkflowGraphView", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-wf-node-loop::child")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-wf-node-loop::child-a")).toBeInTheDocument();
|
||||
fireEvent.click(within(screen.getByTestId("mobile-wf-node-loop")).getByRole("button", { name: /collapse/i }));
|
||||
expect(screen.queryByTestId("mobile-wf-node-loop::child")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mobile-wf-node-loop::child-a")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("exposes move controls for editable sibling rows and calls the move callback", () => {
|
||||
const onMoveNode = vi.fn();
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId={null}
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={() => {}}
|
||||
onSelectEdge={() => {}}
|
||||
canReorder
|
||||
onMoveNode={onMoveNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId("mobile-wf-node-move-up-start")).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("mobile-wf-node-move-up-prompt")).toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-prompt"));
|
||||
expect(onMoveNode).toHaveBeenCalledWith("prompt", "down");
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop"));
|
||||
expect(onMoveNode).toHaveBeenCalledWith("loop", "up");
|
||||
expect(screen.getByTestId("mobile-wf-node-move-down-loop")).toBeDisabled();
|
||||
expect(screen.queryByTestId("mobile-wf-node-move-down-end")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides move controls for read-only built-ins without empty action shells", () => {
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId={null}
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={() => {}}
|
||||
onSelectEdge={() => {}}
|
||||
canReorder={false}
|
||||
onMoveNode={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId(/mobile-wf-node-move-/)).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("mobile-wf-node-prompt")).queryByRole("button", { name: /move/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders template-child move controls with child-level boundaries", () => {
|
||||
const onMoveNode = vi.fn();
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId={null}
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={() => {}}
|
||||
onSelectEdge={() => {}}
|
||||
canReorder
|
||||
onMoveNode={onMoveNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("mobile-wf-node-move-up-loop::child-a")).toBeDisabled();
|
||||
fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-loop::child-a"));
|
||||
expect(onMoveNode).toHaveBeenCalledWith("loop::child-a", "down");
|
||||
fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop::child-b"));
|
||||
expect(onMoveNode).toHaveBeenCalledWith("loop::child-b", "up");
|
||||
expect(screen.getByTestId("mobile-wf-node-move-down-loop::child-b")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("proves the simple editor reorder symptom is gone through callback controls", () => {
|
||||
const onMoveNode = vi.fn();
|
||||
render(
|
||||
<MobileWorkflowGraphView
|
||||
rows={rows}
|
||||
selectedNodeId={null}
|
||||
selectedEdgeId={null}
|
||||
onSelectNode={() => {}}
|
||||
onSelectEdge={() => {}}
|
||||
canReorder
|
||||
onMoveNode={onMoveNode}
|
||||
/>,
|
||||
);
|
||||
|
||||
const movePromptDown = within(screen.getByTestId("mobile-wf-node-prompt")).getByRole("button", { name: "Move down" });
|
||||
fireEvent.click(movePromptDown);
|
||||
|
||||
expect(onMoveNode).toHaveBeenCalledWith("prompt", "down");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
|
||||
import { buildMobileWorkflowGraph } from "../workflow-mobile-graph";
|
||||
import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph";
|
||||
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
|
||||
import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping";
|
||||
|
||||
@@ -30,6 +30,79 @@ function edge(id: string, source: string, target: string, condition = "success")
|
||||
};
|
||||
}
|
||||
|
||||
function rowOrder(nodes: FlowNode<WorkflowFlowNodeData>[]): string[] {
|
||||
return buildMobileWorkflowGraph(nodes, []).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("reorderWorkflowNode", () => {
|
||||
it("swaps adjacent editable top-level siblings in the same column and re-derives the new order", () => {
|
||||
const nodes = [
|
||||
node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
|
||||
node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
|
||||
node("c", "gate", 0, 160, { data: { kind: "gate", label: "C", column: "todo" } }),
|
||||
];
|
||||
|
||||
const reordered = reorderWorkflowNode(nodes, "b", "up");
|
||||
|
||||
expect(rowOrder(reordered)).toEqual(["b", "a", "c"]);
|
||||
expect(reordered.find((n) => n.id === "b")?.position).toEqual({ x: 0, y: 0 });
|
||||
expect(reordered.find((n) => n.id === "a")?.position).toEqual({ x: 0, y: 80 });
|
||||
});
|
||||
|
||||
it("does not move past same-group boundaries", () => {
|
||||
const nodes = [
|
||||
node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
|
||||
node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
|
||||
];
|
||||
|
||||
expect(reorderWorkflowNode(nodes, "a", "up")).toBe(nodes);
|
||||
expect(reorderWorkflowNode(nodes, "b", "down")).toBe(nodes);
|
||||
});
|
||||
|
||||
it("does not move top-level nodes across column groups", () => {
|
||||
const nodes = [
|
||||
node("todo-a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
|
||||
node("doing-a", "script", 0, 80, { data: { kind: "script", label: "B", column: "doing" } }),
|
||||
];
|
||||
|
||||
expect(reorderWorkflowNode(nodes, "todo-a", "down")).toBe(nodes);
|
||||
expect(rowOrder(reorderWorkflowNode(nodes, "doing-a", "up"))).toEqual(["todo-a", "doing-a"]);
|
||||
});
|
||||
|
||||
it("reorders template children only within the same parent", () => {
|
||||
const first = foreachChildFlowId("each", "first");
|
||||
const second = foreachChildFlowId("each", "second");
|
||||
const other = foreachChildFlowId("other", "first");
|
||||
const nodes = [
|
||||
node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each" } }),
|
||||
node(first, "prompt", 20, 60, { parentId: "each", data: { kind: "prompt", label: "First" } }),
|
||||
node(second, "script", 20, 120, { parentId: "each", data: { kind: "script", label: "Second" } }),
|
||||
node("other", "loop", 0, 200, { data: { kind: "loop", label: "Other" } }),
|
||||
node(other, "prompt", 20, 60, { parentId: "other", data: { kind: "prompt", label: "Other child" } }),
|
||||
];
|
||||
|
||||
const rows = buildMobileWorkflowGraph(reorderWorkflowNode(nodes, second, "up"), []);
|
||||
|
||||
expect(rows.find((row) => row.id === "each")?.children.map((child) => child.id)).toEqual([second, first]);
|
||||
expect(rows.find((row) => row.id === "other")?.children.map((child) => child.id)).toEqual([other]);
|
||||
});
|
||||
|
||||
it("refuses to reorder non-editable nodes or swap with a non-editable neighbor", () => {
|
||||
const nodes = [
|
||||
node("start", "start", 0, 0, { data: { kind: "start", label: "Start", column: "todo" } }),
|
||||
node("step", "prompt", 0, 80, { data: { kind: "prompt", label: "Step", column: "todo" } }),
|
||||
node(columnBandNodeId("todo"), "start", -40, 0, {
|
||||
type: "group",
|
||||
data: { kind: "start", label: "Todo", column: "todo" },
|
||||
}),
|
||||
];
|
||||
|
||||
expect(reorderWorkflowNode(nodes, "start", "down")).toBe(nodes);
|
||||
expect(reorderWorkflowNode(nodes, "step", "up")).toBe(nodes);
|
||||
expect(reorderWorkflowNode(nodes, columnBandNodeId("todo"), "down")).toBe(nodes);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMobileWorkflowGraph", () => {
|
||||
it("returns ordered linear rows with outgoing edge destinations", () => {
|
||||
const rows = buildMobileWorkflowGraph(
|
||||
|
||||
@@ -56,6 +56,47 @@ function compareNodePosition(
|
||||
return Math.round(a.position.x) - Math.round(b.position.x);
|
||||
}
|
||||
|
||||
function isEditableWorkflowNode(node: FlowNode<WorkflowFlowNodeData>): boolean {
|
||||
return node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id);
|
||||
}
|
||||
|
||||
function isSameReorderGroup(
|
||||
target: FlowNode<WorkflowFlowNodeData>,
|
||||
candidate: FlowNode<WorkflowFlowNodeData>,
|
||||
): boolean {
|
||||
if (isColumnBandNode(candidate.id)) return false;
|
||||
if (target.parentId || candidate.parentId) return target.parentId === candidate.parentId;
|
||||
return target.data.column === candidate.data.column;
|
||||
}
|
||||
|
||||
export type WorkflowNodeReorderDirection = "up" | "down";
|
||||
|
||||
/**
|
||||
* FNXC:WorkflowSimpleEditor 2026-06-17-02:55:
|
||||
* Simple-editor order is derived from React Flow positions through compareNodePosition, so move controls must swap sibling positions instead of inventing a second ordering field. Keep moves inside the same column group for top-level nodes and inside the same parent group for template children so the re-derived outline and persisted IR stay consistent with canvas placement.
|
||||
*/
|
||||
export function reorderWorkflowNode(
|
||||
nodes: FlowNode<WorkflowFlowNodeData>[],
|
||||
nodeId: string,
|
||||
direction: WorkflowNodeReorderDirection,
|
||||
): FlowNode<WorkflowFlowNodeData>[] {
|
||||
const target = nodes.find((node) => node.id === nodeId);
|
||||
if (!target || !isEditableWorkflowNode(target)) return nodes;
|
||||
|
||||
const siblings = nodes
|
||||
.filter((node) => isSameReorderGroup(target, node))
|
||||
.sort(compareNodePosition);
|
||||
const targetIndex = siblings.findIndex((node) => node.id === nodeId);
|
||||
const neighbor = siblings[targetIndex + (direction === "up" ? -1 : 1)];
|
||||
if (!neighbor || !isEditableWorkflowNode(neighbor)) return nodes;
|
||||
|
||||
return nodes.map((node) => {
|
||||
if (node.id === target.id) return { ...node, position: { ...neighbor.position } };
|
||||
if (node.id === neighbor.id) return { ...node, position: { ...target.position } };
|
||||
return node;
|
||||
});
|
||||
}
|
||||
|
||||
function buildColumnNameMap(columns: WorkflowIrColumn[], nodes: FlowNode<WorkflowFlowNodeData>[]) {
|
||||
const names = new Map(columns.map((column) => [column.id, column.name || column.id]));
|
||||
for (const node of nodes) {
|
||||
|
||||
Reference in New Issue
Block a user