@revenexx/studio-integrations 3.0.0 → 3.0.2

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 3.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 1c65e26: A run started from the editor in waiting mode that fails now says "Sync run failed", as promised, instead of passing on the service's own "Workflow execution failed.".
8
+ - 7c148a6: "Keep editing" in the question about leaving unsaved work is readable in every host, the cockpit included. Under `@revenexx/studio` 0.2.2 and 0.2.3 the design system painted the outline variant's white face over the label, so the fix in PO-415 only showed in the playground. The answer is now a plain filled button and still holds the keyboard when the question opens. The design-system peer moves to `^0.2.10`, the version the cockpit runs.
9
+ - 9277849: A step test that ends on the node's error output is announced as failed — "Test of “Fetch orders” failed" — with the node's own message under it, such as "unauthorized access" when a service refuses the token. Before, it got the same green "Test run finished" as a step that worked, and the reason stood only in the output's JSON. The notice names the step because the answer can arrive after you have moved to another one. A node that ends on its error output without a message is still announced as failed, and the notice says it gave no reason (PO-651).
10
+
11
+ ## 3.0.1
12
+
13
+ ### Patch Changes
14
+
15
+ - 259fe08: A template card whose definition could not be fetched now says "Couldn't load the flow" instead of showing "Loading the flow" for the rest of the visit, on every surface that draws the card. Sorting the gallery by most steps puts templates whose step count is not known after every template whose count is, rather than ranking them as having no steps (PO-249).
16
+
3
17
  ## 3.0.0
4
18
 
5
19
  ### Major Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "3.0.0",
4
+ "version": "3.0.2",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.3",
7
7
  "unbuild": "unknown"
@@ -886,6 +886,17 @@ function toggleTestInput() {
886
886
  if (!testInputOpen.value && !testInputDraft.value) testInputDraft.value = defaultTestInput();
887
887
  testInputOpen.value = !testInputOpen.value;
888
888
  }
889
+ function errorOutputTaken(branch, ports) {
890
+ if (!branch) return null;
891
+ if (branch === "error") return branch;
892
+ return ports.some((p) => p.name === branch && p.kind === "error") ? branch : null;
893
+ }
894
+ function errorOutputMessage(outputs, port) {
895
+ const error = outputs[port];
896
+ if (!error || typeof error !== "object") return null;
897
+ const message = error.message;
898
+ return typeof message === "string" ? message.trim() || null : null;
899
+ }
889
900
  async function runTestStep() {
890
901
  const wn = draftNode.value;
891
902
  if (!wn || testRunning.value) return;
@@ -920,12 +931,20 @@ async function runTestStep() {
920
931
  }
921
932
  );
922
933
  testLogs.value = res.logs ?? [];
923
- testOutputs.value = {
924
- ...testOutputs.value,
925
- [wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
926
- };
934
+ const outputs = res.outputs && typeof res.outputs === "object" ? res.outputs : {};
935
+ testOutputs.value = { ...testOutputs.value, [wn.id]: outputs };
927
936
  outputView.value = "json";
928
- toast.success(t("integration.nodeInspector.toast.testRunFinished"));
937
+ const nodeManifest = findManifest(wn.nodeSlug, wn.nodeVersion);
938
+ const failedOn = errorOutputTaken(res.branch, portsForNode(wn, nodeManifest, props.dynamicOutputs?.[wn.id]));
939
+ if (failedOn) {
940
+ const name = resolveDisplayName(wn.name) || resolveLocalized(nodeManifest?.name) || wn.nodeSlug;
941
+ toast.error(t("integration.nodeInspector.toast.testRunFailed", { name }), {
942
+ description: errorOutputMessage(outputs, failedOn) ?? t("integration.nodeInspector.toast.testRunFailedNoMessage"),
943
+ duration: 8e3
944
+ });
945
+ } else {
946
+ toast.success(t("integration.nodeInspector.toast.testRunFinished"));
947
+ }
929
948
  } catch (e) {
930
949
  const body = e.body;
931
950
  testError.value = body?.error?.message ?? body?.message ?? e?.message ?? t("integration.nodeInspector.output.test.runFailed");
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, resolveComponent } from "vue";
3
- import { RefreshCircle as Loader2 } from "#solar-icons";
3
+ import { RefreshCircle as Loader2, DangerTriangle as AlertTriangle } from "#solar-icons";
4
4
  import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
