From 7797654572ccbd34e1991b8921586333c156d1ae Mon Sep 17 00:00:00 2001 From: flemming-it Date: Mon, 1 Jun 2026 23:11:39 +0200 Subject: [PATCH] fix(editor): per-field ports render from YAML refs when ModuleSpec is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan reported: per-field output ports never appeared even after the hub-side capability-lookup fix. Root cause: the editor only used ModuleSpec when the host returned one. If the operator's hub still ran the pre-fix binary, or hadn't re-installed v3 manifests, or the step's capability was a built-in (system.approval) that ships no manifest at all, the editor would silently collapse to a single output anchor even though the flow YAML clearly references named fields. Robust fix: per-field labels are now a UNION of declared fields (from the manifest, if any) and implicit ones (inferred from the flow graph itself — every $step.field reference an outgoing edge carries adds a field name). Declared fields keep manifest order; implicit ones follow alphabetically. Two helpers (_inputLabelsForStep, _outputLabelsForStep) single-source the merge so the renderer, port-overlay generator, edge router, and hit-tester all see the same list. What this means at run time: - v3 modules with description.en/de → full per-field ports with tooltips, exactly as designed. - v2 modules without descriptions → per-field ports, no tooltips. Was the case already. - v1 modules / hubs without inputs/outputs in ModuleInfoResponse → per-field ports inferred from the YAML references. New behaviour, no regression. - Built-in capabilities (system.approval) → per-field ports from with_-keys and outgoing edges. Works without any hub manifest. Bumps fai_studio_flow_editor to 0.10.1. Signed-off-by: flemming-it --- lib/src/widgets/flow_canvas.dart | 106 ++++++++++++++++++++----------- pubspec.yaml | 2 +- 2 files changed, 70 insertions(+), 38 deletions(-) diff --git a/lib/src/widgets/flow_canvas.dart b/lib/src/widgets/flow_canvas.dart index 1ba537d..cd103f6 100644 --- a/lib/src/widgets/flow_canvas.dart +++ b/lib/src/widgets/flow_canvas.dart @@ -232,6 +232,42 @@ class _FlowCanvasState extends State return _moduleSpecs[cap]; } + /// Merge ModuleSpec-declared input names with whatever the + /// flow YAML's `with_:` block carries that the manifest + /// didn't declare. Declared inputs keep their manifest + /// order; implicit ones land alphabetically after. Same + /// list used by [_stepPositioned] for rendering and by + /// [_buildSegments] / [_hitTestEdge] for port positions. + List _inputLabelsForStep(FlowStep step) { + final spec = _specForStep(step); + final declared = spec?.inputs.map((f) => f.name).toList() ?? const []; + final implicit = + step.with_.keys.where((k) => !declared.contains(k)).toList()..sort(); + return [...declared, ...implicit]; + } + + /// Merge ModuleSpec-declared output names with whatever + /// the flow YAML's edges reference for this step. Declared + /// outputs keep their manifest order; implicit (referenced + /// but undeclared) outputs land alphabetically after. Lets + /// the editor render per-field output anchors even when the + /// hub hasn't shipped a manifest (built-ins like + /// system.approval, or older hub binaries). + List _outputLabelsForStep(FlowStep step) { + final spec = _specForStep(step); + final declared = spec?.outputs.map((f) => f.name).toList() ?? const []; + final implicit = {}; + for (final edge in widget.controller.graph.edges) { + if (edge.fromKind == EdgeEndpointKind.step && + edge.fromId == step.id && + edge.fromField.isNotEmpty && + !declared.contains(edge.fromField)) { + implicit.add(edge.fromField); + } + } + return [...declared, ...(implicit.toList()..sort())]; + } + void _onControllerChanged() { if (!mounted) return; // Start the flow-animation controller only while a step @@ -669,17 +705,23 @@ class _FlowCanvasState extends State .whereType() .where((v) => _isWiredExpression(v.toString())) .length; - // Per-field input/output ports: prefer the resolved - // ModuleSpec from the hub when present, else fall back - // to the legacy YAML-derived shape (with_ keys on the - // left, single anchor on the right). + // Per-field input/output ports: merge whatever the + // ModuleSpec from the hub declares with whatever the + // flow YAML actually references. The union ensures we + // render ports even when the hub hasn't shipped a + // manifest (built-in capabilities like system.approval), + // when the hub binary is older than the per-field-port + // RPC, or when the operator hasn't re-installed the + // module to pick up its new schema_version 3 manifest. + // + // Declared fields keep their manifest order (alphabetical + // server-side). Implicit fields (those that show up in + // the YAML but aren't declared) are appended alphabetically + // afterwards — so the layout stays stable when a new + // manifest later lands and adds them officially. final spec = _specForStep(step); - final inputLabels = spec != null - ? spec.inputs.map((f) => f.name).toList() - : step.with_.keys.toList(); - final outputLabels = spec != null - ? spec.outputs.map((f) => f.name).toList() - : const []; + final inputLabels = _inputLabelsForStep(step); + final outputLabels = _outputLabelsForStep(step); final tooltips = {}; if (spec != null) { final loc = widget.locale == FlowEditorLocale.de ? 'de' : 'en'; @@ -806,11 +848,10 @@ class _FlowCanvasState extends State (s) => s.id == nodeId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); - final spec = _specForStep(step); - final outputs = spec?.outputs ?? const []; + final labels = _outputLabelsForStep(step); double y; - if (fieldName != null && outputs.isNotEmpty) { - final idx = outputs.indexWhere((f) => f.name == fieldName); + if (fieldName != null && labels.isNotEmpty) { + final idx = labels.indexOf(fieldName); if (idx >= 0) { y = NodeGeometry.outputPortY(idx); } else { @@ -876,15 +917,7 @@ class _FlowCanvasState extends State (s) => s.id == edge.toId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); - // Prefer the resolved ModuleSpec's declared input - // order when present (matches what the renderer - // draws); fall back to the with_ keys for legacy - // / unresolved modules. - final spec = _specForStep(step); - final inputLabels = spec != null - ? spec.inputs.map((f) => f.name).toList() - : step.with_.keys.toList(); - final idx = inputLabels.indexOf(edge.toField); + final idx = _inputLabelsForStep(step).indexOf(edge.toField); if (idx >= 0) { to = _inputPortPosition(edge.toId, idx, layout); toSide = EdgeSide.left; @@ -1033,11 +1066,7 @@ class _FlowCanvasState extends State (s) => s.id == edge.toId, orElse: () => const FlowStep(id: '__missing__', use: ''), ); - final spec = _specForStep(step); - final inputLabels = spec != null - ? spec.inputs.map((f) => f.name).toList() - : step.with_.keys.toList(); - final idx = inputLabels.indexOf(edge.toField); + final idx = _inputLabelsForStep(step).indexOf(edge.toField); if (idx >= 0) to = _inputPortPosition(edge.toId, idx, layout); } else if (edge.toKind == EdgeEndpointKind.outputs) { final outs = graph.outputs.keys.toList(); @@ -1381,12 +1410,15 @@ class _FlowCanvasState extends State ); } // Step input port targets — left edges. Right-click on a - // wired port opens the disconnect menu. + // wired port opens the disconnect menu. We render every + // label the merged-labels helper emits (declared by the + // manifest + implicit from with_) so the dot positions + // match what FlowNode.build draws on the card body. for (final step in graph.steps) { - final keys = step.with_.keys.toList(); - for (var i = 0; i < keys.length; i++) { + final labels = _inputLabelsForStep(step); + for (var i = 0; i < labels.length; i++) { final p = _inputPortPosition(step.id, i, layout); - final field = keys[i]; + final field = labels[i]; final value = step.with_[field]?.toString() ?? ''; final wired = _isWiredExpression(value); yield _portDot( @@ -1678,8 +1710,8 @@ class _FlowCanvasState extends State double bestDist = double.infinity; Offset? best; for (final step in graph.steps) { - final keys = step.with_.keys.toList(); - for (var i = 0; i < keys.length; i++) { + final labels = _inputLabelsForStep(step); + for (var i = 0; i < labels.length; i++) { final p = _inputPortPosition(step.id, i, layout); final d = (draft.cursor - p).distance; if (d < bestDist && d <= maxDist) { @@ -1712,8 +1744,8 @@ class _FlowCanvasState extends State double bestDist = double.infinity; const maxDist = 32.0; for (final step in graph.steps) { - final keys = step.with_.keys.toList(); - for (var i = 0; i < keys.length; i++) { + final labels = _inputLabelsForStep(step); + for (var i = 0; i < labels.length; i++) { final p = _inputPortPosition(step.id, i, layout); final d = (draft.cursor - p).distance; if (d < bestDist && d <= maxDist) { @@ -1721,7 +1753,7 @@ class _FlowCanvasState extends State best = _DropTarget( kind: _DraftTargetKind.step, id: step.id, - field: keys[i], + field: labels[i], ); } } diff --git a/pubspec.yaml b/pubspec.yaml index c0b7be4..1ae2e9b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: fai_studio_flow_editor description: Swappable inline YAML editor for F∆I Studio flows. -version: 0.10.0 +version: 0.10.1 publish_to: 'none' repository: https://git.flemming.ai/fai/studio-flow-editor