@revenexx/studio-integrations 0.7.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/CHANGELOG.md +633 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +4 -1
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +8 -2
  6. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
  7. package/dist/runtime/components/Integrations/ExpressionInput.vue +5 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +16 -3
  10. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  11. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +1 -0
  12. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue +2 -0
  14. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  15. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  16. package/dist/runtime/components/Integrations/NodeInspector.vue +135 -10
  17. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  18. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +34 -8
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  21. package/dist/runtime/components/Integrations/NodePalette.vue +12 -8
  22. package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
  23. package/dist/runtime/components/Integrations/RunsTable.vue +2 -2
  24. package/dist/runtime/components/Integrations/StickyNoteView.vue +45 -11
  25. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  26. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  27. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  28. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -3
  29. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +12 -3
  30. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +5 -4
  31. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  32. package/dist/runtime/components/Integrations/WorkflowEditor.vue +79 -12
  33. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  34. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  35. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +13 -3
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
  38. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +95 -29
  39. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
  40. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +11 -10
  41. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  42. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +37 -26
  43. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  44. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -5
  45. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +53 -14
  46. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
  47. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  48. package/dist/runtime/composables/useIntegrationsCredentials.js +2 -2
  49. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
  50. package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
  51. package/dist/runtime/composables/useIntegrationsNodes.js +11 -1
  52. package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
  53. package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
  54. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -14
  55. package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
  56. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +30 -3
  57. package/dist/runtime/composables/useIntegrationsTriggers.js +18 -7
  58. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  59. package/dist/runtime/composables/useIntegrationsWorkflows.js +25 -4
  60. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  61. package/dist/runtime/data/integrationNodeCatalog.d.ts +22 -1
  62. package/dist/runtime/data/integrationNodeCatalog.js +8 -4
  63. package/dist/runtime/data/loopBody.d.ts +129 -0
  64. package/dist/runtime/data/loopBody.js +76 -0
  65. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  66. package/dist/runtime/data/nodeInputData.js +30 -5
  67. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  68. package/dist/runtime/data/triggerGlyphs.js +11 -0
  69. package/dist/runtime/i18n/messages/de.json +62 -33
  70. package/dist/runtime/i18n/messages/en.json +32 -3
  71. package/dist/runtime/pages/integrations/credentials.vue +4 -1
  72. package/dist/runtime/pages/integrations/index.vue +32 -34
  73. package/dist/runtime/pages/integrations/runs.vue +22 -10
  74. package/dist/runtime/pages/integrations/schedules.vue +64 -7
  75. package/dist/runtime/pages/integrations/templates/[slug].vue +13 -5
  76. package/dist/runtime/pages/integrations/workflows/[id].vue +193 -104
  77. package/dist/runtime/types/integrations.d.ts +64 -1
  78. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  79. package/dist/runtime/utils/credentialState.d.ts +6 -1
  80. package/dist/runtime/utils/credentialState.js +1 -1
  81. package/dist/runtime/utils/localize.d.ts +34 -0
  82. package/dist/runtime/utils/localize.js +22 -7
  83. package/dist/runtime/utils/pollBudget.d.ts +1 -1
  84. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  85. package/dist/runtime/utils/servedPaths.js +3 -0
  86. package/dist/runtime/utils/statusStyle.d.ts +52 -0
  87. package/dist/runtime/utils/statusStyle.js +26 -0
  88. package/package.json +1 -1
@@ -49,6 +49,7 @@ import WorkflowEdgeView from "./WorkflowEdgeView.vue";
49
49
  import { layoutWorkflowNodes } from "./workflowLayout";
50
50
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
51
51
  import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
52
+ import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
52
53
  import { portFromHandleId } from "../../data/handleId";
53
54
  import { groupFindingsByNode } from "../../utils/validationFindings";
54
55
  import { reorderBranchSubtrees } from "../../data/branchReorder";