5
5
  import { templateCategoryLabel, templatePackageLabel } from "../../data/integrationTemplates";
6
6
  import TemplateFlowPreview from "./TemplateFlowPreview.vue";
@@ -68,6 +68,17 @@ const chipSize = computed(() => full.value ? "px-2 py-0.5 text-xs" : "px-1.5 py-
68
68
  :height="full ? 116 : 96"
69
69
  :class="full ? 'mt-7' : ''"
70
70
  />
71
+ <!-- The read came back without a definition. Said in words, because a
72
+ spinner here would wait for the rest of the visit, and the card would
73
+ go on looking like a template still worth waiting for (PO-249). -->
74
+ <div
75
+ v-else-if="template.detailFailed"
76
+ role="status"
77
+ class="flex h-full items-center justify-center gap-1.5 px-3 text-xs text-muted-foreground"
78
+ >
79
+ <AlertTriangle :size="full ? 14 : 12" class="shrink-0" />
80
+ {{ t("integration.templates.card.state.flowUnavailable") }}
81
+ </div>
71
82
  <!-- Named, because "still on its way" and "this template draws nothing"
72
83
  are the same sight otherwise — and a spinner nobody can address is a
73
84
  promise no test can hold to. -->
@@ -4,7 +4,7 @@ type __VLS_Props = {
4
4
  size?: number;
5
5
  };
6
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
7
- icon: WorkflowIcon | null;
8
7
  size: number;
8
+ icon: WorkflowIcon | null;
9
9
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
10
  export default _default;
@@ -4,7 +4,7 @@ type __VLS_Props = {
4
4
  size?: number;
5
5
  };
6
6
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {
7
- icon: WorkflowIcon | null;
8
7
  size: number;
8
+ icon: WorkflowIcon | null;
9
9
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
10
10
  export default _default;
@@ -95,10 +95,12 @@ export function useIntegrationsTemplates() {
95
95
  const t = useIntegrationsT();
96
96
  const rawList = useState("integrations.templates.raw", () => []);
97
97
  const rawDetails = useState("integrations.templates.rawDetails", () => ({}));
98
+ const failedDetails = useState("integrations.templates.failedDetails", () => ({}));
98
99
  const templates = computed(() => rawList.value.map((item) => {
99
100
  const base = mapListItem(item);
100
101
  const detail = rawDetails.value[item.slug];
101
- return detail ? applyDetail(base, detail) : base;
102
+ if (detail) return applyDetail(base, detail);
103
+ return failedDetails.value[item.slug] ? { ...base, detailFailed: true } : base;
102
104
  }));
103
105
  const loaded = useState("integrations.templates.loaded", () => false);
104
106
  const loading = useState("integrations.templates.loading", () => false);
@@ -112,11 +114,13 @@ export function useIntegrationsTemplates() {
112
114
  const list2 = unwrapList(await api.get("/templates"));
113
115
  rawList.value = list2;
114
116
  rawDetails.value = {};
117
+ failedDetails.value = {};
115
118
  loaded.value = true;
116
119
  } catch (e) {
117
120
  error.value = errorReason(e, t("integration.templates.state.error.fallback"));
118
121
  rawList.value = [];
119
122
  rawDetails.value = {};
123
+ failedDetails.value = {};
120
124
  loaded.value = true;
121
125
  } finally {
122
126
  gate.release();
@@ -142,14 +146,19 @@ export function useIntegrationsTemplates() {
142
146
  });
143
147
  function remember(details) {
144
148
  const found = [...details].filter((entry) => entry[1] !== null);
145
- if (!found.length) return;
146
- rawDetails.value = { ...rawDetails.value, ...Object.fromEntries(found) };
149
+ const missing = [...details].filter(([, detail]) => detail === null).map(([slug]) => slug);
150
+ if (found.length) rawDetails.value = { ...rawDetails.value, ...Object.fromEntries(found) };
151
+ if (found.length || missing.length) {
152
+ const failed = { ...failedDetails.value };
153
+ for (const [slug] of found) delete failed[slug];
154
+ for (const slug of missing) failed[slug] = true;
155
+ failedDetails.value = failed;
156
+ }
147
157
  }
148
158
  async function fetchDetail(slug) {
149
159
  const base = get(slug);
150
160
  if (!base?.fromApi) return base;
151
- const detail = await loader.one(slug);
152
- if (detail) remember(/* @__PURE__ */ new Map([[slug, detail]]));
161
+ remember(/* @__PURE__ */ new Map([[slug, await loader.one(slug)]]));
153
162
  return get(slug);
154
163
  }
155
164
  async function ensureDetails(slugs, _concurrency = 4) {
@@ -818,6 +818,8 @@
818
818
  "integration.nodeInspector.toast.samplePinned": "Beispielausgabe angeheftet",
819
819
  "integration.nodeInspector.toast.secretCopied": "Secret kopiert",
820
820
  "integration.nodeInspector.toast.secretCopyFailed": "Secret konnte nicht kopiert werden",
821
+ "integration.nodeInspector.toast.testRunFailed": "Test von „{name}“ fehlgeschlagen",
822
+ "integration.nodeInspector.toast.testRunFailedNoMessage": "Der Schritt endete am Fehlerausgang, ohne einen Grund zu nennen.",
821
823
  "integration.nodeInspector.toast.testRunFinished": "Testlauf abgeschlossen",
822
824
  "integration.nodeInspector.toast.webhookUrlCopied": "Webhook-URL kopiert",
823
825
  "integration.nodeInspector.toast.webhookUrlCopyFailed": "URL konnte nicht kopiert werden",
@@ -1113,6 +1115,7 @@
1113
1115
  "integration.secrets.table.column.key": "Schlüssel",
1114
1116
  "integration.secrets.table.column.updated": "Aktualisiert",
1115
1117
  "integration.shell.action.skipToContent": "Zum Inhalt springen",
1118
+ "integration.templates.card.state.flowUnavailable": "Der Ablauf konnte nicht geladen werden",
1116
1119
  "integration.templates.card.state.loadingFlow": "Der Ablauf wird geladen",
1117
1120
  "integration.templates.card.trigger.event": "Event-gesteuert",
1118
1121
  "integration.templates.card.trigger.schedule": "Geplant",
@@ -818,6 +818,8 @@
818
818
  "integration.nodeInspector.toast.samplePinned": "Sample output pinned",
819
819
  "integration.nodeInspector.toast.secretCopied": "Secret copied",
820
820
  "integration.nodeInspector.toast.secretCopyFailed": "Could not copy the secret",
821
+ "integration.nodeInspector.toast.testRunFailed": "Test of “{name}” failed",
822
+ "integration.nodeInspector.toast.testRunFailedNoMessage": "The step ended on its error output without saying why.",
821
823
  "integration.nodeInspector.toast.testRunFinished": "Test run finished",
822
824
  "integration.nodeInspector.toast.webhookUrlCopied": "Webhook URL copied",
823
825
  "integration.nodeInspector.toast.webhookUrlCopyFailed": "Could not copy the URL",
@@ -1113,6 +1115,7 @@
1113
1115
  "integration.secrets.table.column.key": "Key",
1114
1116
  "integration.secrets.table.column.updated": "Updated",
1115
1117
  "integration.shell.action.skipToContent": "Skip to content",
1118
+ "integration.templates.card.state.flowUnavailable": "Couldn't load the flow",
1116
1119
  "integration.templates.card.state.loadingFlow": "Loading the flow",
1117
1120
  "integration.templates.card.trigger.event": "Event-driven",
1118
1121
  "integration.templates.card.trigger.schedule": "Scheduled",
@@ -49,7 +49,7 @@ const filtered = computed(() => {
49
49
  const sorted = [...items];
50
50
  if (sortBy.value === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
51
51
  else if (sortBy.value === "level") sorted.sort((a, b) => (LEVEL_RANK[a.level] ?? 0) - (LEVEL_RANK[b.level] ?? 0) || a.name.localeCompare(b.name));
52
- else sorted.sort((a, b) => b.nodeCount - a.nodeCount || a.name.localeCompare(b.name));
52
+ else sorted.sort((a, b) => Number(!!b.detailLoaded) - Number(!!a.detailLoaded) || b.nodeCount - a.nodeCount || a.name.localeCompare(b.name));
53
53
  return sorted;
54
54
  });
55
55
  watch(() => templates.value.length, () => {
@@ -234,6 +234,10 @@ function answerLeave(leave) {
234
234
  leaveDecision?.(leave);
235
235
  leaveDecision = null;
236
236
  }
237
+ function focusStay(event) {
238
+ event.preventDefault();
239
+ event.target?.querySelector('[data-leave-answer="stay"]')?.focus();
240
+ }
237
241
  let leavingOnPurpose = false;
238
242
  async function leaveTo(path) {
239
243
  leavingOnPurpose = true;
@@ -339,6 +343,10 @@ function extractRunId(res) {
339
343
  }
340
344
  return null;
341
345
  }
346
+ function bodyRunId(e) {
347
+ if (!(e instanceof IntegrationsApiError)) return null;
348
+ return extractRunId(e.body?.run);
349
+ }
342
350
  function asRunObject(res) {
343
351
  if (!res || typeof res !== "object" || Array.isArray(res)) return null;
344
352
  const o = res;
@@ -353,12 +361,12 @@ function viewRunFromModal(id2) {
353
361
  function isRefusal(e) {
354
362
  if (!(e instanceof IntegrationsApiError)) return false;
355
363
  if (e.status < 400 || e.status >= 500 || e.status === 408) return false;
356
- return extractRunId(e.body?.run) === null;
364
+ return bodyRunId(e) === null;
357
365
  }
358
366
  function syncErrorMessage(e) {
359
367
  if (e instanceof IntegrationsApiError) {
360
368
  if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
361
- if (e.status === 422 && e.body?.status === "failed") return t("integration.editor.run.error.syncFailed");
369
+ if (e.status === 422 && bodyRunId(e) !== null) return t("integration.editor.run.error.syncFailed");
362
370
  }
363
371
  return errorReason(e, t("integration.editor.run.error.syncFailed"));
364
372
  }
@@ -418,9 +426,7 @@ async function run(mode) {
418
426
  runObject.value = asRunObject(syncResponse.value);
419
427
  } catch (e) {
420
428
  await reportRunFailure(e, syncErrorMessage);
421
- if (e instanceof IntegrationsApiError) {
422
- runModalRunId.value = extractRunId(e.body?.run);
423
- }
429
+ runModalRunId.value = bodyRunId(e);
424
430
  } finally {
425
431
  syncLoading.value = false;
426
432
  void loadRuns(1);
@@ -1368,29 +1374,24 @@ async function confirmDeactivate() {
1368
1374
 
1369
1375
  <!-- Leaving the workflow with unsaved work.
1370
1376
  Staying is the default in the only sense that decides anything: it holds the
1371
- focus when the dialog opens, so Enter or Space keeps the work. That is what
1372
- `AlertDialogCancel` is for — the dialog focuses its cancel element, and with
1373
- none declared it focuses the first tabbable button instead, which was
1374
- "Discard and leave". A breadcrumb click followed by Enter then threw away the
1375
- afternoon this dialog exists to protect.
1376
- `as-child` so the studio's own Button is that element rather than a second one
1377
- beside it. Escape means the same thing, through `update:open`; a click beside
1378
- the dialog does nothing at all, which an alert dialog is entitled to.
1377
+ focus when the dialog opens, so Enter or Space keeps the work — `focusStay`
1378
+ puts it there. Escape means the same thing, through `update:open`; a click
1379
+ beside the dialog does nothing at all, which an alert dialog is entitled to.
1379
1380
 
1380
- The class list on that Button is the price of `as-child`: `AlertDialogCancel`
1381
- applies the outline variant to whatever it renders, and every dialog in this
1382
- module draws the answer on the right as a filled one. So the two variants meet
1383
- on one element and `tailwind-merge` decides between them per group — which it
1384
- cannot do where the outline variant names a group the default one does not, and
1385
- those are the five that have to be said here. Left to itself the merge produced
1386
- a background from the outline variant and a text colour from the default, which
1387
- resolve to the same colour in this theme: the label was invisible until hovered
1388
- (PO-415). Nothing marks this answer as the one holding the keyboard otherwise —
1389
- `focus-visible` does not match after the click that opened the dialog. -->
1381
+ The answer on the right is a plain filled Button, as in every other dialog in
1382
+ this module, and deliberately not wrapped in `AlertDialogCancel`. The cancel
1383
+ adds its own variant to whatever it renders, so two variants met on one
1384
+ element, and the design system paints a button's face as a background image
1385
+ over its background colour: no class written here decides what is seen. Under
1386
+ `@revenexx/studio` 0.2.2 and 0.2.3 the outline variant painted a white face
1387
+ over the label, and the answer that keeps the work was unreadable in the
1388
+ cockpit (PO-415, PO-598). Nothing marks this answer as the one holding the
1389
+ keyboard otherwise — `focus-visible` does not match after the click that
1390
+ opened the dialog. -->
1390
1391
  <AlertDialog :open="leaveOpen" @update:open="(v) => {
1391
1392
  if (!v) answerLeave(false);
1392
1393
  }">
1393
- <AlertDialogContent>
1394
+ <AlertDialogContent @open-auto-focus="focusStay">
1394
1395
  <AlertDialogHeader>
1395
1396
  <AlertDialogTitle>{{ t("integration.editor.leave.dialog.title") }}</AlertDialogTitle>
1396
1397
  <AlertDialogDescription>
@@ -1401,14 +1402,9 @@ async function confirmDeactivate() {
1401
1402
  <Button variant="outline" @click="answerLeave(true)">
1402
1403
  {{ t("integration.editor.leave.dialog.discard") }}
1403
1404
  </Button>
1404
- <AlertDialogCancel as-child>
1405
- <Button
1406
- class="border-transparent bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground dark:border-transparent dark:bg-primary dark:hover:bg-primary/90"
1407
- @click="answerLeave(false)"
1408
- >
1409
- {{ t("integration.editor.leave.dialog.stay") }}
1410
- </Button>
1411
- </AlertDialogCancel>
1405
+ <Button data-leave-answer="stay" @click="answerLeave(false)">
1406
+ {{ t("integration.editor.leave.dialog.stay") }}
1407
+ </Button>
1412
1408
  </AlertDialogFooter>
1413
1409
  </AlertDialogContent>
1414
1410
  </AlertDialog>
@@ -616,6 +616,8 @@ export interface WorkflowTemplate {
616
616
  }>;
617
617
  /** True once the heavy definition blob has been merged via fetchDetail. */
618
618
  detailLoaded?: boolean;
619
+ /** True when the definition was asked for and did not arrive — not "still on its way". */
620
+ detailFailed?: boolean;
619
621
  /** Source node package this template ships with (e.g. Business Central). */
620
622
  package?: {
621
623
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@dagrejs/dagre": "^3.0.0",
37
- "@revenexx/studio": "^0.2.3",
37
+ "@revenexx/studio": "^0.2.10",
38
38
  "@tiptap/core": "^3.28.0",
39
39
  "@tiptap/extension-document": "^3.28.0",
40
40
  "@tiptap/extension-history": "^3.28.0",