FN-6523: add mobile workflow connection picker
Adds a touch-friendly path for creating workflow edges from the mobile editor. - Add mobile node Connect controls with a target picker that delegates to the existing edge validation path.\n- Surface duplicate-edge feedback and select newly created mobile edges for editing.\n- Document the mobile connection flow, add regression coverage, register locale strings, and add the required changeset.\n\nFiles changed:\n .changeset/fn-6523-mobile-workflow-connect.md | 5 +\n docs/workflow-editor.md | 2 +-\n .../app/components/MobileWorkflowGraphView.css | 67 ++++++++++--\n .../app/components/MobileWorkflowGraphView.tsx | 76 ++++++++++++--\n .../app/components/WorkflowNodeEditor.tsx | 69 +++++++++++--\n .../__tests__/WorkflowNodeEditor.test.tsx | 114 +++++++++++++++++++++\n .../app/components/workflow-mobile-graph.ts | 7 ++\n packages/i18n/locales/en/app.json | 4 +\n packages/i18n/locales/es/app.json | 4 +\n packages/i18n/locales/fr/app.json | 4 +\n packages/i18n/locales/ko/app.json | 4 +\n packages/i18n/locales/zh-CN/app.json | 4 +\n packages/i18n/locales/zh-TW/app.json | 4 +\n 13 files changed, 342 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-6523 Fusion-Task-Lineage: 23b32826-278f-4230-a49c-22755ee700cc
This commit is contained in:
5
.changeset/fn-6523-mobile-workflow-connect.md
Normal file
5
.changeset/fn-6523-mobile-workflow-connect.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Enable creating workflow node connections from the mobile workflow editor.
|
||||
@@ -61,7 +61,7 @@ Some nodes expose specialized inspector fields: for example prompt execution det
|
||||
|
||||
## Edges, conditions, and rework
|
||||
|
||||
Create edges by connecting node handles on the graph. A new connection defaults to a **success** edge. The edge inspector lets you edit routing details when the source node supports it:
|
||||
Create edges by connecting node handles on the graph. In the mobile and compact simple graph, use a node's **Connect** action and target picker to create the same edge without dragging on the canvas; built-in workflows hide this mutation control because they are read-only. A new connection defaults to a **success** edge. The edge inspector lets you edit routing details when the source node supports it:
|
||||
|
||||
- **Success / failure conditions:** prompt, script, gate, code, and for-each style sources can route on `success` or `failure`.
|
||||
- **Outcome conditions:** review-style nodes route verdicts as `outcome:<verdict>` values. The shipped verdict list is `approve`, `revise`, `rethink`, and `unavailable`.
|
||||
|
||||
@@ -19,13 +19,24 @@
|
||||
padding-left: calc(var(--mobile-wf-depth, 0) * var(--space-md));
|
||||
}
|
||||
|
||||
.mobile-wf-node-actions {
|
||||
display: inline-flex;
|
||||
align-items: stretch;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.mobile-wf-node-actions--connect {
|
||||
justify-content: flex-end;
|
||||
padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs));
|
||||
}
|
||||
|
||||
.mobile-wf-node-main {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
min-width: 0;
|
||||
min-height: var(--wf-editor-touch-target, 44px);
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
@@ -94,12 +105,12 @@
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand {
|
||||
.mobile-wf-node-expand,
|
||||
.mobile-wf-connect-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--wf-editor-touch-target, 44px);
|
||||
min-height: var(--wf-editor-touch-target, 44px);
|
||||
min-height: var(--wf-editor-touch-target);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-secondary);
|
||||
@@ -111,19 +122,61 @@
|
||||
box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:hover {
|
||||
.mobile-wf-node-expand {
|
||||
width: var(--wf-editor-touch-target);
|
||||
}
|
||||
|
||||
.mobile-wf-connect-button {
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:hover,
|
||||
.mobile-wf-connect-button:hover {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:focus-visible {
|
||||
.mobile-wf-node-expand:focus-visible,
|
||||
.mobile-wf-connect-button:focus-visible,
|
||||
.mobile-wf-connect-select:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.mobile-wf-node-expand:active {
|
||||
.mobile-wf-node-expand:active,
|
||||
.mobile-wf-connect-button:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.mobile-wf-connect-picker {
|
||||
display: grid;
|
||||
gap: var(--space-xs);
|
||||
padding-left: calc((var(--mobile-wf-depth, 0) * var(--space-md)) + var(--space-xs));
|
||||
}
|
||||
|
||||
.mobile-wf-connect-label {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.mobile-wf-connect-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.mobile-wf-node-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.mobile-wf-node-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.mobile-wf-connect-button {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile-wf-node-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -10,6 +10,7 @@ interface MobileWorkflowGraphViewProps {
|
||||
selectedEdgeId?: string | null;
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
onCreateConnection?: (source: string, target: string) => void;
|
||||
}
|
||||
|
||||
function NodeRow({
|
||||
@@ -19,6 +20,7 @@ function NodeRow({
|
||||
selectedEdgeId,
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
onCreateConnection,
|
||||
}: {
|
||||
row: MobileWorkflowNodeSummary;
|
||||
depth: number;
|
||||
@@ -26,11 +28,15 @@ function NodeRow({
|
||||
selectedEdgeId?: string | null;
|
||||
onSelectNode: (id: string) => void;
|
||||
onSelectEdge: (id: string) => void;
|
||||
onCreateConnection?: (source: string, target: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const hasChildren = row.children.length > 0;
|
||||
const [expanded, setExpanded] = useState(depth === 0);
|
||||
const [connectPickerOpen, setConnectPickerOpen] = useState(false);
|
||||
const selected = selectedNodeId === row.id;
|
||||
const connectionTargets = row.connectionTargets ?? [];
|
||||
const canCreateConnection = !!onCreateConnection && row.editable && connectionTargets.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mobile-wf-node-group">
|
||||
@@ -53,17 +59,70 @@ function NodeRow({
|
||||
{row.editable ? <Pencil size={14} aria-hidden /> : null}
|
||||
</button>
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{canCreateConnection ? (
|
||||
<div
|
||||
className="mobile-wf-node-actions mobile-wf-node-actions--connect"
|
||||
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="mobile-wf-connect-button"
|
||||
data-testid={`mobile-wf-connect-${row.id}`}
|
||||
aria-expanded={connectPickerOpen}
|
||||
onClick={() => setConnectPickerOpen((value) => !value)}
|
||||
>
|
||||
<GitBranch size={14} aria-hidden />
|
||||
<span>{t("workflowNodes.mobileConnect", "Connect")}</span>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{/*
|
||||
FNXC:WorkflowEditor 2026-06-16-23:45:
|
||||
Mobile and compact simple editing do not render the React Flow canvas, so drag-to-connect handles are unavailable. This picker gives touch users a non-canvas path while the editor still owns edge validation and construction.
|
||||
*/}
|
||||
{canCreateConnection && connectPickerOpen ? (
|
||||
<div
|
||||
className="mobile-wf-connect-picker"
|
||||
style={{ ["--mobile-wf-depth" as string]: String(depth) }}
|
||||
>
|
||||
<label className="mobile-wf-connect-label" htmlFor={`mobile-wf-connect-target-${row.id}`}>
|
||||
{t("workflowNodes.mobileConnectTarget", "Target node")}
|
||||
</label>
|
||||
<select
|
||||
id={`mobile-wf-connect-target-${row.id}`}
|
||||
className="input mobile-wf-connect-select"
|
||||
data-testid={`mobile-wf-connect-target-${row.id}`}
|
||||
defaultValue=""
|
||||
onChange={(event) => {
|
||||
const target = event.currentTarget.value;
|
||||
if (!target) return;
|
||||
onCreateConnection?.(row.id, target);
|
||||
event.currentTarget.value = "";
|
||||
setConnectPickerOpen(false);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("workflowNodes.mobileConnectChooseTarget", "Choose a target…")}</option>
|
||||
{connectionTargets.map((target) => (
|
||||
<option key={target.id} value={target.id}>
|
||||
{target.label} ({target.kind})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
{(row.columnName || row.outgoing.length > 0) && (
|
||||
<div
|
||||
className="mobile-wf-node-meta"
|
||||
@@ -96,6 +155,7 @@ function NodeRow({
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -110,6 +170,7 @@ export function MobileWorkflowGraphView({
|
||||
selectedEdgeId,
|
||||
onSelectNode,
|
||||
onSelectEdge,
|
||||
onCreateConnection,
|
||||
}: MobileWorkflowGraphViewProps) {
|
||||
const { t } = useTranslation("app");
|
||||
if (rows.length === 0) {
|
||||
@@ -131,6 +192,7 @@ export function MobileWorkflowGraphView({
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
onSelectNode={onSelectNode}
|
||||
onSelectEdge={onSelectEdge}
|
||||
onCreateConnection={onCreateConnection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -85,7 +85,7 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
|
||||
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
|
||||
import { buildMobileWorkflowGraph } from "./workflow-mobile-graph";
|
||||
import { buildMobileWorkflowGraph, type MobileWorkflowConnectionTarget } from "./workflow-mobile-graph";
|
||||
|
||||
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
|
||||
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
|
||||
@@ -1234,8 +1234,8 @@ function InnerEditor({
|
||||
// which dedupes on source/target/handles and would block parallel
|
||||
// success+failure edges between the same pair (KTD-3). buildConnectionEdge
|
||||
// reimplements addEdge's sanity guards plus the author-time cycle guard (KTD-9).
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
const createConnectionEdge = useCallback(
|
||||
(connection: Connection, options: { selectCreatedEdge?: boolean } = {}) => {
|
||||
const result = buildConnectionEdge(connection, edges, nodes);
|
||||
if ("error" in result) {
|
||||
if (result.error === "cycle") {
|
||||
@@ -1246,14 +1246,38 @@ function InnerEditor({
|
||||
),
|
||||
"warning",
|
||||
);
|
||||
} else if (result.error === "duplicate") {
|
||||
addToast(
|
||||
t("workflowNodes.duplicateBlocked", "That connection already exists"),
|
||||
"warning",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setEdges((eds) => [...eds, result.edge]);
|
||||
if (options.selectCreatedEdge) {
|
||||
setSelectedEdgeId(result.edge.id);
|
||||
setSelectedNodeId(null);
|
||||
setInspectorCollapsed(false);
|
||||
}
|
||||
},
|
||||
[edges, nodes, setEdges, addToast, t],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
createConnectionEdge(connection);
|
||||
},
|
||||
[createConnectionEdge],
|
||||
);
|
||||
|
||||
const onCreateSimpleConnection = useCallback(
|
||||
(source: string, target: string) => {
|
||||
createConnectionEdge({ source, target, sourceHandle: null, targetHandle: null }, { selectCreatedEdge: true });
|
||||
},
|
||||
[createConnectionEdge],
|
||||
);
|
||||
|
||||
// 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(
|
||||
@@ -1974,10 +1998,40 @@ function InnerEditor({
|
||||
}),
|
||||
[models, agents, skills],
|
||||
);
|
||||
const mobileGraphRows = useMemo(
|
||||
() => buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t),
|
||||
[nodesForRender, edges, columns, catalogs, t],
|
||||
);
|
||||
const mobileConnectionTargetsBySource = useMemo(() => {
|
||||
const targetNodes = nodesForRender
|
||||
.filter((node) => !isColumnBandNode(node.id) && node.data.kind !== "start")
|
||||
.map((node): MobileWorkflowConnectionTarget => ({
|
||||
id: node.id,
|
||||
label: node.data.label || node.id,
|
||||
kind: node.data.kind,
|
||||
}));
|
||||
|
||||
const targetsBySource = new Map<string, MobileWorkflowConnectionTarget[]>();
|
||||
for (const source of nodesForRender) {
|
||||
if (
|
||||
isColumnBandNode(source.id)
|
||||
|| source.data.kind === "start"
|
||||
|| source.data.kind === "end"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const targets = targetNodes.filter((target) => target.id !== source.id);
|
||||
if (targets.length > 0) targetsBySource.set(source.id, targets);
|
||||
}
|
||||
return targetsBySource;
|
||||
}, [nodesForRender]);
|
||||
|
||||
const mobileGraphRows = useMemo(() => {
|
||||
const attachConnectionTargets = (rows: ReturnType<typeof buildMobileWorkflowGraph>): ReturnType<typeof buildMobileWorkflowGraph> =>
|
||||
rows.map((row) => ({
|
||||
...row,
|
||||
connectionTargets: isBuiltin ? [] : mobileConnectionTargetsBySource.get(row.id) ?? [],
|
||||
children: attachConnectionTargets(row.children),
|
||||
}));
|
||||
|
||||
return attachConnectionTargets(buildMobileWorkflowGraph(nodesForRender, edges, columns, catalogs, t));
|
||||
}, [nodesForRender, edges, columns, catalogs, t, isBuiltin, mobileConnectionTargetsBySource]);
|
||||
|
||||
const currentExecutor = (selectedNode?.data.config?.executor as ExecutorKind | undefined) ?? "model";
|
||||
|
||||
@@ -2520,6 +2574,7 @@ function InnerEditor({
|
||||
setSelectedEdgeId(id);
|
||||
setSelectedNodeId(null);
|
||||
}}
|
||||
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -290,6 +290,41 @@ function scriptDef(): WorkflowDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
function plainConnectDef(): WorkflowDefinition {
|
||||
return {
|
||||
id: "WF-PLAIN-CONNECT",
|
||||
kind: "workflow",
|
||||
name: "Plain connect",
|
||||
description: "",
|
||||
ir: {
|
||||
version: "v2",
|
||||
name: "Plain connect",
|
||||
columns: [
|
||||
{ id: "triage", name: "Triage", traits: [{ trait: "intake" }] },
|
||||
{ id: "done", name: "Done", traits: [{ trait: "complete" }] },
|
||||
],
|
||||
nodes: [
|
||||
{ id: "start", kind: "start", column: "triage" },
|
||||
{ id: "draft", kind: "step-review", column: "triage", config: { type: "code" } },
|
||||
{ id: "review", kind: "prompt", column: "triage", config: { prompt: "review" } },
|
||||
{ id: "end", kind: "end", column: "done" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "draft", condition: "success" },
|
||||
{ from: "review", to: "end", condition: "success" },
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
start: { x: 0, y: 20 },
|
||||
draft: { x: 120, y: 60 },
|
||||
review: { x: 240, y: 120 },
|
||||
end: { x: 360, y: 240 },
|
||||
},
|
||||
createdAt: "2026-06-03T00:00:00.000Z",
|
||||
updatedAt: "2026-06-03T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("workflow-flow-mapping", () => {
|
||||
it("round-trips IR through flow and back, preserving structure and layout", () => {
|
||||
const original = def();
|
||||
@@ -436,6 +471,85 @@ describe("WorkflowNodeEditor", () => {
|
||||
expect(screen.getByTestId("wf-mobile-add-gate-gate")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("creates a condition-capable edge from the mobile simple graph without the canvas", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
await screen.findByText("Save");
|
||||
const shell = await screen.findByTestId("wf-mobile-shell");
|
||||
expect(within(shell).queryByTestId("rf__wrapper")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mobile-wf-connect-start")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("mobile-wf-connect-end")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(await screen.findByTestId("mobile-wf-connect-lint"));
|
||||
fireEvent.change(screen.getByTestId("mobile-wf-connect-target-lint"), { target: { value: "end" } });
|
||||
|
||||
const inspector = await screen.findByTestId("wf-edge-inspector");
|
||||
expect(within(inspector).getByTestId("wf-edge-condition")).toHaveValue("success");
|
||||
expect(screen.getAllByText("end").length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("creates a verdict-source edge in the desktop compact simple graph", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([plainConnectDef()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
expect(await screen.findByTestId("wf-workflow-name")).toHaveTextContent("Plain connect");
|
||||
fireEvent.click(screen.getByTestId("wf-layout-toggle"));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
|
||||
fireEvent.click(await screen.findByTestId("mobile-wf-connect-draft"));
|
||||
fireEvent.change(screen.getByTestId("mobile-wf-connect-target-draft"), { target: { value: "review" } });
|
||||
|
||||
const inspector = await screen.findByTestId("wf-edge-inspector");
|
||||
expect(within(inspector).queryByTestId("wf-edge-condition")).not.toBeInTheDocument();
|
||||
expect(within(inspector).getByTestId("wf-edge-verdict")).toHaveValue("");
|
||||
});
|
||||
|
||||
it("hides simple-graph connection controls for built-in read-only workflows", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Default coding workflow" }));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
expect(screen.queryByTestId(/mobile-wf-connect-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("rejects cyclic simple-graph connections with a toast", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
const addToast = vi.fn();
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([def()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={addToast} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "QA" }));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
|
||||
fireEvent.click(await screen.findByTestId("mobile-wf-connect-merge"));
|
||||
fireEvent.change(screen.getByTestId("mobile-wf-connect-target-merge"), { target: { value: "lint" } });
|
||||
await waitFor(() => expect(addToast).toHaveBeenCalledWith(
|
||||
"That connection would create a cycle — only rework edges inside a for-each template may loop back",
|
||||
"warning",
|
||||
));
|
||||
expect(screen.queryByTestId("wf-edge-inspector")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers connection controls for editable foreach template children in the simple graph", async () => {
|
||||
mockWorkflowEditorViewport("mobile");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([stepwiseDef()]);
|
||||
|
||||
render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Stepwise" }));
|
||||
await screen.findByTestId("wf-mobile-shell");
|
||||
expect(await screen.findByTestId(`mobile-wf-connect-${foreachChildFlowId("loop", "exec")}`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces built-in simple-editor actions at desktop width", async () => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
|
||||
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface MobileWorkflowEdgeSummary {
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
export interface MobileWorkflowConnectionTarget {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: WorkflowFlowNodeData["kind"];
|
||||
}
|
||||
|
||||
export interface MobileWorkflowNodeSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -27,6 +33,7 @@ export interface MobileWorkflowNodeSummary {
|
||||
parentId?: string;
|
||||
templateLocalId?: string;
|
||||
outgoing: MobileWorkflowEdgeSummary[];
|
||||
connectionTargets?: MobileWorkflowConnectionTarget[];
|
||||
children: MobileWorkflowNodeSummary[];
|
||||
}
|
||||
|
||||
|
||||
@@ -6905,6 +6905,10 @@
|
||||
"gateBlocks": "Gate (blocks)",
|
||||
"gateMode": "Gate mode",
|
||||
"insertTemplate": "Insert template {{name}}",
|
||||
"mobileConnect": "Connect",
|
||||
"mobileConnectTarget": "Target node",
|
||||
"mobileConnectChooseTarget": "Choose a target…",
|
||||
"duplicateBlocked": "That connection already exists",
|
||||
"interpreterOnly": "This workflow branches, so it runs on the graph interpreter — it can't compile to the linear step engine, but it will still run.",
|
||||
"joinAll": "All branches",
|
||||
"joinAny": "Any branch",
|
||||
|
||||
@@ -6792,6 +6792,10 @@
|
||||
"summaryReviewType": "Revisión {{type}}",
|
||||
"trivialGraphHint": "Este flujo de trabajo solo ejecuta inicio → fin. Añade pasos desde la paleta superior para construirlo.",
|
||||
"insertTemplate": "Insertar plantilla {{name}}",
|
||||
"mobileConnect": "Conectar",
|
||||
"mobileConnectTarget": "Nodo de destino",
|
||||
"mobileConnectChooseTarget": "Elige un destino…",
|
||||
"duplicateBlocked": "Esa conexión ya existe",
|
||||
"templateFilterLabel": "Filtrar plantillas",
|
||||
"templateFilterPlaceholder": "Filtrar plantillas",
|
||||
"templateSeamConflict": "Este fragmento duplica la unión \"{{seam}}\" que ya está en el lienzo, por lo que no se puede insertar.",
|
||||
|
||||
@@ -6792,6 +6792,10 @@
|
||||
"summaryReviewType": "Examen {{type}}",
|
||||
"trivialGraphHint": "Ce workflow ne fait qu'exécuter début → fin. Ajoutez des étapes depuis la palette ci-dessus pour le développer.",
|
||||
"insertTemplate": "Insérer le modèle {{name}}",
|
||||
"mobileConnect": "Connecter",
|
||||
"mobileConnectTarget": "Nœud cible",
|
||||
"mobileConnectChooseTarget": "Choisissez une cible…",
|
||||
"duplicateBlocked": "Cette connexion existe déjà",
|
||||
"templateFilterLabel": "Filtrer les modèles",
|
||||
"templateFilterPlaceholder": "Filtrer les modèles",
|
||||
"templateSeamConflict": "Ce fragment duplique la jointure « {{seam}} » déjà présente sur le canevas, il ne peut donc pas être inséré.",
|
||||
|
||||
@@ -6792,6 +6792,10 @@
|
||||
"summaryReviewType": "{{type}} 검토",
|
||||
"trivialGraphHint": "이 워크플로는 시작 → 끝만 실행합니다. 위 팔레트에서 단계를 추가하여 구성하세요.",
|
||||
"insertTemplate": "{{name}} 템플릿 삽입",
|
||||
"mobileConnect": "연결",
|
||||
"mobileConnectTarget": "대상 노드",
|
||||
"mobileConnectChooseTarget": "대상을 선택하세요…",
|
||||
"duplicateBlocked": "해당 연결이 이미 있습니다",
|
||||
"templateFilterLabel": "템플릿 필터",
|
||||
"templateFilterPlaceholder": "템플릿 필터",
|
||||
"templateSeamConflict": "이 조각은 이미 캔버스에 있는 \"{{seam}}\" 이음새와 중복되므로 삽입할 수 없습니다.",
|
||||
|
||||
@@ -6792,6 +6792,10 @@
|
||||
"summaryReviewType": "{{type}} 审查",
|
||||
"trivialGraphHint": "此工作流仅运行开始→结束。从上方面板添加步骤以构建工作流。",
|
||||
"insertTemplate": "插入模板 {{name}}",
|
||||
"mobileConnect": "连接",
|
||||
"mobileConnectTarget": "目标节点",
|
||||
"mobileConnectChooseTarget": "选择目标…",
|
||||
"duplicateBlocked": "该连接已存在",
|
||||
"templateFilterLabel": "筛选模板",
|
||||
"templateFilterPlaceholder": "筛选模板",
|
||||
"templateSeamConflict": "此片段与画布上已存在的“{{seam}}”接缝重复,无法插入。",
|
||||
|
||||
@@ -6792,6 +6792,10 @@
|
||||
"summaryReviewType": "{{type}} 審查",
|
||||
"trivialGraphHint": "這個工作流程只會執行 start → end。請從上方的選盤新增步驟來建構它。",
|
||||
"insertTemplate": "插入範本 {{name}}",
|
||||
"mobileConnect": "連接",
|
||||
"mobileConnectTarget": "目標節點",
|
||||
"mobileConnectChooseTarget": "選擇目標…",
|
||||
"duplicateBlocked": "該連接已存在",
|
||||
"templateFilterLabel": "篩選範本",
|
||||
"templateFilterPlaceholder": "篩選範本",
|
||||
"templateSeamConflict": "這個片段與畫布上已有的「{{seam}}」接縫重複,因此無法插入。",
|
||||
|
||||
Reference in New Issue
Block a user