Files
fusion/packages/dashboard/app/components/workflow-mobile-graph.ts
gsxdsm 7b3c628fba 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
2026-06-17 03:12:16 -07:00

174 lines
6.1 KiB
TypeScript

import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
import type { WorkflowIrColumn } from "@fusion/core";
import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
import {
columnIdFromBandNode,
isColumnBandNode,
templateNodeIdFromChild,
} from "./workflow-flow-mapping";
import { nodeConfigSummary, type NodeSummaryCatalogs, type SummaryTranslate } from "./nodes/node-summary";
export interface MobileWorkflowEdgeSummary {
id: string;
source: string;
target: string;
targetLabel: string;
label: string;
kind?: string;
}
export interface MobileWorkflowConnectionTarget {
id: string;
label: string;
kind: WorkflowFlowNodeData["kind"];
}
export interface MobileWorkflowNodeSummary {
id: string;
label: string;
kind: WorkflowFlowNodeData["kind"];
summary: string;
columnName?: string;
editable: boolean;
parentId?: string;
templateLocalId?: string;
outgoing: MobileWorkflowEdgeSummary[];
connectionTargets?: MobileWorkflowConnectionTarget[];
children: MobileWorkflowNodeSummary[];
}
function edgeLabel(edge: FlowEdge): string {
if (typeof edge.label === "string" && edge.label.trim()) return edge.label;
return String(edge.data?.condition ?? "success");
}
function nodeDisplayLabel(node: FlowNode<WorkflowFlowNodeData>): string {
return node.data.label || node.id;
}
function compareNodePosition(
a: FlowNode<WorkflowFlowNodeData>,
b: FlowNode<WorkflowFlowNodeData>,
): number {
const ay = Math.round(a.position.y);
const by = Math.round(b.position.y);
if (ay !== by) return ay - by;
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) {
if (!isColumnBandNode(node.id)) continue;
const id = columnIdFromBandNode(node.id);
if (!names.has(id)) names.set(id, node.data.label || id);
}
return names;
}
export function buildMobileWorkflowGraph(
nodes: FlowNode<WorkflowFlowNodeData>[],
edges: FlowEdge[],
columns: WorkflowIrColumn[] = [],
catalogs: NodeSummaryCatalogs = {},
t?: SummaryTranslate,
): MobileWorkflowNodeSummary[] {
const columnNames = buildColumnNameMap(columns, nodes);
const nodesById = new Map(nodes.map((node) => [node.id, node]));
const childNodesByParent = new Map<string, FlowNode<WorkflowFlowNodeData>[]>();
const edgesBySource = new Map<string, FlowEdge[]>();
for (const node of nodes) {
if (!node.parentId) continue;
const list = childNodesByParent.get(node.parentId) ?? [];
list.push(node);
childNodesByParent.set(node.parentId, list);
}
for (const list of childNodesByParent.values()) {
list.sort(compareNodePosition);
}
for (const edge of edges) {
const list = edgesBySource.get(edge.source) ?? [];
list.push(edge);
edgesBySource.set(edge.source, list);
}
const summarizeEdge = (edge: FlowEdge): MobileWorkflowEdgeSummary => {
const target = nodesById.get(edge.target);
return {
id: edge.id,
source: edge.source,
target: edge.target,
targetLabel: target ? nodeDisplayLabel(target) : edge.target,
label: edgeLabel(edge),
kind: typeof edge.data?.kind === "string" ? edge.data.kind : undefined,
};
};
const summarizeNode = (node: FlowNode<WorkflowFlowNodeData>): MobileWorkflowNodeSummary => {
const children = (childNodesByParent.get(node.id) ?? []).map(summarizeNode);
const columnId = node.data.column;
return {
id: node.id,
label: nodeDisplayLabel(node),
kind: node.data.kind,
summary: nodeConfigSummary(node.data, catalogs, t),
columnName: columnId ? columnNames.get(columnId) ?? columnId : undefined,
editable: node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id),
parentId: node.parentId,
templateLocalId: node.parentId ? templateNodeIdFromChild(node.parentId, node.id) : undefined,
outgoing: (edgesBySource.get(node.id) ?? []).map(summarizeEdge),
children,
};
};
const topLevelNodes = nodes
.filter((node) => !node.parentId && !isColumnBandNode(node.id))
.sort(compareNodePosition);
return topLevelNodes.map(summarizeNode);
}