@revenexx/studio-integrations 1.1.1 → 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 (96) hide show
  1. package/CHANGELOG.md +483 -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 +45 -19
  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 +2 -1
  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 +129 -18
  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 +26 -8
  67. package/dist/runtime/i18n/messages/en.json +26 -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 +55 -8
  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 +155 -20
  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/validationFindings.d.ts +26 -3
  95. package/dist/runtime/utils/validationFindings.js +18 -3
  96. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
- import type { WorkflowRun } from '../../types/integrations.js';
1
+ import type { RunStatus, TriggerType, WorkflowRun } from '../../types/integrations.js';
2
+ import type { RunSort, RunSortDirection } from '../../utils/runFeedQuery.js';
2
3
  type __VLS_Props = {
3
4
  runs: WorkflowRun[];
4
5
  loading?: boolean;
@@ -7,14 +8,71 @@ type __VLS_Props = {
7
8
  pagination?: boolean;
8
9
  /** false inside a workflow — the context makes the column redundant. */
9
10
  showWorkflow?: boolean;
11
+ /**
12
+ * The owning list reads its page and its narrowing from the platform.
13
+ *
14
+ * The table then renders the rows it is handed and reports what the operator
15
+ * asked for, instead of narrowing and slicing an array. That is what lets the
16
+ * list reach past the newest hundred runs at all (PO-369).
17
+ */
18
+ serverMode?: boolean;
19
+ /** How many runs match the narrowing. The pager's "of 7"; server mode only. */
20
+ total?: number;
21
+ /**
22
+ * How many rows a page holds — the SAME number the page asks the platform for.
23
+ *
24
+ * The shared table works its footer's "showing 1–25 of 140" out from its own
25
+ * page size, so a table left on its default while the request asks for another
26
+ * number states a range that is simply wrong: it counted twenty where
27
+ * twenty-five had arrived.
28
+ */
29
+ perPage?: number;
30
+ /** The narrowing in force. Owned by the page, drawn here. */
31
+ status?: RunStatus[];
32
+ triggerType?: TriggerType | '';
33
+ /** The order the platform is reading the list in. Server mode only. */
34
+ sort?: RunSort;
35
+ direction?: RunSortDirection;
10
36
  };
11
- declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
37
+ type __VLS_PublicProps = __VLS_Props & {
38
+ /** 1-based, as the platform pages. The shared table counts from zero. */
39
+ 'page'?: number;
40
+ };
41
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
42
+ search: (text: string) => any;
12
43
  changed: () => any;
13
- }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
44
+ narrow: (patch: {
45
+ status?: RunStatus[];
46
+ triggerType?: TriggerType | "";
47
+ }) => any;
48
+ "page-size": (size: number) => any;
49
+ reorder: (order: {
50
+ sort: RunSort;
51
+ direction: RunSortDirection;
52
+ }) => any;
53
+ "update:page": (value: number) => any;
54
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
55
+ onSearch?: ((text: string) => any) | undefined;
14
56
  onChanged?: (() => any) | undefined;
