@revenexx/studio-integrations 1.1.0 → 1.2.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 (98) hide show
  1. package/CHANGELOG.md +520 -0
  2. package/README.md +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
  7. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
  8. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  9. package/dist/runtime/components/Integrations/ExpressionInput.vue +77 -22
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
  12. package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
  13. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
  14. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
  15. package/dist/runtime/components/Integrations/NodeConfigField.vue +23 -0
  16. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeInspector.vue +25 -32
  18. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  19. package/dist/runtime/components/Integrations/RunActionsMenu.vue +5 -3
  20. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  21. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  22. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  23. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  24. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  25. package/dist/runtime/components/Integrations/RunsTable.vue +143 -20
  26. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  27. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  28. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  29. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  30. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  31. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  32. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  33. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  34. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  35. package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -28
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  38. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  41. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  42. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  43. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  44. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  45. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  47. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  48. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  49. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  50. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  51. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  52. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  53. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  54. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  55. package/dist/runtime/composables/useIntegrationsTemplates.js +2 -5
  56. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  57. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -7
  58. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  59. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  60. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  61. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  62. package/dist/runtime/data/expressionEditor.js +11 -5
  63. package/dist/runtime/data/handleId.d.ts +61 -1
  64. package/dist/runtime/data/handleId.js +8 -1
  65. package/dist/runtime/data/payloadForm.js +3 -2
  66. package/dist/runtime/i18n/messages/de.json +30 -11
  67. package/dist/runtime/i18n/messages/en.json +27 -8
  68. package/dist/runtime/pages/integrations/audit.vue +7 -6
  69. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  70. package/dist/runtime/pages/integrations/index.vue +70 -12
  71. package/dist/runtime/pages/integrations/runs.vue +116 -7
  72. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  73. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  74. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  75. package/dist/runtime/pages/integrations/workflows/[id].vue +177 -28
  76. package/dist/runtime/types/integrations.d.ts +5 -0
  77. package/dist/runtime/utils/apiError.d.ts +21 -1
  78. package/dist/runtime/utils/apiError.js +1 -1
  79. package/dist/runtime/utils/clipboard.d.ts +33 -0
  80. package/dist/runtime/utils/clipboard.js +11 -0
  81. package/dist/runtime/utils/deepClone.d.ts +41 -0
  82. package/dist/runtime/utils/deepClone.js +3 -0
  83. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  84. package/dist/runtime/utils/formatDuration.js +13 -0
  85. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  86. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  87. package/dist/runtime/utils/notify.d.ts +1 -1
  88. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  89. package/dist/runtime/utils/runEnvelope.js +12 -0
  90. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  91. package/dist/runtime/utils/runFeedQuery.js +66 -0
  92. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  93. package/dist/runtime/utils/runOutputs.js +10 -0
  94. package/dist/runtime/utils/statusStyle.d.ts +24 -0
  95. package/dist/runtime/utils/statusStyle.js +1 -0
  96. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  97. package/dist/runtime/utils/validationFindings.js +18 -3
  98. package/package.json +1 -1
@@ -50,7 +50,10 @@ import { layoutWorkflowNodes } from "./workflowLayout";
50
50
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
51
51
  import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
52
52
  import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
53
- import { portFromHandleId } from "../../data/handleId";
53
+ import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
54
+ import { OPPOSITE_SIDE } from "./canvasSides";
55
+ import { deepClone } from "../../utils/deepClone";
56
+ import { copyToClipboard } from "../../utils/clipboard";
54
57
  import { groupFindingsByNode } from "../../utils/validationFindings";
55
58
  import { reorderBranchSubtrees } from "../../data/branchReorder";
56
59
  import { findingsByNodeField } from "../../utils/validationFindings";