@@ -56,6 +57,7 @@ import { findingsByNodeField } from "../../utils/validationFindings";
56
57
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
57
58
  const props = defineProps({
58
59
  workflow: { type: Object, required: true },
60
+ draft: { type: [Object, null], required: false },
59
61
  runBusy: { type: Boolean, required: false },
60
62
  runWaiting: { type: Boolean, required: false },
61
63
  overlayOpen: { type: Boolean, required: false }
@@ -63,7 +65,7 @@ const props = defineProps({
63
65
  const emit = defineEmits(["change", "run", "toggleActive", "openSettings"]);
64
66
  const t = useIntegrationsT();
65
67
  const { keys: secretKeys } = useIntegrationsSecrets();
66
- const blob = ref(clone(props.workflow.blob));
68
+ const blob = ref(clone(props.draft ?? props.workflow.blob));
67
69
  watch(() => props.workflow.id, () => {
68
70
  blob.value = clone(props.workflow.blob);
69
71
  syncFromBlob();
@@ -88,6 +90,29 @@ watch(() => JSON.stringify(invalidNodeFields.value), () => {
88
90
  if (dragInFlight) return;
89
91
  syncFromBlob();
90
92
  });
93
+ const selectedLoopBody = computed(() => {
94
+ const selected = new Set(flow.getSelectedNodes.value.map((n) => n.id));
95
+ if (!selected.size) return /* @__PURE__ */ new Set();
96
+ const loops = iteratorNodeIds(blob.value.nodes);
97
+ if (!loops.length) return /* @__PURE__ */ new Set();
98
+ const out = /* @__PURE__ */ new Set();
99
+ for (const body of computeLoopBodies(loops, blob.value.edges)) {
100
+ const touched = selected.has(body.iteratorId) || [...body.nodeIds].some((id) => selected.has(id));
101
+ if (!touched) continue;
102
+ out.add(body.iteratorId);
103
+ for (const id of body.nodeIds) out.add(id);
104
+ }
105
+ return out;
106
+ });
107
+ provide("workflow-loop-highlight", selectedLoopBody);
108
+ watch(() => iteratorNodeIds(blob.value.nodes).join("|"), () => {
109
+ if (dragInFlight) return;
110
+ syncFromBlob();
111
+ });
112
+ watch(useIntegrationsUiLocale(), () => {
113
+ if (dragInFlight) return;
114
+ syncFromBlob();
115
+ });
91
116
  const dynamicOutputNodes = computed(() => blob.value.nodes.filter((n) => {
92
117
  const m = findManifest(n.nodeSlug, n.nodeVersion);
93
118
  return (m?.outputs ?? []).some((o) => o.resolveOutputs === true);
@@ -228,6 +253,7 @@ function bornClass(id) {
228
253
  return bornIds.value.has(id) ? "node-pop-in" : void 0;
229
254
  }
230
255
  function syncFromBlob() {
256
+ const loops = nodeLoopData(blob.value.nodes, blob.value.edges);
231
257
  const stickies = [...blob.value.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
232
258
  const collapsedBy = collapsedGroupOf();
233
259
  const groupNodes = (blob.value.groups ?? []).map((g, i) => {
@@ -283,7 +309,8 @@ function syncFromBlob() {
283
309
  data: {
284
310
  ...n,
285
311
  __resolvedOutputs: dynamicOutputs.value[n.id],
286
- __invalidFields: invalidNodeFields.value[n.id]
312
+ __invalidFields: invalidNodeFields.value[n.id],
313
+ __loop: loops.get(n.id)
287
314
  }
288
315
  }))
289
316
  ];
@@ -469,22 +496,49 @@ flow.onEdgeUpdate(({ edge, connection }) => {
469
496
  syncFromBlob();
470
497
  push();
471
498
  });
472
- flow.onEdgesChange((changes) => {
499
+ let pendingEdgeRemovals = [];
500
+ let pendingNodeRemovals = [];
501
+ let removalFlushQueued = false;
502
+ function queueRemovalFlush() {
503
+ if (removalFlushQueued) return;
504
+ removalFlushQueued = true;
505
+ void nextTick(flushRemovals);
506
+ }
507
+ function flushRemovals() {
508
+ removalFlushQueued = false;
509
+ const edges = pendingEdgeRemovals;
510
+ const nodes = pendingNodeRemovals;
511
+ pendingEdgeRemovals = [];
512
+ pendingNodeRemovals = [];
513
+ const goneNodes = new Set(nodes);
473
514
  let mutated = false;
515
+ for (const c of edges) {
516
+ if (goneNodes.has(c.source) || goneNodes.has(c.target)) continue;
517
+ blob.value.edges = blob.value.edges.filter((e) => e.id !== c.id);
518
+ mutated = true;
519
+ }
520
+ if (nodes.length) {
521
+ dropFromBlob(nodes);
522
+ mutated = true;
523
+ }
524
+ if (mutated) {
525
+ syncFromBlob();
526
+ push();
527
+ }
528
+ }
529
+ flow.onEdgesChange((changes) => {
474
530
  for (const c of changes) {
475
531
  if (c.type === "remove") {
476
- blob.value.edges = blob.value.edges.filter((e) => e.id !== c.id);
477
- mutated = true;
532
+ pendingEdgeRemovals.push({ id: c.id, source: c.source, target: c.target });
478
533
  }
479
534
  }
480
- if (mutated) push();
535
+ if (pendingEdgeRemovals.length) queueRemovalFlush();
481
536
  });
482
537
  flow.onNodesChange((changes) => {
483
538
  const removed = changes.filter((c) => c.type === "remove").map((c) => c.id);
484
539
  if (!removed.length) return;
485
- void nextTick(() => {
486
- for (const id of removed) removeBlobNode(id);
487
- });
540
+ pendingNodeRemovals.push(...removed);
541
+ queueRemovalFlush();
488
542
  });
489
543
  function stripFromGroups(drop) {
490
544
  if (!blob.value.groups?.length) return;
@@ -541,7 +595,7 @@ function addSticky(at) {
541
595
  syncFromBlob();
542
596
  push();
543
597
  }
544
- function removeBlobNodes(ids) {
598
+ function dropFromBlob(ids) {
545
599
  if (!ids.length) return;
546
600
  const drop = new Set(ids);
547
601
  stripFromGroups(drop);
@@ -553,6 +607,10 @@ function removeBlobNodes(ids) {
553
607
  if (blob.value.triggerPositions?.[id]) delete blob.value.triggerPositions[id];
554
608
  }
555
609
  if (selectedId.value && drop.has(selectedId.value)) selectedId.value = null;
610
+ }
611
+ function removeBlobNodes(ids) {
612
+ if (!ids.length) return;
613
+ dropFromBlob(ids);
556
614
  syncFromBlob();
557
615
  push();
558
616
  }
@@ -779,14 +837,14 @@ const selectedUpstreamOutputs = computed(() => {
779
837
  return blob.value.edges.filter((e) => e.to.nodeId === id).flatMap((e) => {
780
838
  const source = blob.value.nodes.find((n) => n.id === e.from.nodeId);
781
839
  if (!source) return [];
782
- const portDef = (findManifest(source.nodeSlug, source.nodeVersion)?.outputs ?? []).find((o) => o.name === e.from.output);
840
+ const port = portsForNode(source, findManifest(source.nodeSlug, source.nodeVersion), dynamicOutputs.value[source.id]).find((p) => p.name === e.from.output);
783
841
  return [{
784
842
  nodeId: source.id,
785
843
  // A node's own name is text the user typed — never trimmed, never read
786
844
  // as JSON. The manifest strings below it get the normalising resolver.
787
845
  nodeName: resolveDisplayName(source.name) || source.nodeSlug,
788
846
  output: e.from.output,
789
- fields: portDef?.fields
847
+ fields: port?.fields.map((f) => f.name)
790
848
  }];
791
849
  });
792
850
  });
@@ -1213,6 +1271,14 @@ async function focusPalette() {
1213
1271
  paletteRef.value?.focusSearch();
1214
1272
  }
1215
1273
  const lightboxOpen = computed(() => !!selection.value);
1274
+ watch(lightboxOpen, (open) => {
1275
+ if (!open) renameIntent.value = false;
1276
+ });
1277
+ function onConfigOpenAutoFocus(event) {
1278
+ if (renameIntent.value) return;
1279
+ event.preventDefault();
1280
+ event.target?.focus();
1281
+ }
1216
1282
  const canvasHasKeyboard = computed(() => !lightboxOpen.value && !variablesOpen.value && !props.overlayOpen);
1217
1283
  const singleSelected = computed(() => flow.getSelectedNodes.value.length === 1 ? flow.getSelectedNodes.value[0] : null);
1218
1284
  useCanvasShortcuts(() => ({
@@ -1790,6 +1856,7 @@ const edgeTypes = {
1790
1856
  form as a strip down the left of an empty workbench. -->
1791
1857
  <DialogContent
1792
1858
  :show-close-button="false"
1859
+ @open-auto-focus="onConfigOpenAutoFocus"
1793
1860
  class="flex max-w-none flex-col gap-0 overflow-hidden rounded-lg p-0 sm:max-w-none"
1794
1861
  :class="selectionIsTrigger ? 'h-[min(520px,86vh)] w-[min(600px,94vw)]' : 'h-[min(680px,86vh)] w-[min(1100px,94vw)]'"
1795
1862
  >
@@ -4,6 +4,22 @@ import '@vue-flow/minimap/dist/style.css';
4
4
  import type { Workflow, WorkflowBlob } from '../../types/integrations.js';
5
5
  type __VLS_Props = {
6
6
  workflow: Workflow;
7
+ /**
8
+ * Unsaved work the page is still holding for this workflow, read ONCE, at
9
+ * mount (behaviour specs/workflow-save.md).
10
+ *
11
+ * The editor is mounted per tab, so a trip to the Runs tab and back seeded a
12
+ * fresh working copy from the SAVED blob while the page still held the draft:
13
+ * the canvas showed one graph, the header said "Unsaved changes" about
14
+ * another, and Save wrote the one nobody could see. Seeding from the draft is
15
+ * what makes a move inside the workflow keep the work.
16
+ *
17
+ * Deliberately not watched. The page's draft is a copy of what this editor
18
+ * last emitted, so following it would re-seed the canvas — and drop the
19
+ * selection, the undo stack and any open dialog with it — on the user's own
20
+ * keystrokes.
21
+ */
22
+ draft?: WorkflowBlob | null;
7
23
  /** Busy flag for the control-pane Play button (a run is being triggered). */
8
24
  runBusy?: boolean;
9
25
  /** The run is queued behind a bundle build — busy, but worth saying why. */
@@ -3,6 +3,12 @@ import '@vue-flow/core/dist/theme-default.css';
3
3
  import type { Workflow } from '../../types/integrations.js';
4
4
  type __VLS_Props = {
5
5
  workflow: Workflow;
6
+ /**
7
+ * The editor has unsaved work the page is holding. Nothing here shows it — the
8
+ * canvas behind this tab is not mounted — but a restore is what destroys it, so
9
+ * the question this panel asks has to name it (specs/workflow-save.md).
10
+ */
11
+ draftPending?: boolean;
6
12
  };
7
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
14
  restored: () => any;
@@ -18,9 +18,11 @@ import GroupFrameView from "./GroupFrameView.vue";
18
18
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
19
19
  import NodeDynamicOutputsResolver from "./NodeDynamicOutputsResolver.vue";
20
20
  import { findManifest } from "../../data/integrationNodeCatalog";
21
+ import { iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
21
22
  import { useIntegrationsRevisions } from "../../composables/useIntegrationsRevisions";
22
23
  const props = defineProps({
23
- workflow: { type: Object, required: true }
24
+ workflow: { type: Object, required: true },
25
+ draftPending: { type: Boolean, required: false }
24
26
  });
25
27
  const emit = defineEmits(["restored"]);
26
28
  const revisionsApi = useIntegrationsRevisions();
@@ -141,6 +143,7 @@ function buildPreview(blob) {
141
143
  }
142
144
  return "default";
143
145
  };
146
+ const loops = nodeLoopData(blob.nodes, blob.edges);
144
147
  const stickies = [...blob.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
145
148
  previewNodes.value = [
146
149
  ...previewGroupNodes(blob, nodePos),
@@ -166,7 +169,7 @@ function buildPreview(blob) {
166
169
  type: "workflow",
167
170
  position: n.position ?? { x: 240, y: 80 },
168
171
  draggable: false,
169
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
172
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.get(n.id) }
170
173
  }))
171
174
  ];
172
175
  previewEdges.value = blob.edges.map((e) => {
@@ -225,7 +228,8 @@ async function select(revision) {
225
228
  }
226
229
  }
227
230
  async function restoreRevision(rev) {
228
- if (!confirm(t("integration.history.confirm.restore", { revision: rev.revision }))) return;
231
+ const question = props.draftPending ? t("integration.history.confirm.restoreWithDraft", { revision: rev.revision }) : t("integration.history.confirm.restore", { revision: rev.revision });
232
+ if (!confirm(question)) return;
229
233
  restoring.value = rev.revision;
230
234
  try {
231
235
  await revisionsApi.restore(props.workflow.id, rev.revision);
@@ -243,6 +247,12 @@ async function restoreRevision(rev) {
243
247
  watch(() => props.workflow.revision, () => {
244
248
  if (selected.value === null) buildPreview(props.workflow.blob);
245
249
  });
250
+ watch(() => iteratorNodeIds(previewBlob.value.nodes).join("|"), () => {
251
+ buildPreview(previewBlob.value);
252
+ });
253
+ watch(useIntegrationsUiLocale(), () => {
254
+ buildPreview(previewBlob.value);
255
+ });
246
256
  function formatDateTime(iso) {
247
257
  if (!iso) return "\u2014";
248
258
  try {
@@ -3,6 +3,12 @@ import '@vue-flow/core/dist/theme-default.css';
3
3
  import type { Workflow } from '../../types/integrations.js';
4
4
  type __VLS_Props = {
5
5
  workflow: Workflow;
6
+ /**
7
+ * The editor has unsaved work the page is holding. Nothing here shows it — the
8
+ * canvas behind this tab is not mounted — but a restore is what destroys it, so
9
+ * the question this panel asks has to name it (specs/workflow-save.md).
10
+ */
11
+ draftPending?: boolean;
6
12
  };
7
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
14
  restored: () => any;
@@ -9,6 +9,19 @@ interface NodeData extends WorkflowNode {
9
9
  * injected by the editor the same non-persisting way. Read-only canvases
10
10
  * (run replay, history preview) never pass them, so nothing is marked there. */
11
11
  __invalidFields?: string[];
12
+ /**
13
+ * Where this node stands with respect to an iterator's loop body (PO-277),
14
+ * computed per sync from the blob's edges (`data/loopBody.ts`) and injected
15
+ * like the two above. Unlike them it reaches ALL THREE canvases: what repeats
16
+ * is a property of the workflow rather than of editing it, and a run replay is
17
+ * where the repetition is most real.
18
+ */
19
+ __loop?: {
20
+ /** The iterators whose body this node is in, outermost first, by label. */
21
+ inside?: string[];
22
+ /** Set on an iterator itself: how many nodes hang off its `item` port. */
23
+ bodySize?: number;
24
+ };
12
25
  }
13
26
  type __VLS_Props = {
14
27
  id: string;
@@ -3,8 +3,8 @@ import { computed, inject, nextTick, ref, watch } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import * as Lucide from "lucide-vue-next";
6
- import { Copy, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
- import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
6
+ import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, Repeat, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
+ import { NODE_CATEGORIES, findManifest, isVersionExecutable } from "../../data/integrationNodeCatalog";
8
8
  import { isTerminalManifest } from "../../data/nodeInputData";
9
9
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
10
10
  const props = defineProps({
@@ -20,9 +20,14 @@ const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.v
20
20
  const title = computed(
21
21
  () => resolveDisplayName(props.data.name) || resolveLocalized(manifest.value?.name) || props.data.nodeSlug
22
22
  );
23
- const inputRequired = computed(() => manifest.value?.input?.required !== false);
24
23
  const invalidFields = computed(() => props.data.__invalidFields ?? []);
25
24
  const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, { fields: invalidFields.value.join(", ") }));
25
+ const insideLoops = computed(() => props.data.__loop?.inside ?? []);
26
+ const repeatsLabel = computed(() => t("integration.canvas.node.badge.repeats", insideLoops.value.length, { iterators: insideLoops.value.join(", ") }));
27
+ const bodySize = computed(() => props.data.__loop?.bodySize);
28
+ const bodySizeLabel = computed(() => t("integration.canvas.node.badge.loopBody", bodySize.value ?? 0));
29
+ const loopHighlight = inject("workflow-loop-highlight", null);
30
+ const inSelectedLoop = computed(() => !!loopHighlight?.value.has(props.id));
26
31
  const notes = computed(() => (props.data.notes ?? "").trim());
27
32
  const noteOpen = ref(false);
28
33
  const noteReady = ref(false);
@@ -31,11 +36,15 @@ const notePlacement = ref(Position.Bottom);
31
36
  const noteMaxHeight = ref(0);
32
37
  let noteTimer = null;
33
38
  const notePanelId = computed(() => `node-note-${props.id}`);
39
+ function handleTitle(p) {
40
+ return p.hint ? p.label + " \u2014 " + p.hint : p.label;
41
+ }
34
42
  function portsFromOutputs(list) {
35
43
  const ports = [];
36
44
  for (const o of list) {
37
45
  if (o.name) {
38
- ports.push({ name: o.name, kind: o.kind, label: resolveLocalized(o.label) || o.name });
46
+ const hint = resolveLocalized(o.description);
47
+ ports.push({ name: o.name, kind: o.kind, label: resolveLocalized(o.label) || o.name, ...hint ? { hint } : {} });
39
48
  } else if (o.sourceFromConfig) {
40
49
  const raw = props.data.config?.[o.sourceFromConfig];
41
50
  const values = Array.isArray(raw) ? raw : [];
@@ -160,6 +169,8 @@ function releaseNote() {
160
169
  }, 250);
161
170
  }
162
171
  const nodeActions = inject("workflow-node-actions", null);
172
+ const unbuildableVersion = computed(() => nodeActions !== null && !isVersionExecutable(props.data.nodeSlug, props.data.nodeVersion));
173
+ const unbuildableLabel = computed(() => t("integration.canvas.node.badge.noBuiltCode", { version: props.data.nodeVersion }));
163
174
  const canConfigure = computed(() => typeof nodeActions?.configure === "function");
164
175
  function configure() {
165
176
  nodeActions?.configure?.(props.id);
@@ -226,17 +237,15 @@ function remove() {
226
237
 
227
238
  <!-- Input: ONE logical input, attachable from the left or the top — both
228
239
  dots feed the same `__in` port. Clearly visible, ring + shadow. -->
229
- <template v-if="inputRequired">
230
- <Handle
231
- v-for="side in IN_SIDES"
232
- :id="`__in__${side}`"
233
- :key="`in-${side}`"
234
- type="target"
235
- :position="sideToPosition(side)"
236
- :title="t('integration.canvas.node.handle.input')"
237
- class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-slate-700"
238
- />
239
- </template>
240
+ <Handle
241
+ v-for="side in IN_SIDES"
242
+ :id="`__in__${side}`"
243
+ :key="`in-${side}`"
244
+ type="target"
245
+ :position="sideToPosition(side)"
246
+ :title="t('integration.canvas.node.handle.input')"
247
+ class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-slate-700"
248
+ />
240
249
 
241
250
  <!-- Output handles: right + bottom, distributed per side. Color encodes the
242
251
  port semantics (true/false/error/branch/default), matching the edges. -->
@@ -247,7 +256,7 @@ function remove() {
247
256
  :key="`${p.name}-${side}`"
248
257
  type="source"
249
258
  :position="sideToPosition(side)"
250
- :title="p.label"
259
+ :title="handleTitle(p)"
251
260
  :style="distributedStyle(side, i, outputs.length)"
252
261
  class="!size-3 !rounded-full !border-2 !border-white !shadow-sm transition-all hover:!size-3.5"
253
262
  :class="portTone(p).handle"
@@ -261,23 +270,76 @@ function remove() {
261
270
  @mouseenter="onHoverEnter"
262
271
  @mouseleave="onHoverLeave"
263
272
  :class="[
264
- selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : invalidFields.length ? 'border-amber-500/70 shadow-[0_0_0_2px_rgba(245,158,11,0.22)] hover:shadow-md' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
273
+ selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : invalidFields.length ? 'border-amber-500/70 shadow-[0_0_0_2px_rgba(245,158,11,0.22)] hover:shadow-md' : inSelectedLoop ? 'border-slate-400/80 shadow-[0_0_0_2px_rgba(100,116,139,0.20)]' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
265
274
  showPortLabels ? 'pb-3.5' : ''
266
275
  ]"
267
276
  >
268
- <!-- Incomplete settings at the last save: the count, amber, at the corner
269
- the input handles leave free. Hoverable on purpose — the count alone
270
- doesn't say what is wrong, so the title spells it out and lists the
271
- fields; `nodrag` keeps that hover from starting a card drag. -->
277
+ <!-- The card's marks, in one row at the corner the input handles leave free.
278
+ A row rather than two absolutely-placed badges: the amber one's width
279
+ follows its count, so any fixed offset for a second badge overlaps it as
280
+ soon as the count reaches two digits. Hoverable on purpose — a symbol or
281
+ a bare count at a corner is a riddle, so each carries its own title;
282
+ `nodrag` keeps that hover from starting a card drag. -->
272
283
  <span
273
- v-if="invalidFields.length"
274
- role="img"
275
- :aria-label="invalidLabel"
276
- :title="invalidLabel"
277
- class="nodrag absolute -left-2 -top-2 z-10 flex cursor-help items-center gap-0.5 rounded-full bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
284
+ v-if="invalidFields.length || unbuildableVersion || insideLoops.length || bodySize !== void 0"
285
+ class="nodrag absolute -left-2 -top-2 z-10 flex items-center gap-1"
278
286
  >
279
- <Lucide.TriangleAlert :size="9" :stroke-width="2.5" />
280
- {{ invalidFields.length }}
287
+ <span
288
+ v-if="invalidFields.length"
289
+ role="img"
290
+ :aria-label="invalidLabel"
291
+ :title="invalidLabel"
292
+ class="flex cursor-help items-center gap-0.5 rounded-full bg-amber-500 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
293
+ >
294
+ <!-- `weight="Bold"` for the same reason the Lucide draw carried
295
+ `stroke-width="2.5"`: 9px of white on amber. Solar's default
296
+ BoldDuotone washes out at that size against the pill. -->
297
+ <AlertTriangle :size="9" weight="Bold" />
298
+ {{ invalidFields.length }}
299
+ </span>
300
+ <!-- The pinned version cannot be run. Rose rather than amber, and no
301
+ count: amber on this card means unfinished work of the user's own —
302
+ settings still to fill in — while this is the platform missing a piece,
303
+ which the user cannot fix by typing. Hence the stronger colour despite
304
+ being the one that does NOT block activation. -->
305
+ <span
306
+ v-if="unbuildableVersion"
307
+ role="img"
308
+ :aria-label="unbuildableLabel"
309
+ :title="unbuildableLabel"
310
+ class="flex cursor-help items-center rounded-full bg-rose-600 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
311
+ >
312
+ <AlertTriangle :size="9" weight="Bold" />
313
+ </span>
314
+ <!-- Repetition (PO-277). Slate, not amber or rose: a loop is how the
315
+ workflow is built, not something wrong with it. The glyph is the
316
+ mark; the count next to it is the depth, printed from 2 up because
317
+ "repeats" is the news at one loop and "repeats twice over" is the
318
+ news above it. Both marks carry their sentence as their accessible
319
+ name — a bare arrow at a corner is a riddle. -->
320
+ <span
321
+ v-if="insideLoops.length"
322
+ role="img"
323
+ :aria-label="repeatsLabel"
324
+ :title="repeatsLabel"
325
+ class="flex cursor-help items-center gap-0.5 rounded-full bg-slate-600 px-1.5 py-0.5 text-[9px] font-bold leading-none text-white shadow-sm"
326
+ >
327
+ <Repeat :size="9" weight="Bold" />
328
+ <template v-if="insideLoops.length > 1">{{ insideLoops.length }}</template>
329
+ </span>
330
+ <!-- The loop itself, and how much of the canvas it repeats. Outlined
331
+ rather than filled: it names the same fact from the other end, and two
332
+ solid slate pills side by side on a nested iterator would read as one. -->
333
+ <span
334
+ v-if="bodySize !== void 0"
335
+ role="img"
336
+ :aria-label="bodySizeLabel"
337
+ :title="bodySizeLabel"
338
+ class="flex cursor-help items-center gap-0.5 rounded-full border border-slate-300 bg-white px-1.5 py-0.5 text-[9px] font-bold leading-none text-slate-600 shadow-sm"
339
+ >
340
+ <Repeat :size="9" weight="Bold" />
341
+ {{ bodySize }}
342
+ </span>
281
343
  </span>
282
344
  <!-- Always-visible edit affordance: single click opens the config lightbox.
283
345
  (Double-click on the whole card works too.) Hidden in read-only hosts
@@ -330,7 +392,11 @@ function remove() {
330
392
  v-if="resolvePending"
331
393
  class="pointer-events-none absolute bottom-1 left-1/2 flex -translate-x-1/2 select-none items-center gap-1 whitespace-nowrap text-[9px] font-semibold uppercase tracking-wide text-muted-foreground"
332
394
  >
333
- <Lucide.LoaderCircle :size="9" class="animate-spin" />
395
+ <!-- `weight="Bold"` for the badge's reason one size down: the default
396
+ BoldDuotone draws a filled disc across the whole viewBox, and a disc
397
+ turning looks like a disc standing still. Bold knocks the arrows out
398
+ of the ring, so 9px of it still reads as spinning. -->
399
+ <Loader2 :size="9" weight="Bold" class="animate-spin" />
334
400
  {{ t("integration.canvas.node.port.resolving") }}
335
401
  </span>
336
402
  <template v-else>
@@ -9,6 +9,19 @@ interface NodeData extends WorkflowNode {
9
9
  * injected by the editor the same non-persisting way. Read-only canvases
10
10
  * (run replay, history preview) never pass them, so nothing is marked there. */
11
11
  __invalidFields?: string[];
12
+ /**
13
+ * Where this node stands with respect to an iterator's loop body (PO-277),
14
+ * computed per sync from the blob's edges (`data/loopBody.ts`) and injected
15
+ * like the two above. Unlike them it reaches ALL THREE canvases: what repeats
16
+ * is a property of the workflow rather than of editing it, and a run replay is
17
+ * where the repetition is most real.
18
+ */
19
+ __loop?: {
20
+ /** The iterators whose body this node is in, outermost first, by label. */
21
+ inside?: string[];
22
+ /** Set on an iterator itself: how many nodes hang off its `item` port. */
23
+ bodySize?: number;
24
+ };
12
25
  }
13
26
  type __VLS_Props = {
14
27
  id: string;
@@ -11,9 +11,7 @@ import {
11
11
  AltArrowRight as Next,
12
12
  Play,
13
13
  Pause,
14
- RefreshCircle as Loader2,
15
- ClockCircle as Clock,
16
- LinkRound as Webhook
14
+ RefreshCircle as Loader2
17
15
  } from "#solar-icons";
18
16
  import { Badge, Button } from "@revenexx/studio";
19
17
  import { toast } from "vue-sonner";
@@ -26,8 +24,10 @@ import StickyNoteView from "./StickyNoteView.vue";
26
24
  import GroupFrameView from "./GroupFrameView.vue";
27
25
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
28
26
  import { findManifest } from "../../data/integrationNodeCatalog";
27
+ import { nodeLoopData } from "../../data/loopBody";
28
+ import { triggerGlyph } from "../../data/triggerGlyphs";
29
29
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
30
- import { runStatusLabel, statusClass } from "../../utils/statusStyle";
30
+ import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
31
31
  const props = defineProps({
32
32
  workflow: { type: Object, required: true },
33
33
  initialRunId: { type: [String, null], required: false },
@@ -237,6 +237,7 @@ function replayNodeClass(id, type) {
237
237
  const active = activeStep.value?.node_id === id ? " replay-node-active" : "";
238
238
  return `${tone}${reached ? "" : " replay-unreached"}${active}`;
239
239
  }
240
+ const loops = computed(() => nodeLoopData(props.workflow.blob.nodes, props.workflow.blob.edges));
240
241
  const vfNodes = computed(() => {
241
242
  const blob = props.workflow.blob;
242
243
  const triggerPos = (id, i) => blob.triggerPositions?.[id] ?? { x: -40, y: 60 + i * 140 };
@@ -275,7 +276,7 @@ const vfNodes = computed(() => {
275
276
  position: n.position ?? { x: 240, y: 80 },
276
277
  draggable: false,
277
278
  class: replayNodeClass(n.id, "workflow"),
278
- data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id] }
279
+ data: { ...n, __resolvedOutputs: dynamicOutputs.value[n.id], __loop: loops.value.get(n.id) }
279
280
  }))
280
281
  ];
281
282
  });
@@ -408,7 +409,7 @@ function pretty(v) {
408
409
  }
409
410
  }
410
411
  function triggerIcon(type) {
411
- return type === "schedule" ? Clock : type === "webhook" ? Webhook : Play;
412
+ return triggerGlyph(type);
412
413
  }
413
414
  const activeStepTitle = computed(() => {
414
415
  const s = activeStep.value;
@@ -472,7 +473,7 @@ watch(() => props.initialRunId, (id) => {
472
473
  <div class="absolute top-3 left-3 z-10 flex items-center gap-2 rounded-lg border border-black/[0.08] bg-white/95 px-2.5 py-1.5 text-sm shadow-sm backdrop-blur-sm">
473
474
  <Play :size="13" class="text-muted-foreground" />
474
475
  <span class="font-medium">{{ selectedRunId ? t("integration.replay.canvas.runLabel", { id: selectedRunId }) : t("integration.replay.canvas.pickRun") }}</span>
475
- <Badge v-if="runStatus" variant="outline" class="border text-xs font-medium uppercase" :class="statusClass(runStatus)">
476
+ <Badge v-if="runStatus" variant="outline" class="border text-xs font-medium uppercase" :class="runStatusClass(runStatus)">
476
477
  {{ runStatusLabel(runStatus) }}
477
478
  </Badge>
478
479
  <Badge v-if="stepsSource" variant="outline" class="border text-xs font-medium uppercase text-muted-foreground">
@@ -536,7 +537,7 @@ watch(() => props.initialRunId, (id) => {
536
537
  <template v-else>{{ t("integration.replay.debug.noStepSelected") }}</template>
537
538
  </div>
538
539
  </div>
539
- <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="statusClass(activeStep.status)">
540
+ <Badge v-if="activeStep" variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(activeStep.status)">
540
541
  {{ runStatusLabel(activeStep.status) }}
541
542
  </Badge>
542
543
  </div>
@@ -614,8 +615,8 @@ watch(() => props.initialRunId, (id) => {
614
615
  <component :is="triggerIcon(r.triggerType)" :size="12" class="shrink-0 text-muted-foreground" />
615
616
  <span class="truncate">#{{ r.id }}</span>
616
617
  </span>
617
- <Badge variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="statusClass(r.status)">
618
- {{ r.status }}
618
+ <Badge variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(r.status)">
619
+ {{ runStatusLabel(r.status) }}
619
620
  </Badge>
620
621
  </div>
621
622
  <div class="mt-0.5 flex items-center justify-between gap-2 text-sm text-muted-foreground">
@@ -4,33 +4,32 @@ export interface ExecutionModeOption {
4
4
  label: string;
5
5
  hint: string;
6
6
  }
7
+ /** What this dialog edits — the values as they stand, saved or not. */
8
+ export interface WorkflowSettingsValues {
9
+ name: string;
10
+ description: string;
11
+ tags: string[];
12
+ icon: WorkflowIcon | null;
13
+ executionMode: string;
14
+ }
7
15
  type __VLS_Props = {
8
16
  open: boolean;
9
17
  workflow: Workflow;
10
18
  modes: ExecutionModeOption[];
11
- modeDraft: string;
12
- savingMode?: boolean;
13
- savingMeta?: boolean;
19
+ /**
20
+ * The values to edit: the editor's unsaved ones where it has any, the saved ones
21
+ * otherwise. Reopening the dialog therefore shows what the operator last entered
22
+ * rather than what the platform still holds.
23
+ */
24
+ current: WorkflowSettingsValues;
14
25
  /** Tag suggestions (tags already used across the tenant's workflows). */
15
26
  tagSuggestions?: string[];
16
27
  };
17
28
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
18
29
  "update:open": (args_0: boolean) => any;
19
- "change-mode": (mode: string) => any;
20
- "save-meta": (meta: {
21
- name: string;
22
- description: string;
23
- tags: string[];
24
- icon: WorkflowIcon | null;
25
- }) => any;
30
+ apply: (values: WorkflowSettingsValues) => any;
26
31
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
27
32
  "onUpdate:open"?: ((args_0: boolean) => any) | undefined;
28
- "onChange-mode"?: ((mode: string) => any) | undefined;
29
- "onSave-meta"?: ((meta: {
30
- name: string;
31
- description: string;
32
- tags: string[];
33
- icon: WorkflowIcon | null;
34
- }) => any) | undefined;
33
+ onApply?: ((values: WorkflowSettingsValues) => any) | undefined;
35
34
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
36
35
  export default _default;