57
+ onNarrow?: ((patch: {
58
+ status?: RunStatus[];
59
+ triggerType?: TriggerType | "";
60
+ }) => any) | undefined;
61
+ "onPage-size"?: ((size: number) => any) | undefined;
62
+ onReorder?: ((order: {
63
+ sort: RunSort;
64
+ direction: RunSortDirection;
65
+ }) => any) | undefined;
66
+ "onUpdate:page"?: ((value: number) => any) | undefined;
15
67
  }>, {
68
+ sort: RunSort;
16
69
  loading: boolean;
70
+ total: number;
71
+ direction: RunSortDirection;
72
+ triggerType: TriggerType | "";
73
+ perPage: number;
17
74
  pagination: boolean;
18
75
  showWorkflow: boolean;
76
+ serverMode: boolean;
19
77
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
78
  export default _default;
@@ -2,6 +2,7 @@
2
2
  import { computed, ref, watch } from "vue";
3
3
  import { RefreshCircle as Loader2 } from "#solar-icons";
4
4
  import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@revenexx/studio";
5
+ import LoadingAnnouncement from "./LoadingAnnouncement.vue";
5
6
  import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
6
7
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
7
8
  const emit = defineEmits(["created"]);
@@ -64,7 +65,15 @@ async function importAccount(account) {
64
65
  </DialogDescription>
65
66
  </DialogHeader>
66
67
 
67
- <div v-if="sftp.loading.value && !activeAccounts.length" class="space-y-2">
68
+ <!-- Mounts with the dialog, like the one in the create dialog and with the same
69
+ limit written down there (PO-251). -->
70
+ <LoadingAnnouncement
71
+ :label="t('integration.credentials.sftpImport.title')"
72
+ :loading="sftp.loading.value && !activeAccounts.length"
73
+ :text="t('integration.credentials.sftpImport.state.loading')"
74
+ />
75
+
76
+ <div v-if="sftp.loading.value && !activeAccounts.length" aria-hidden="true" class="space-y-2">
68
77
  <div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
69
78
  </div>
70
79
  <p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
@@ -171,7 +171,7 @@ function onViewMouseDown(e) {
171
171
  v-for="(c, i) in STICKY_COLORS"
172
172
  :key="i"
173
173
  type="button"
174
- class="size-5 rounded-full border transition hover:scale-110"
174
+ class="size-6 rounded-full border transition hover:scale-110"
175
175
  :class="(data.sticky.color ?? 0) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
176
176
  :style="{ backgroundColor: c.bg, borderColor: c.border }"
177
177
  :title="t(c.nameKey)"
@@ -21,10 +21,12 @@ import {
21
21
  } from "@revenexx/studio";
22
22
  import { toast } from "vue-sonner";
23
23
  import { TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
24
+ import LoadingAnnouncement from "./LoadingAnnouncement.vue";
24
25
  import TemplateCard from "./TemplateCard.vue";
25
26
  import WorkflowIconPicker from "./WorkflowIconPicker.vue";
26
27
  import WorkflowTagsInput from "./WorkflowTagsInput.vue";
27
28
  import { resolveLocalized } from "../../utils/localize";
29
+ import { errorReason } from "../../utils/apiError";
28
30
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
29
31
  const t = useIntegrationsT();
30
32
  const router = useRouter();
@@ -102,7 +104,7 @@ async function submit() {
102
104
  open.value = false;
103
105
  await router.push(`/integrations/workflows/${created.id}`);
104
106
  } catch (e) {
105
- error.value = e.message || t("integration.workflows.create.error.generic");
107
+ error.value = errorReason(e, t("integration.workflows.create.error.generic"));
106
108
  } finally {
107
109
  submitting.value = false;
108
110
  }
@@ -126,7 +128,7 @@ async function useTemplate(slug) {
126
128
  open.value = false;
127
129
  await router.push(`/integrations/workflows/${id}`);
128
130
  } catch (e) {
129
- toast.error(e?.message ?? t("integration.workflows.create.toast.templateError"));
131
+ toast.error(errorReason(e, t("integration.workflows.create.toast.templateError")));
130
132
  } finally {
131
133
  instantiatingSlug.value = null;
132
134
  }
@@ -143,6 +145,19 @@ async function useTemplate(slug) {
143
145
  <!-- Switcher: the same TabBar the studio surfaces use. -->
144
146
  <TabBar v-model="mode" :tabs="modeTabs" class="-mt-1 shrink-0" />
145
147
 
148
+ <!-- Beside the switcher rather than inside the template half, and that is the
149
+ whole point: the dialog opens on the blank half, so a region placed in the
150
+ other branch would be inserted at the moment the reader switches to it —
151
+ with the catalogue already on its way and its own text already set, which is
152
+ a live region nothing was listening to. Out here it is in the dialog from
153
+ the moment it opens, and the text changes under a reader who is already
154
+ there (PO-251). -->
155
+ <LoadingAnnouncement
156
+ :label="t('integration.workflows.create.tab.template')"
157
+ :loading="mode === 'template' && templatesLoading && !templates.length"
158
+ :text="t('integration.workflows.create.template.state.loading')"
159
+ />
160
+
146
161
  <!-- ── Blank ─────────────────────────────────────────────────────────── -->
147
162
  <!-- -m-1/p-1 keeps the selection rings clear of the scroll clipping. -->
148
163
  <form v-if="mode === 'blank'" class="-m-1 max-h-[65vh] space-y-3 overflow-y-auto p-1" @submit.prevent="submit">
@@ -261,7 +276,7 @@ async function useTemplate(slug) {
261
276
  >
262
277
  </div>
263
278
 
264
- <div v-if="templatesLoading && !templates.length" class="grid gap-2 p-1 sm:grid-cols-2">
279
+ <div v-if="templatesLoading && !templates.length" aria-hidden="true" class="grid gap-2 p-1 sm:grid-cols-2">
265
280
  <div v-for="n in 6" :key="`sk-tpl-${n}`" class="rounded-lg border border-black/[0.06] bg-white p-3 space-y-2">
266
281
  <div class="flex items-center gap-2">
267
282
  <div class="size-7 rounded-md bg-black/[0.07] animate-pulse" />
@@ -0,0 +1,18 @@
1
+ export type DeletableWorkflow = {
2
+ id: number;
3
+ name: string;
4
+ };
5
+ type __VLS_Props = {
6
+ workflow: DeletableWorkflow | null;
7
+ };
8
+ type __VLS_PublicProps = __VLS_Props & {
9
+ 'open'?: boolean;
10
+ };
11
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ "update:open": (value: boolean) => any;
13
+ deleted: (workflow: DeletableWorkflow) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
16
+ onDeleted?: ((workflow: DeletableWorkflow) => any) | undefined;
17
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
+ export default _default;
@@ -0,0 +1,70 @@
1
+ <script setup>
2
+ import { ref, watch } from "vue";
3
+ import { RefreshCircle as Loader2 } from "#solar-icons";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogContent,
7
+ AlertDialogDescription,
8
+ AlertDialogFooter,
9
+ AlertDialogHeader,
10
+ AlertDialogTitle,
11
+ Button
12
+ } from "@revenexx/studio";
13
+ import { useIntegrationsWorkflows } from "../../composables/useIntegrationsWorkflows";
14
+ import { errorReason } from "../../utils/apiError";
15
+ const open = defineModel("open", { type: Boolean, ...{ default: false } });
16
+ const props = defineProps({
17
+ workflow: { type: [Object, null], required: true }
18
+ });
19
+ const emit = defineEmits(["deleted"]);
20
+ const t = useIntegrationsT();
21
+ const { remove } = useIntegrationsWorkflows();
22
+ const busy = ref(false);
23
+ const error = ref(null);
24
+ watch(open, (o) => {
25
+ if (o) error.value = null;
26
+ });
27
+ async function confirm() {
28
+ const wf = props.workflow;
29
+ if (!wf || busy.value) return;
30
+ busy.value = true;
31
+ error.value = null;
32
+ try {
33
+ await remove(wf.id);
34
+ open.value = false;
35
+ emit("deleted", wf);
36
+ } catch (e) {
37
+ error.value = errorReason(e, t("integration.workflows.delete.error.generic"));
38
+ } finally {
39
+ busy.value = false;
40
+ }
41
+ }
42
+ </script>
43
+
44
+ <template>
45
+ <AlertDialog :open="open" @update:open="(v) => {
46
+ if (!v && !busy) open = false;
47
+ }">
48
+ <AlertDialogContent>
49
+ <AlertDialogHeader>
50
+ <AlertDialogTitle>{{ t("integration.workflows.delete.dialog.title") }}</AlertDialogTitle>
51
+ <AlertDialogDescription>
52
+ <span v-if="props.workflow">
53
+ <span class="font-medium text-foreground">{{ props.workflow.name }}</span>
54
+ {{ t("integration.workflows.delete.dialog.description") }}
55
+ </span>
56
+ </AlertDialogDescription>
57
+ </AlertDialogHeader>
58
+ <p v-if="error" class="text-sm text-rose-600">{{ error }}</p>
59
+ <AlertDialogFooter>
60
+ <Button variant="outline" :disabled="busy" @click="open = false">
61
+ {{ t("integration.common.action.cancel") }}
62
+ </Button>
63
+ <Button class="bg-destructive text-white hover:bg-destructive/90" :disabled="busy" @click="confirm">
64
+ <Loader2 v-if="busy" :size="14" class="animate-spin" />
65
+ {{ busy ? t("integration.workflows.delete.action.deleting") : t("integration.common.action.delete") }}
66
+ </Button>
67
+ </AlertDialogFooter>
68
+ </AlertDialogContent>
69
+ </AlertDialog>
70
+ </template>
@@ -0,0 +1,18 @@
1
+ export type DeletableWorkflow = {
2
+ id: number;
3
+ name: string;
4
+ };
5
+ type __VLS_Props = {
6
+ workflow: DeletableWorkflow | null;
7
+ };
8
+ type __VLS_PublicProps = __VLS_Props & {
9
+ 'open'?: boolean;
10
+ };
11
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ "update:open": (value: boolean) => any;
13
+ deleted: (workflow: DeletableWorkflow) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
16
+ onDeleted?: ((workflow: DeletableWorkflow) => any) | undefined;
17
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
+ export default _default;
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
3
3
  import { RefreshCircle as Loader2 } from "#solar-icons";
4
4
  import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input } from "@revenexx/studio";
5
5
  import { useIntegrationsWorkflows } from "../../composables/useIntegrationsWorkflows";
6
+ import { errorReason } from "../../utils/apiError";
6
7
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
7
8
  const props = defineProps({
8
9
  workflow: { type: [Object, null], required: true }
@@ -29,7 +30,7 @@ async function submit() {
29
30
  open.value = false;
30
31
  emit("duplicated", copy);
31
32
  } catch (e) {
32
- error.value = e?.message || t("integration.workflows.duplicate.error.generic");
33
+ error.value = errorReason(e, t("integration.workflows.duplicate.error.generic"));
33
34
  } finally {
34
35
  busy.value = false;
35
36
  }
@@ -146,20 +146,20 @@ function spliceNode() {
146
146
  >
147
147
  <button
148
148
  type="button"
149
- class="flex size-5 items-center justify-center rounded text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
149
+ class="flex size-6 items-center justify-center rounded text-muted-foreground transition hover:bg-muted/60 hover:text-foreground"
150
150
  :title="t('integration.canvas.edge.action.insertNode')"
151
151
  @click.stop="spliceNode"
152
152
  >
153
- <Plus :size="12" />
153
+ <Plus :size="13" />
154
154
  </button>
155
155
  <div class="h-3.5 w-px bg-black/10" />
156
156
  <button
157
157
  type="button"
158
- class="flex size-5 items-center justify-center rounded text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
158
+ class="flex size-6 items-center justify-center rounded text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
159
159
  :title="t('integration.canvas.edge.action.delete')"
160
160
  @click.stop="removeEdge"
161
161
  >
162
- <TrashBin :size="12" />
162
+ <TrashBin :size="13" />
163
163
  </button>
164
164
  </div>
165
165
  </EdgeLabelRenderer>
@@ -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">