@@ -65,14 +68,11 @@ const props = defineProps({
65
68
  const emit = defineEmits(["change", "run", "toggleActive", "openSettings"]);
66
69
  const t = useIntegrationsT();
67
70
  const { keys: secretKeys } = useIntegrationsSecrets();
68
- const blob = ref(clone(props.draft ?? props.workflow.blob));
71
+ const blob = ref(deepClone(props.draft ?? props.workflow.blob));
69
72
  watch(() => props.workflow.id, () => {
70
- blob.value = clone(props.workflow.blob);
73
+ blob.value = deepClone(props.workflow.blob);
71
74
  syncFromBlob();
72
75
  });
73
- function clone(v) {
74
- return JSON.parse(JSON.stringify(v));
75
- }
76
76
  const vfNodes = ref([]);
77
77
  const vfEdges = ref([]);
78
78
  const flow = useVueFlow();
@@ -80,7 +80,7 @@ const invalidNodeMessages = computed(() => findingsByNodeField(props.workflow.wa
80
80
  const dynamicOutputs = ref({});
81
81
  const invalidNodeFields = computed(() => {
82
82
  const out = {};
83
- for (const g of groupFindingsByNode(props.workflow.warnings ?? [], props.workflow.blob.nodes, () => "")) {
83
+ for (const g of groupFindingsByNode(props.workflow.warnings ?? [], props.workflow.blob.nodes, () => "", (_n, key) => key)) {
84
84
  if (g.nodeId) out[g.nodeId] = g.fields;
85
85
  }
86
86
  return out;
@@ -160,9 +160,6 @@ function nodePosition(id) {
160
160
  function pickSourceSide(_sourceId, _targetId) {
161
161
  return "bottom";
162
162
  }
163
- function oppositeSide(s) {
164
- return s === "top" ? "bottom" : s === "right" ? "left" : s === "bottom" ? "top" : "right";
165
- }
166
163
  function stickyArea(s) {
167
164
  return (s.size?.width ?? 240) * (s.size?.height ?? 160);
168
165
  }
@@ -252,6 +249,7 @@ function markBorn(...ids) {
252
249
  function bornClass(id) {
253
250
  return bornIds.value.has(id) ? "node-pop-in" : void 0;
254
251
  }
252
+ const NODE_FALLBACK_POSITION = { x: 240, y: 80 };
255
253
  function syncFromBlob() {
256
254
  const loops = nodeLoopData(blob.value.nodes, blob.value.edges);
257
255
  const stickies = [...blob.value.stickies ?? []].sort((a, b) => stickyArea(b) - stickyArea(a));
@@ -304,7 +302,7 @@ function syncFromBlob() {
304
302
  ...blob.value.nodes.filter((n) => !collapsedBy.has(n.id)).map((n) => ({
305
303
  id: n.id,
306
304
  type: "workflow",
307
- position: n.position ?? { x: 240, y: 80 },
305
+ position: n.position ?? NODE_FALLBACK_POSITION,
308
306
  class: bornClass(n.id),
309
307
  data: {
310
308
  ...n,
@@ -330,8 +328,8 @@ function syncFromBlob() {
330
328
  ...vf,
331
329
  source,
332
330
  target,
333
- sourceHandle: srcGroup ? "default__bottom" : vf.sourceHandle,
334
- targetHandle: tgtGroup ? "__in__top" : vf.targetHandle
331
+ sourceHandle: srcGroup ? outputHandleId("default", "bottom") : vf.sourceHandle,
332
+ targetHandle: tgtGroup ? inputHandleId("top") : vf.targetHandle
335
333
  });
336
334
  }
337
335
  const bySource = /* @__PURE__ */ new Map();
@@ -355,15 +353,15 @@ function makeVfEdge(id, sourceId, targetId, output) {
355
353
  const manifest = wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
356
354
  const isBranchPort = (manifest?.outputs ?? []).some((o) => o.sourceFromConfig) || ["true", "false"].includes(output);
357
355
  const srcSide = pickSourceSide(sourceId, targetId);
358
- const tgtSide = oppositeSide(srcSide);
356
+ const tgtSide = OPPOSITE_SIDE[srcSide];
359
357
  const order = orderedOutputPorts(sourceId);
360
358
  const portIndex = order.indexOf(output);
361
359
  return {
362
360
  id,
363
361
  source: sourceId,
364
362
  target: targetId,
365
- sourceHandle: `${output}__${srcSide}`,
366
- targetHandle: `__in__${tgtSide}`,
363
+ sourceHandle: outputHandleId(output, srcSide),
364
+ targetHandle: inputHandleId(tgtSide),
367
365
  type: "flow",
368
366
  data: {
369
367
  kind,
@@ -561,7 +559,7 @@ function removeBlobNode(id) {
561
559
  push();
562
560
  }
563
561
  function push() {
564
- emit("change", clone(blob.value));
562
+ emit("change", deepClone(blob.value));
565
563
  }
566
564
  const STICKY_DEFAULT_SIZE = { width: 240, height: 160 };
567
565
  function updateSticky(id, patch) {
@@ -622,7 +620,7 @@ function duplicateBlobNodes(ids) {
622
620
  src.forEach((n, i) => idMap.set(n.id, `n-${stamp}${i}`));
623
621
  const offset = 40;
624
622
  const copies = src.map((n) => ({
625
- ...clone(n),
623
+ ...deepClone(n),
626
624
  id: idMap.get(n.id),
627
625
  position: { x: (n.position?.x ?? 0) + offset, y: (n.position?.y ?? 0) + offset }
628
626
  }));
@@ -661,12 +659,10 @@ async function copyNodes(ids) {
661
659
  if (!nodes.length) return;
662
660
  const inner = new Set(nodes.map((n) => n.id));
663
661
  const edges = blob.value.edges.filter((e) => inner.has(e.from.nodeId) && inner.has(e.to.nodeId));
664
- try {
665
- await navigator.clipboard.writeText(JSON.stringify({ kind: CLIP_KIND, version: 1, nodes, edges }, null, 2));
666
- toast.success(t("integration.editor.clipboard.toast.copied", nodes.length));
667
- } catch {
668
- toast.error(t("integration.editor.clipboard.error.write"));
669
- }
662
+ await copyToClipboard(JSON.stringify({ kind: CLIP_KIND, version: 1, nodes, edges }, null, 2), {
663
+ copied: t("integration.editor.clipboard.toast.copied", nodes.length),
664
+ failed: t("integration.editor.clipboard.error.write")
665
+ });
670
666
  }
671
667
  async function pasteNodes(at) {
672
668
  let text = "";
@@ -695,7 +691,7 @@ async function pasteNodes(at) {
695
691
  const dx = at ? Math.round(at.x - minX) : 40;
696
692
  const dy = at ? Math.round(at.y - minY) : 40;
697
693
  blob.value.nodes.push(...payload.nodes.map((n) => ({
698
- ...clone(n),
694
+ ...deepClone(n),
699
695
  id: idMap.get(n.id),
700
696
  position: { x: (n.position?.x ?? 0) + dx, y: (n.position?.y ?? 0) + dy }
701
697
  })));
@@ -906,10 +902,10 @@ function groupAtPoint(pos) {
906
902
  function addGroupFrame() {
907
903
  const rect = flow.vueFlowRef.value?.getBoundingClientRect();
908
904
  const center = rect ? flow.screenToFlowCoordinate({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) : { x: 120, y: 120 };
909
- addFromPayload({ kind: "group" }, {
905
+ addFromPayload({ kind: "group" }, freeSpotNear({
910
906
  x: center.x - EMPTY_GROUP_SIZE.width / 2,
911
907
  y: center.y - EMPTY_GROUP_SIZE.height / 2
912
- });
908
+ }));
913
909
  }
914
910
  function adoptIntoGroupAt(id, pos) {
915
911
  const g = groupAtPoint(pos);
@@ -970,6 +966,21 @@ function onDrop(ev) {
970
966
  const payload = JSON.parse(raw);
971
967
  addFromPayload(payload, flow.screenToFlowCoordinate({ x: ev.clientX, y: ev.clientY }));
972
968
  }
969
+ const CASCADE_STEP = 2 * GRID;
970
+ function freeSpotNear(at) {
971
+ const taken = [
972
+ ...vfNodes.value.map((n) => n.position),
973
+ ...blob.value.nodes.map((n) => n.position ?? NODE_FALLBACK_POSITION),
974
+ ...Object.values(blob.value.triggerPositions ?? {})
975
+ ];
976
+ const occupied = (p) => taken.some((q) => Math.abs(q.x - p.x) < GRID && Math.abs(q.y - p.y) < GRID);
977
+ const spot = { x: snap(at.x), y: snap(at.y) };
978
+ for (let i = 0; i <= taken.length && occupied(spot); i++) {
979
+ spot.x += CASCADE_STEP;
980
+ spot.y += CASCADE_STEP;
981
+ }
982
+ return spot;
983
+ }
973
984
  function onPaletteInsert(payload) {
974
985
  if (pendingConnect.value && payload.kind === "manifest") {
975
986
  const pc = pendingConnect.value;
@@ -1003,7 +1014,7 @@ function onPaletteInsert(payload) {
1003
1014
  pendingSpliceEdgeId.value = null;
1004
1015
  const rect = flow.vueFlowRef.value?.getBoundingClientRect();
1005
1016
  const center = rect ? flow.screenToFlowCoordinate({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }) : { x: 120, y: 120 };
1006
- addFromPayload(payload, center);
1017
+ addFromPayload(payload, freeSpotNear(center));
1007
1018
  }
1008
1019
  const pendingSpliceEdgeId = ref(null);
1009
1020
  provide("workflow-edge-actions", {
@@ -11,6 +11,7 @@ import { Badge, Button } from "@revenexx/studio";
11
11
  import { toast } from "vue-sonner";
12
12
  import "@vue-flow/core/dist/style.css";
13
13
  import "@vue-flow/core/dist/theme-default.css";
14
+ import LoadingAnnouncement from "./LoadingAnnouncement.vue";
14
15
  import WorkflowNodeView from "./WorkflowNodeView.vue";
15
16
  import WorkflowTriggerView from "./WorkflowTriggerView.vue";
16
17
  import StickyNoteView from "./StickyNoteView.vue";
@@ -18,8 +19,11 @@ import GroupFrameView from "./GroupFrameView.vue";
18
19
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
19
20
  import NodeDynamicOutputsResolver from "./NodeDynamicOutputsResolver.vue";
20
21
  import { findManifest } from "../../data/integrationNodeCatalog";
22
+ import { OPPOSITE_SIDE } from "./canvasSides";
23
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
21
24
  import { iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
22
25
  import { useIntegrationsRevisions } from "../../composables/useIntegrationsRevisions";
26
+ import { errorReason } from "../../utils/apiError";
23
27
  const props = defineProps({
24
28
  workflow: { type: Object, required: true },
25
29
  draftPending: { type: Boolean, required: false }
@@ -118,7 +122,6 @@ function buildPreview(blob) {
118
122
  return { x: 0, y: 0 };
119
123
  };
120
124
  const pickSourceSide = (_sourceId, _targetId) => "bottom";
121
- const opposite = (s) => s === "top" ? "bottom" : s === "right" ? "left" : s === "bottom" ? "top" : "right";
122
125
  const edgeKind = (sourceNodeId, output) => {
123
126
  const wn = blob.nodes.find((n) => n.id === sourceNodeId);
124
127
  if (!wn) return "default";
@@ -182,8 +185,8 @@ function buildPreview(blob) {
182
185
  id: e.id,
183
186
  source: e.from.nodeId,
184
187
  target: e.to.nodeId,
185
- sourceHandle: `${e.from.output}__${srcSide}`,
186
- targetHandle: `__in__${opposite(srcSide)}`,
188
+ sourceHandle: outputHandleId(e.from.output, srcSide),
189
+ targetHandle: inputHandleId(OPPOSITE_SIDE[srcSide]),
187
190
  type: "flow",
188
191
  selectable: false,
189
192
  data: { kind, label: isBranchPort ? e.from.output : void 0 },
@@ -222,7 +225,7 @@ async function select(revision) {
222
225
  buildPreview(detail.blob);
223
226
  }
224
227
  } catch (e) {
225
- toast.error(e?.message ?? t("integration.history.toast.loadRevisionFailed"));
228
+ toast.error(errorReason(e, t("integration.history.toast.loadRevisionFailed")));
226
229
  } finally {
227
230
  detailLoading.value = false;
228
231
  }
@@ -239,7 +242,7 @@ async function restoreRevision(rev) {
239
242
  await loadList(true);
240
243
  void select(null);
241
244
  } catch (e) {
242
- toast.error(e?.message ?? t("integration.history.toast.restoreFailed"));
245
+ toast.error(errorReason(e, t("integration.history.toast.restoreFailed")));
243
246
  } finally {
244
247
  restoring.value = null;
245
248
  }
@@ -328,6 +331,14 @@ onMounted(() => {
328
331
  </p>
329
332
  </div>
330
333
 
334
+ <!-- In the aside's chrome rather than in the scrolling list, so it is on the
335
+ page while the revisions are on their way (PO-251). -->
336
+ <LoadingAnnouncement
337
+ :label="t('integration.history.heading.title')"
338
+ :loading="listLoading && !entries.length"
339
+ :text="t('integration.history.state.loading')"
340
+ />
341
+
331
342
  <div class="flex-1 overflow-y-auto p-2 space-y-1">
332
343
  <!-- Current (live) version -->
333
344
  <button
@@ -385,7 +396,7 @@ onMounted(() => {
385
396
  {{ revisionsApi.error.value }}
386
397
  </p>
387
398
 
388
- <div v-if="listLoading && !entries.length" class="space-y-2 px-2 py-2">
399
+ <div v-if="listLoading && !entries.length" aria-hidden="true" class="space-y-2 px-2 py-2">
389
400
  <div v-for="n in 5" :key="`sk-rev-${n}`" class="flex items-center gap-3 py-1.5">
390
401
  <div class="size-7 shrink-0 rounded-md bg-black/[0.07] animate-pulse" />
391
402
  <div class="min-w-0 flex-1 space-y-1.5">
@@ -5,8 +5,12 @@ import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import * as Lucide from "lucide-vue-next";
6
6
  import { Copy, DangerTriangle as AlertTriangle, RefreshCircle as Loader2, Repeat, TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
7
  import { NODE_CATEGORIES, findManifest, isVersionExecutable } from "../../data/integrationNodeCatalog";
8
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
8
9
  import { isTerminalManifest } from "../../data/nodeInputData";
9
- import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
10
+ import { resolveLocalized } from "../../utils/localize";
11
+ import { SIDE_POSITION, distributedStyle } from "./canvasSides";
12
+ import { fieldDisplayLabel } from "../../utils/validationFindings";
13
+ import { nodeDisplayName } from "../../utils/nodeDisplayName";
10
14
  const props = defineProps({
11
15
  id: { type: String, required: true },
12
16
  data: { type: Object, required: true },
@@ -17,11 +21,11 @@ defineEmits(["updateNodeInternals"]);
17
21
  const t = useIntegrationsT();
18
22
  const manifest = computed(() => findManifest(props.data.nodeSlug, props.data.nodeVersion));
19
23
  const category = computed(() => NODE_CATEGORIES.find((c) => c.key === manifest.value?.category));
20
- const title = computed(
21
- () => resolveDisplayName(props.data.name) || resolveLocalized(manifest.value?.name) || props.data.nodeSlug
22
- );
24
+ const title = computed(() => nodeDisplayName(props.data));
23
25
  const invalidFields = computed(() => props.data.__invalidFields ?? []);
24
- const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, { fields: invalidFields.value.join(", ") }));
26
+ const invalidLabel = computed(() => t("integration.canvas.node.badge.invalidConfig", invalidFields.value.length, {
27
+ fields: invalidFields.value.map((key) => fieldDisplayLabel(props.data, key)).join(", ")
28
+ }));
25
29
  const insideLoops = computed(() => props.data.__loop?.inside ?? []);
26
30
  const repeatsLabel = computed(() => t("integration.canvas.node.badge.repeats", insideLoops.value.length, { iterators: insideLoops.value.join(", ") }));
27
31
  const bodySize = computed(() => props.data.__loop?.bodySize);
@@ -111,14 +115,6 @@ function portTone(p) {
111
115
  const showPortLabels = computed(() => resolvePending.value || outputs.value.length > 1 || outputs.value.some((p) => p.kind !== "default"));
112
116
  const IN_SIDES = ["top"];
113
117
  const OUT_SIDES = ["bottom"];
114
- function sideToPosition(s) {
115
- return s === "top" ? Position.Top : s === "right" ? Position.Right : s === "bottom" ? Position.Bottom : Position.Left;
116
- }
117
- function distributedStyle(side, i, total) {
118
- const pct = `${(i + 1) / (total + 1) * 100}%`;
119
- if (side === "top" || side === "bottom") return { left: pct };
120
- return { top: pct };
121
- }
122
118
  const flow = useVueFlow();
123
119
  const hovered = ref(false);
124
120
  let hoverTimer = null;
@@ -192,22 +188,22 @@ function remove() {
192
188
  <NodeToolbar v-if="nodeActions" :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
193
189
  <button
194
190
  type="button"
195
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-foreground/80 hover:bg-muted/50"
191
+ class="flex size-6 items-center justify-center rounded text-sm text-foreground/80 hover:bg-muted/50"
196
192
  :title="t('integration.canvas.node.action.duplicate')"
197
193
  :aria-label="t('integration.canvas.node.action.duplicateAria')"
198
194
  @click="duplicate"
199
195
  >
200
- <Copy :size="11" />
196
+ <Copy :size="13" />
201
197
  </button>
202
198
  <div class="h-4 w-px bg-black/10" />
203
199
  <button
204
200
  type="button"
205
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
201
+ class="flex size-6 items-center justify-center rounded text-sm text-rose-600 hover:bg-rose-50"
206
202
  :title="t('integration.common.action.delete')"
207
203
  :aria-label="t('integration.canvas.node.action.deleteAria')"
208
204
  @click="remove"
209
205
  >
210
- <TrashBin :size="11" />
206
+ <TrashBin :size="13" />
211
207
  </button>
212
208
  </NodeToolbar>
213
209
 
@@ -239,10 +235,10 @@ function remove() {
239
235
  dots feed the same `__in` port. Clearly visible, ring + shadow. -->
240
236
  <Handle
241
237
  v-for="side in IN_SIDES"
242
- :id="`__in__${side}`"
238
+ :id="inputHandleId(side)"
243
239
  :key="`in-${side}`"
244
240
  type="target"
245
- :position="sideToPosition(side)"
241
+ :position="SIDE_POSITION[side]"
246
242
  :title="t('integration.canvas.node.handle.input')"
247
243
  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
244
  />
@@ -252,10 +248,10 @@ function remove() {
252
248
  <template v-for="side in OUT_SIDES" :key="`side-${side}`">
253
249
  <Handle
254
250
  v-for="(p, i) in outputs"
255
- :id="`${p.name}__${side}`"
251
+ :id="outputHandleId(p.name, side)"
256
252
  :key="`${p.name}-${side}`"
257
253
  type="source"
258
- :position="sideToPosition(side)"
254
+ :position="SIDE_POSITION[side]"
259
255
  :title="handleTitle(p)"
260
256
  :style="distributedStyle(side, i, outputs.length)"
261
257
  class="!size-3 !rounded-full !border-2 !border-white !shadow-sm transition-all hover:!size-3.5"
@@ -24,10 +24,14 @@ import StickyNoteView from "./StickyNoteView.vue";
24
24
  import GroupFrameView from "./GroupFrameView.vue";
25
25
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
26
26
  import { findManifest } from "../../data/integrationNodeCatalog";
27
+ import { OPPOSITE_SIDE } from "./canvasSides";
28
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
27
29
  import { nodeLoopData } from "../../data/loopBody";
28
30
  import { triggerGlyph } from "../../data/triggerGlyphs";
31
+ import { formatDuration } from "../../utils/formatDuration";
29
32
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
30
33
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
34
+ import { errorReason } from "../../utils/apiError";
31
35
  const props = defineProps({
32
36
  workflow: { type: Object, required: true },
33
37
  initialRunId: { type: [String, null], required: false },
@@ -59,7 +63,7 @@ async function loadRuns(reset = false) {
59
63
  runsPage.value = res.page;
60
64
  runsLastPage.value = res.lastPage;
61
65
  } catch (e) {
62
- toast.error(e?.message ?? t("integration.replay.toast.loadRunsFailed"));
66
+ toast.error(errorReason(e, t("integration.replay.toast.loadRunsFailed")));
63
67
  } finally {
64
68
  runsLoading.value = false;
65
69
  }
@@ -101,7 +105,7 @@ async function loadSteps(runId, isPoll = false) {
101
105
  }
102
106
  } catch (e) {
103
107
  if (selectedRunId.value === runId) {
104
- stepsError.value = e?.message ?? t("integration.replay.error.loadStepsFailed");
108
+ stepsError.value = errorReason(e, t("integration.replay.error.loadStepsFailed"));
105
109
  }
106
110
  } finally {
107
111
  if (!isPoll) stepsLoading.value = false;
@@ -293,7 +297,6 @@ const vfEdges = computed(() => {
293
297
  return { x: 0, y: 0 };
294
298
  };
295
299
  const pickSourceSide = (_sourceId, _targetId) => "bottom";
296
- const opposite = (s) => s === "top" ? "bottom" : s === "right" ? "left" : s === "bottom" ? "top" : "right";
297
300
  const edgeKind = (sourceNodeId, output) => {
298
301
  const wn = blob.nodes.find((n) => n.id === sourceNodeId);
299
302
  if (!wn) return "default";
@@ -331,8 +334,8 @@ const vfEdges = computed(() => {
331
334
  id: e.id,
332
335
  source: e.from.nodeId,
333
336
  target: e.to.nodeId,
334
- sourceHandle: `${e.from.output}__${srcSide}`,
335
- targetHandle: `__in__${opposite(srcSide)}`,
337
+ sourceHandle: outputHandleId(e.from.output, srcSide),
338
+ targetHandle: inputHandleId(OPPOSITE_SIDE[srcSide]),
336
339
  type: "flow",
337
340
  selectable: false,
338
341
  animated: traversed && !targetReached,
@@ -395,12 +398,6 @@ function formatTime(iso) {
395
398
  return String(iso);
396
399
  }
397
400
  }
398
- function formatDuration(ms) {
399
- if (ms == null) return "\u2014";
400
- if (ms < 1e3) return `${ms} ms`;
401
- if (ms < 6e4) return `${(ms / 1e3).toFixed(1)} s`;
402
- return `${Math.floor(ms / 6e4)}m ${Math.round(ms % 6e4 / 1e3)}s`;
403
- }
404
401
  function pretty(v) {
405
402
  try {
406
403
  return JSON.stringify(v, null, 2);
@@ -3,6 +3,8 @@ import { computed, ref } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
+ import { SIDE_POSITION } from "./canvasSides";
7
+ import { outputHandleId } from "../../data/handleId";
6
8
  import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
7
9
  import { resolveDisplayName } from "../../utils/localize";
8
10
  import { triggerTypeLabel } from "../../data/integrationNodeCatalog";
@@ -45,21 +47,18 @@ function remove() {
45
47
  flow.removeNodes([props.id]);
46
48
  }
47
49
  const OUT_SIDES = ["bottom"];
48
- function sideToPosition(s) {
49
- return s === "top" ? Position.Top : s === "right" ? Position.Right : s === "bottom" ? Position.Bottom : Position.Left;
50
- }
51
50
  </script>
52
51
 
53
52
  <template>
54
53
  <NodeToolbar :is-visible="hovered || selected" :position="Position.Top" :offset="8" class="flex items-center gap-1 rounded-md border border-black/[0.08] bg-white px-1 py-1 shadow-md" @mouseenter="onHoverEnter" @mouseleave="onHoverLeave">
55
54
  <button
56
55
  type="button"
57
- class="flex h-6 items-center gap-1 rounded px-1.5 text-sm text-rose-600 hover:bg-rose-50"
56
+ class="flex size-6 items-center justify-center rounded text-sm text-rose-600 hover:bg-rose-50"
58
57
  :title="t('integration.common.action.delete')"
59
58
  :aria-label="t('integration.canvas.trigger.action.deleteAria')"
60
59
  @click="remove"
61
60
  >
62
- <TrashBin :size="11" />
61
+ <TrashBin :size="13" />
63
62
  </button>
64
63
  </NodeToolbar>
65
64
 
@@ -97,10 +96,10 @@ function sideToPosition(s) {
97
96
  "default"; the side suffix is stripped on persist. -->
98
97
  <Handle
99
98
  v-for="side in OUT_SIDES"
100
- :id="`default__${side}`"
99
+ :id="outputHandleId('default', side)"
101
100
  :key="side"
102
101
  type="source"
103
- :position="sideToPosition(side)"
102
+ :position="SIDE_POSITION[side]"
104
103
  class="!size-3 !rounded-full !border-2 !border-white !bg-emerald-500 !shadow-sm transition-all hover:!size-3.5 hover:!bg-emerald-600"
105
104
  />
106
105
  </template>
@@ -19,6 +19,8 @@ import {
19
19
  SelectValue
20
20
  } from "@revenexx/studio";
21
21
  import { toast } from "vue-sonner";
22
+ import { deepClone } from "../../utils/deepClone";
23
+ import { copyToClipboard } from "../../utils/clipboard";
22
24
  import { languageKeyRead, languageKeyToWrite, resolveDisplayName } from "../../utils/localize";
23
25
  const props = defineProps({
24
26
  open: { type: Boolean, required: true },
@@ -34,7 +36,7 @@ const jsonErrors = reactive({});
34
36
  const descDrafts = reactive({});
35
37
  watch(() => props.open, (o) => {
36
38
  if (!o) return;
37
- working.value = JSON.parse(JSON.stringify(props.variables));
39
+ working.value = deepClone(props.variables);
38
40
  for (const k of Object.keys(jsonDrafts)) delete jsonDrafts[k];
39
41
  for (const k of Object.keys(jsonErrors)) delete jsonErrors[k];
40
42
  for (const k of Object.keys(descDrafts)) delete descDrafts[k];
@@ -146,12 +148,10 @@ function addVariable() {
146
148
  newKey.value = "";
147
149
  }
148
150
  async function copyUsage(key) {
149
- try {
150
- await navigator.clipboard.writeText(`\${{ vars.${key} }}`);
151
- toast.success(t("integration.workflows.variables.toast.copied"));
152
- } catch {
153
- toast.error(t("integration.workflows.variables.toast.copyFailed"));
154
- }
151
+ await copyToClipboard(`\${{ vars.${key} }}`, {
152
+ copied: t("integration.workflows.variables.toast.copied"),
153
+ failed: t("integration.workflows.variables.toast.copyFailed")
154
+ });
155
155
  }
156
156
  </script>
157
157
 
@@ -1,17 +1,12 @@
1
1
  <script setup>
2
- import { RefreshCircle as Loader2, MenuDots as MoreHorizontal } from "#solar-icons";
2
+ import { MenuDots as MoreHorizontal } from "#solar-icons";
3
3
  import { toast } from "vue-sonner";
4
- import { apiValidationIssues } from "../../utils/apiError";
4
+ import { apiValidationIssues, errorReason } from "../../utils/apiError";
5
+ import WorkflowDeleteDialog from "./WorkflowDeleteDialog.vue";
5
6
  import WorkflowDuplicateDialog from "./WorkflowDuplicateDialog.vue";
6
7
  import WorkflowIconView from "./WorkflowIconView.vue";
7
8
  import { Layers } from "#solar-icons";
8
9
  import {
9
- AlertDialog,
10
- AlertDialogContent,
11
- AlertDialogDescription,
12
- AlertDialogFooter,
13
- AlertDialogHeader,
14
- AlertDialogTitle,
15
10
  Badge,
16
11
  Button,
17
12
  DropdownMenu,
@@ -27,7 +22,7 @@ defineProps({
27
22
  });
28
23
  const t = useIntegrationsT();
29
24
  const router = useRouter();
30
- const { remove, toggleActive } = useIntegrationsWorkflows();
25
+ const { toggleActive } = useIntegrationsWorkflows();
31
26
  const duplicateTarget = ref(null);
32
27
  const duplicateOpen = ref(false);
33
28
  function askDuplicate(wf) {
@@ -77,21 +72,11 @@ function runBadge(status) {
77
72
  return { label: t("integration.workflows.table.status.run.cancelled"), class: "bg-slate-100 text-slate-700 border-slate-200" };
78
73
  }
79
74
  }
80
- const pendingDelete = ref(null);
81
- const deleting = ref(false);
75
+ const deleteOpen = ref(false);
76
+ const deleteTarget = ref(null);
82
77
  function askDelete(wf) {
83
- pendingDelete.value = wf;
84
- }
85
- async function confirmDelete() {
86
- const wf = pendingDelete.value;
87
- if (!wf) return;
88
- deleting.value = true;
89
- try {
90
- await remove(wf.id);
91
- pendingDelete.value = null;
92
- } finally {
93
- deleting.value = false;
94
- }
78
+ deleteTarget.value = { id: wf.id, name: wf.name };
79
+ deleteOpen.value = true;
95
80
  }
96
81
  async function onToggleActive(wf) {
97
82
  try {
@@ -102,7 +87,7 @@ async function onToggleActive(wf) {
102
87
  toast.error(t("integration.workflows.table.toast.activateBlocked", issues.length, { name: wf.name }));
103
88
  return;
104
89
  }
105
- toast.error(e?.message ?? t("integration.workflows.table.toast.statusChangeFailed"));
90
+ toast.error(errorReason(e, t("integration.workflows.table.toast.statusChangeFailed")));
106
91
  }
107
92
  }
108
93
  function lifecycleOf(wf) {
@@ -405,30 +390,7 @@ const columns = computed(() => [
405
390
  </template>
406
391
  </DataTable>
407
392
 
408
- <AlertDialog :open="!!pendingDelete" @update:open="(v) => {
409
- if (!v) pendingDelete = null;
410
- }">
411
- <AlertDialogContent>
412
- <AlertDialogHeader>
413
- <AlertDialogTitle>{{ t("integration.workflows.table.deleteDialog.title") }}</AlertDialogTitle>
414
- <AlertDialogDescription>
415
- <span v-if="pendingDelete">
416
- <span class="font-medium text-foreground">{{ pendingDelete.name }}</span>
417
- {{ t("integration.workflows.table.deleteDialog.description") }}
418
- </span>
419
- </AlertDialogDescription>
420
- </AlertDialogHeader>
421
- <AlertDialogFooter>
422
- <Button variant="outline" :disabled="deleting" @click="pendingDelete = null">
423
- {{ t("integration.common.action.cancel") }}
424
- </Button>
425
- <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="deleting" @click="confirmDelete">
426
- <Loader2 v-if="deleting" :size="14" class="animate-spin" />
427
- {{ deleting ? t("integration.workflows.table.deleteDialog.action.deleting") : t("integration.common.action.delete") }}
428
- </Button>
429
- </AlertDialogFooter>
430
- </AlertDialogContent>
431
- </AlertDialog>
393
+ <WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" />
432
394
 
433
395
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
434
396
  </template>
@@ -0,0 +1,48 @@
1
+ import { Position } from '@vue-flow/core';
2
+ import type { Side } from '../../data/handleId.js';
3
+ /**
4
+ * What a connection dot's side means to the canvas: where the library draws it, what
5
+ * faces it, and where along the edge it sits.
6
+ *
7
+ * `data/handleId.ts` owns what a side *is* — the closed set of four, and the id a dot
8
+ * carries. This owns what the canvas does with one. The split is the import: that
9
+ * module has none, so its round trip can be proved without the canvas library, and
10
+ * `Position` is Vue Flow's. Keeping the two apart is what stops a string-shape contract
11
+ * from depending on a rendering library.
12
+ *
13
+ * **Every map here is a `Record<Side, …>`, and that is the point of the module.** Each
14
+ * was a four-branch ternary ending in an `else`, in a component, and two components had
15
+ * their own copy of the first one. An `else` absorbs a fifth side silently: a `centre`
16
+ * dot would have been drawn on the **left**, called `right` its opposite, and spread
17
+ * along the wrong axis — with no type error and nothing on screen to notice, because
18
+ * every one of those is a plausible answer for *some* side.
19
+ *
20
+ * A `Record<Side, …>` cannot do that. Add a name to `SIDES` and all three of these stop
21
+ * compiling until they say what it means. That is weaker than PO-258's "one edit" and
22
+ * better than it: it is still more than one edit, but none of them can be forgotten.
23
+ */
24
+ /**
25
+ * The side a dot is on → the position Vue Flow draws it at.
26
+ *
27
+ * The two vocabularies happen to be the same four strings today — `Position.Top` is
28
+ * `'top'` — so this could be a cast, and is not. A cast would tie us to that
29
+ * coincidence holding in a library we do not own, and it would say nothing when it
30
+ * stopped: the studio's sides are its own, and this is where they are translated.
31
+ */
32
+ export declare const SIDE_POSITION: Record<Side, Position>;
33
+ /**
34
+ * The side facing a given one, which is where an edge arriving from it should land.
35
+ *
36
+ * "Opposite" is only meaningful because the four sides are two axes. A fifth position
37
+ * that is not on an axis — a centre dot — has no opposite at all, and the honest answer
38
+ * then is to change this type rather than to invent one; the compiler will ask.
39
+ */
40
+ export declare const OPPOSITE_SIDE: Record<Side, Side>;
41
+ /**
42
+ * Place the `i`th of `total` output dots evenly along a side.
43
+ *
44
+ * Evenly *between* the corners rather than across them — `(i + 1) / (total + 1)` — so
45
+ * the first and last dot sit inside the card's edge instead of on it, and a single
46
+ * output lands in the middle.
47
+ */
48
+ export declare function distributedStyle(side: Side, i: number, total: number): Record<string, string>;