@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
@@ -25,6 +25,7 @@ import {
25
25
  import { toast } from "vue-sonner";
26
26
  import { REVENEXX_SOURCE_KIND_BY_SLUG } from "../../utils/revenexxCredentialSource";
27
27
  import { cockpitSources } from "../../utils/cockpitSources";
28
+ import { errorReason } from "../../utils/apiError";
28
29
  const props = defineProps({
29
30
  type: { type: [Object, null], required: true }
30
31
  });
@@ -93,7 +94,7 @@ async function ensureLists() {
93
94
  await fetchMarkets();
94
95
  }
95
96
  } catch (e) {
96
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.loadResources"));
97
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.loadResources")));
97
98
  } finally {
98
99
  loadingList.value = false;
99
100
  }
@@ -124,7 +125,7 @@ async function applySftp() {
124
125
  });
125
126
  applied.value = `${acc.login_username}@${sftpHost}:${sftpPort}`;
126
127
  } catch (e) {
127
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.readSftpPassword"));
128
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.readSftpPassword")));
128
129
  } finally {
129
130
  applying.value = false;
130
131
  }
@@ -147,7 +148,7 @@ async function applySmtp() {
147
148
  });
148
149
  applied.value = `${cred.smtp_username}@${mailSmtpHost}:${mailSmtpPort}`;
149
150
  } catch (e) {
150
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.readMailCredential"));
151
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.readMailCredential")));
151
152
  } finally {
152
153
  applying.value = false;
153
154
  }
@@ -202,7 +203,7 @@ async function createSftp() {
202
203
  createOpen.value = false;
203
204
  toast.success(t("integration.credentials.revenexxSource.toast.sftpCreated"));
204
205
  } catch (e) {
205
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.createSftp"));
206
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.createSftp")));
206
207
  } finally {
207
208
  creating.value = false;
208
209
  provisioningNote.value = "";
@@ -230,7 +231,7 @@ async function createSmtp() {
230
231
  createOpen.value = false;
231
232
  toast.success(t("integration.credentials.revenexxSource.toast.smtpCreated"));
232
233
  } catch (e) {
233
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.createSmtp"));
234
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.createSmtp")));
234
235
  } finally {
235
236
  creating.value = false;
236
237
  }
@@ -246,7 +247,7 @@ async function createApiKey() {
246
247
  createOpen.value = false;
247
248
  toast.success(t("integration.credentials.revenexxSource.toast.apiKeyCreated"));
248
249
  } catch (e) {
249
- toast.error(e?.message ?? t("integration.credentials.revenexxSource.error.createApiKey"));
250
+ toast.error(errorReason(e, t("integration.credentials.revenexxSource.error.createApiKey")));
250
251
  } finally {
251
252
  creating.value = false;
252
253
  }
@@ -3,7 +3,9 @@ import { onMounted, ref, watch } from "vue";
3
3
  import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
4
  import { Badge, Button } from "@revenexx/studio";
5
5
  import { toast } from "vue-sonner";
6
+ import LoadingAnnouncement from "./LoadingAnnouncement.vue";
6
7
  import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
8
+ import { errorReason } from "../../utils/apiError";
7
9
  const props = defineProps({
8
10
  workflowId: { type: Number, required: true }
9
11
  });
@@ -27,7 +29,7 @@ async function load(p = page.value) {
27
29
  lastPage.value = res.lastPage;
28
30
  page.value = res.page;
29
31
  } catch (e) {
30
- error.value = e?.message ?? t("integration.deadLetters.error.load");
32
+ error.value = errorReason(e, t("integration.deadLetters.error.load"));
31
33
  } finally {
32
34
  loading.value = false;
33
35
  }
@@ -46,7 +48,7 @@ async function onReplay(dl) {
46
48
  toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
47
49
  await load();
48
50
  } catch (e) {
49
- toast.error(e?.message ?? t("integration.deadLetters.toast.replay.error"));
51
+ toast.error(errorReason(e, t("integration.deadLetters.toast.replay.error")));
50
52
  } finally {
51
53
  busyId.value = null;
52
54
  }
@@ -58,7 +60,7 @@ async function onDiscard(dl) {
58
60
  toast.success(t("integration.deadLetters.toast.discard.done"));
59
61
  await load();
60
62
  } catch (e) {
61
- toast.error(e?.message ?? t("integration.deadLetters.toast.discard.error"));
63
+ toast.error(errorReason(e, t("integration.deadLetters.toast.discard.error")));
62
64
  } finally {
63
65
  busyId.value = null;
64
66
  }
@@ -75,6 +77,15 @@ function fmt(iso) {
75
77
 
76
78
  <template>
77
79
  <div class="space-y-3">
80
+ <!-- Outside the branch below, so the words are on the page before the answer is
81
+ (PO-251). This panel is the workflow editor's own copy of the missed-runs
82
+ list; the region is named after the panel rather than the tab. -->
83
+ <LoadingAnnouncement
84
+ :label="t('integration.deadLetters.panel.title')"
85
+ :loading="loading && !items.length"
86
+ :text="t('integration.deadLetters.state.loading')"
87
+ />
88
+
78
89
  <div class="flex items-center justify-between">
79
90
  <div>
80
91
  <h3 class="text-sm font-semibold">{{ t("integration.deadLetters.panel.title") }}</h3>
@@ -92,7 +103,7 @@ function fmt(iso) {
92
103
  {{ error }}
93
104
  </div>
94
105
 
95
- <div v-if="loading && !items.length">
106
+ <div v-if="loading && !items.length" aria-hidden="true">
96
107
  <div class="rounded-lg border border-black/[0.06] bg-white divide-y divide-black/[0.04]">
97
108
  <div v-for="n in 4" :key="`sk-${n}`" class="flex items-start gap-3 px-3 py-3">
98
109
  <div class="h-5 w-16 shrink-0 rounded bg-black/[0.07] animate-pulse" />
@@ -14,6 +14,10 @@ type __VLS_Props = {
14
14
  invalid?: boolean;
15
15
  /** The setting's label, so the editable region has an accessible name. */
16
16
  label?: string;
17
+ /** The setting takes several lines: Enter opens one instead of being
18
+ * swallowed, and the box opens at four rows and grows to ten before it
19
+ * scrolls. */
20
+ multiline?: boolean;
17
21
  };
18
22
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
19
23
  "update:modelValue": (value: string) => any;
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from "vue";
2
+ import { computed, inject, onBeforeUnmount, onMounted, ref, useId, watch } from "vue";
3
3
  import { Editor, EditorContent } from "@tiptap/vue-3";
4
4
  import { Node } from "@tiptap/core";
5
5
  import Document from "@tiptap/extension-document";
@@ -14,7 +14,8 @@ const props = defineProps({
14
14
  variables: { type: Object, required: false },
15
15
  upstreamOutputs: { type: Array, required: false },
16
16
  invalid: { type: Boolean, required: false },
17
- label: { type: String, required: false }
17
+ label: { type: String, required: false },
18
+ multiline: { type: Boolean, required: false }
18
19
  });
19
20
  const emit = defineEmits(["update:modelValue"]);
20
21
  const t = useIntegrationsT();
@@ -76,23 +77,40 @@ const ExprToken = Node.create({
76
77
  };
77
78
  }
78
79
  });
80
+ const lineMode = () => ({ multiline: props.multiline });
81
+ function editableAttributes() {
82
+ return {
83
+ "role": "textbox",
84
+ ...props.multiline ? { "aria-multiline": "true" } : {},
85
+ ...props.label ? { "aria-label": props.label } : {}
86
+ };
87
+ }
88
+ function endOfLine(line) {
89
+ const doc = editor.state.doc;
90
+ const index = Math.min(Math.max(line, 0), doc.childCount - 1);
91
+ let pos = 0;
92
+ doc.forEach((node, offset, i) => {
93
+ if (i === index) pos = offset + node.nodeSize - 1;
94
+ });
95
+ return pos;
96
+ }
79
97
  const editor = new Editor({
80
98
  extensions: [Document, Paragraph, Text, History, ExprToken],
81
- content: textToDoc(text.value),
99
+ content: textToDoc(text.value, lineMode()),
82
100
  editorProps: {
83
- // Single line: swallow Enter (paragraph splits would break the field).
84
- handleKeyDown: (_view, event) => event.key === "Enter" && !event.isComposing,
85
- // A contenteditable region is not a text box to assistive technology
86
- // unless it says so, and its label sits in a sibling element — so it would
87
- // otherwise be an anonymous blob to a screen reader.
88
- attributes: { role: "textbox", ...props.label ? { "aria-label": props.label } : {} }
101
+ // Single line: swallow Enter (paragraph splits would break the field). A
102
+ // multiline field wants exactly that split, so it lets the key through.
103
+ handleKeyDown: (_view, event) => !props.multiline && event.key === "Enter" && !event.isComposing,
104
+ attributes: editableAttributes()
89
105
  },
90
106
  onUpdate({ editor: ed }) {
91
107
  const json = ed.getJSON();
92
- let raw = docToText(json);
108
+ let raw = docToText(json, lineMode());
93
109
  if (hasRawTokenText(json)) {
94
- ed.commands.setContent(textToDoc(raw));
95
- raw = docToText(ed.getJSON());
110
+ const line = ed.state.selection.$from.index(0);
111
+ ed.commands.setContent(textToDoc(raw, lineMode()));
112
+ ed.commands.setTextSelection(endOfLine(line));
113
+ raw = docToText(ed.getJSON(), lineMode());
96
114
  }
97
115
  if (raw !== text.value) emit("update:modelValue", raw);
98
116
  },
@@ -106,10 +124,14 @@ onBeforeUnmount(() => {
106
124
  editor.destroy();
107
125
  });
108
126
  watch(text, (t2) => {
109
- if (docToText(editor.getJSON()) !== t2) {
110
- editor.commands.setContent(textToDoc(t2));
127
+ if (docToText(editor.getJSON(), lineMode()) !== t2) {
128
+ editor.commands.setContent(textToDoc(t2, lineMode()));
111
129
  }
112
130
  });
131
+ watch(() => props.multiline, () => {
132
+ editor.setOptions({ editorProps: { ...editor.options.editorProps, attributes: editableAttributes() } });
133
+ editor.commands.setContent(textToDoc(text.value, lineMode()));
134
+ });
113
135
  function insertToken(expr) {
114
136
  if (editor.isDestroyed) return;
115
137
  editor.chain().focus().insertContent({ type: EXPR_TOKEN_NODE, attrs: { token: expr } }).run();
@@ -158,10 +180,16 @@ const filteredSections = computed(() => {
158
180
  });
159
181
  const open = ref(false);
160
182
  const wrapRef = ref(null);
183
+ const triggerRef = ref(null);
184
+ const listOpen = computed(() => open.value && hasSuggestions.value);
185
+ const pickerId = useId();
161
186
  function toggle() {
162
187
  if (!open.value && !hasSuggestions.value) return;
163
188
  open.value = !open.value;
164
189
  }
190
+ function onTriggerClick(e) {
191
+ if (e.detail === 0) toggle();
192
+ }
165
193
  function onFocusOut(e) {
166
194
  if (wrapRef.value && e.relatedTarget instanceof globalThis.Node && wrapRef.value.contains(e.relatedTarget)) return;
167
195
  open.value = false;
@@ -173,6 +201,13 @@ function onKeydown(e) {
173
201
  open.value = false;
174
202
  }
175
203
  }
204
+ function onPickerKeydown(e) {
205
+ if (e.key !== "Escape" || !open.value) return;
206
+ e.stopPropagation();
207
+ e.preventDefault();
208
+ open.value = false;
209
+ triggerRef.value?.focus();
210
+ }
176
211
  function onDocMousedown(e) {
177
212
  if (wrapRef.value && !wrapRef.value.contains(e.target)) open.value = false;
178
213
  }
@@ -188,24 +223,42 @@ function insert(expr) {
188
223
  </script>
189
224
 
190
225
  <template>
191
- <div ref="wrapRef" class="relative" @focusout="onFocusOut">
226
+ <!-- `keydown` on the widget rather than on the editor alone: with focus on the
227
+ trigger, Escape used to reach the configuration lightbox and close the whole
228
+ dialog. The editor keeps its own handler, which stops the event before it
229
+ arrives here. -->
230
+ <div ref="wrapRef" class="relative" @focusout="onFocusOut" @keydown="onKeydown">
192
231
  <div class="flex items-center gap-1">
193
232
  <div
194
- class="expr-editor relative h-8 min-w-0 flex-1 overflow-hidden rounded-md border bg-white px-2 text-sm transition focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"
195
- :class="invalid ? 'border-rose-400' : 'border-black/[0.08]'"
233
+ class="expr-editor relative min-w-0 flex-1 rounded-md border bg-white px-2 text-sm transition focus-within:outline-2 focus-within:-outline-offset-1 focus-within:outline-ring"
234
+ :class="[
235
+ multiline ? 'expr-multiline' : 'h-8 overflow-hidden',
236
+ invalid ? 'border-rose-400' : 'border-black/[0.08]'
237
+ ]"
196
238
  >
197
239
  <span
198
240
  v-if="!text"
199
- class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 truncate text-muted-foreground/60"
241
+ class="pointer-events-none absolute left-2 truncate text-muted-foreground/60"
242
+ :class="multiline ? 'top-1' : 'top-1/2 -translate-y-1/2'"
200
243
  >{{ placeholder }}</span>
201
- <EditorContent :editor="editor" class="h-full" @keydown="onKeydown" />
244
+ <EditorContent :editor="editor" :class="multiline ? '' : 'h-full'" @keydown="onKeydown" />
202
245
  </div>
246
+ <!-- The name says what the button does AND what it opens, because the glyph
247
+ says neither; `aria-expanded` says whether the list is there, and
248
+ `aria-controls` only while it is — an id pointing at nothing is worse
249
+ than no link. See the docblock on `listOpen` for why this is not the
250
+ shared popover. -->
203
251
  <button
252
+ ref="triggerRef"
204
253
  type="button"
205
254
  :title="t('integration.nodeConfig.expression.insertTitle')"
255
+ :aria-label="t('integration.nodeConfig.expression.insertAria')"
256
+ :aria-expanded="listOpen"
257
+ :aria-controls="listOpen ? pickerId : void 0"
206
258
  class="flex h-8 shrink-0 items-center rounded-md border border-black/[0.08] bg-white px-1.5 font-mono text-sm text-muted-foreground transition hover:text-foreground hover:bg-muted/30"
207
- :class="open ? 'ring-2 ring-emerald-500/40' : ''"
259
+ :class="[listOpen ? 'ring-2 ring-emerald-500/40' : '', multiline ? 'self-start' : '']"
208
260
  @mousedown.prevent="toggle"
261
+ @click="onTriggerClick"
209
262
  >
210
263
  {{ triggerLabel }}
211
264
  </button>
@@ -224,8 +277,10 @@ function insert(expr) {
224
277
  here and in the panel beside the form — and a popup nothing can address
225
278
  is a popup nothing can test either. -->
226
279
  <div
227
- v-if="open && hasSuggestions"
280
+ v-if="listOpen"
281
+ :id="pickerId"
228
282
  role="group"
283
+ @keydown="onPickerKeydown"
229
284
  :aria-label="t('integration.nodeConfig.expression.picker.aria')"
230
285
  class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
231
286
  >
@@ -267,5 +322,5 @@ function insert(expr) {
267
322
  </template>
268
323
 
269
324
  <style>
270
- .expr-editor .ProseMirror{align-items:center;display:flex;height:100%;outline:none;overflow-x:auto;scrollbar-width:none}.expr-editor .ProseMirror p{line-height:30px;margin:0;white-space:pre}.expr-pill{align-items:center;background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.25);border-radius:6px;color:#047857;display:inline-flex;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;height:20px;line-height:1;margin:0 2px;padding:0 7px;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.expr-pill.ProseMirror-selectednode{outline:2px solid rgba(16,185,129,.5)}
325
+ .expr-editor .ProseMirror{align-items:center;display:flex;height:100%;outline:none;overflow-x:auto;scrollbar-width:none}.expr-editor .ProseMirror p{line-height:30px;margin:0;white-space:pre}.expr-editor.expr-multiline{--expr-line:1.375rem;max-height:calc(var(--expr-line)*10 + .5rem);min-height:calc(var(--expr-line)*4 + .5rem);overflow-x:hidden;overflow-y:auto;padding-bottom:.25rem;padding-top:.25rem}.expr-editor.expr-multiline .ProseMirror{display:block;height:auto;overflow-x:visible}.expr-editor.expr-multiline .ProseMirror p{line-height:var(--expr-line);white-space:pre-wrap}.expr-pill{align-items:center;background:rgba(16,185,129,.1);border:1px solid rgba(16,185,129,.25);border-radius:6px;color:#047857;display:inline-flex;font-family:ui-sans-serif,system-ui,sans-serif;font-size:11px;height:20px;line-height:1;margin:0 2px;padding:0 7px;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle;white-space:nowrap}.expr-pill.ProseMirror-selectednode{outline:2px solid rgba(16,185,129,.5)}
271
326
  </style>
@@ -14,6 +14,10 @@ type __VLS_Props = {
14
14
  invalid?: boolean;
15
15
  /** The setting's label, so the editable region has an accessible name. */
16
16
  label?: string;
17
+ /** The setting takes several lines: Enter opens one instead of being
18
+ * swallowed, and the box opens at four rows and grows to ten before it
19
+ * scrolls. */
20
+ multiline?: boolean;
17
21
  };
18
22
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
19
23
  "update:modelValue": (value: string) => any;
@@ -4,6 +4,7 @@ import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
5
  import { ChevronsDownUp, ChevronsUpDown, Ungroup } from "lucide-vue-next";
6
6
  import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
7
+ import { inputHandleId, outputHandleId } from "../../data/handleId";
7
8
  import { resolveDisplayName } from "../../utils/localize";
8
9
  const props = defineProps({
9
10
  id: { type: String, required: true },
@@ -100,9 +101,9 @@ function onResizeEnd() {
100
101
  v-for="(c, i) in GROUP_COLORS"
101
102
  :key="i"
102
103
  type="button"
103
- class="size-4.5 rounded-full border transition hover:scale-110"
104
+ class="size-6 rounded-full border transition hover:scale-110"
104
105
  :class="(data.group.color ?? 4) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
105
- :style="{ backgroundColor: c.chip, borderColor: c.border, width: '16px', height: '16px' }"
106
+ :style="{ backgroundColor: c.chip, borderColor: c.border }"
106
107
  :title="t(c.nameKey)"
107
108
  :aria-label="t(c.nameKey)"
108
109
  @click="data.actions?.setColor(i)"
@@ -138,7 +139,7 @@ function onResizeEnd() {
138
139
  <!-- Collapsed: compact stand-in card with generic edge handles. -->
139
140
  <template v-if="collapsed">
140
141
  <Handle
141
- id="__in__top"
142
+ :id="inputHandleId('top')"
142
143
  type="target"
143
144
  :position="Position.Top"
144
145
  class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
@@ -165,7 +166,7 @@ function onResizeEnd() {
165
166
  </div>
166
167
  </div>
167
168
  <Handle
168
- id="default__bottom"
169
+ :id="outputHandleId('default', 'bottom')"
169
170
  type="source"
170
171
  :position="Position.Bottom"
171
172
  class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a surface is doing, in words, for somebody who cannot see the shimmer.
3
+ *
4
+ * A studio surface that is waiting draws placeholder blocks and — before PO-251 —
5
+ * said nothing, which is exactly what it says when there is genuinely nothing to
6
+ * show. Those two states ask for opposite reactions: wait, or go and do something
7
+ * about it. The shimmer separates them for a sighted reader and is not information a
8
+ * screen reader can reach.
9
+ *
10
+ * Three things this component exists to keep from drifting, all of them learned on
11
+ * the template gallery (PO-246), which was the first surface to get this right:
12
+ *
13
+ * - **It is always in the DOM**, and only its text changes. A live region that
14
+ * appears together with its content is a region assistive technology may never
15
+ * have been watching: what gets announced is a change *inside* an existing region.
16
+ * So mount this outside the `v-if` chain it describes, never inside the branch
17
+ * that draws the placeholders.
18
+ * - **It is named**, because a page can hold more than one. The template gallery
19
+ * draws one per card for its flow miniature; the dashboard has four panels that
20
+ * each load on their own. Two unnamed status regions on one page are two things
21
+ * neither a reader nor a test can tell apart.
22
+ * - **`aria-busy` is not what this is.** The attribute only asks assistive
23
+ * technology to defer exposing changes to an element, and on a role-less `div`
24
+ * that is not a live region NVDA and JAWS effectively ignore it — it expresses the
25
+ * state without keeping anything from being walked. The placeholders themselves
26
+ * get `aria-hidden` at the call site instead: a placeholder carries no
27
+ * information, so there is nothing to lose by removing it from the tree.
28
+ */
29
+ type __VLS_Props = {
30
+ /** Names the region, so several on one page stay distinguishable. */
31
+ label: string;
32
+ /** Whether the surface this speaks for is waiting. */
33
+ loading: boolean;
34
+ /** What to say while it is — "Loading the runs…", in the surface's own words. */
35
+ text: string;
36
+ };
37
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
38
+ export default _default;
@@ -0,0 +1,16 @@
1
+ <script setup>
2
+ const props = defineProps({
3
+ label: { type: String, required: true },
4
+ loading: { type: Boolean, required: true },
5
+ text: { type: String, required: true }
6
+ });
7
+ </script>
8
+
9
+ <template>
10
+ <p
11
+ role="status"
12
+ aria-live="polite"
13
+ :aria-label="props.label"
14
+ class="sr-only"
15
+ >{{ props.loading ? props.text : "" }}</p>
16
+ </template>
@@ -0,0 +1,38 @@
1
+ /**
2
+ * What a surface is doing, in words, for somebody who cannot see the shimmer.
3
+ *
4
+ * A studio surface that is waiting draws placeholder blocks and — before PO-251 —
5
+ * said nothing, which is exactly what it says when there is genuinely nothing to
6
+ * show. Those two states ask for opposite reactions: wait, or go and do something
7
+ * about it. The shimmer separates them for a sighted reader and is not information a
8
+ * screen reader can reach.
9
+ *
10
+ * Three things this component exists to keep from drifting, all of them learned on
11
+ * the template gallery (PO-246), which was the first surface to get this right:
12
+ *
13
+ * - **It is always in the DOM**, and only its text changes. A live region that
14
+ * appears together with its content is a region assistive technology may never
15
+ * have been watching: what gets announced is a change *inside* an existing region.
16
+ * So mount this outside the `v-if` chain it describes, never inside the branch
17
+ * that draws the placeholders.
18
+ * - **It is named**, because a page can hold more than one. The template gallery
19
+ * draws one per card for its flow miniature; the dashboard has four panels that
20
+ * each load on their own. Two unnamed status regions on one page are two things
21
+ * neither a reader nor a test can tell apart.
22
+ * - **`aria-busy` is not what this is.** The attribute only asks assistive
23
+ * technology to defer exposing changes to an element, and on a role-less `div`
24
+ * that is not a live region NVDA and JAWS effectively ignore it — it expresses the
25
+ * state without keeping anything from being walked. The placeholders themselves
26
+ * get `aria-hidden` at the call site instead: a placeholder carries no
27
+ * information, so there is nothing to lose by removing it from the tree.
28
+ */
29
+ type __VLS_Props = {
30
+ /** Names the region, so several on one page stay distinguishable. */
31
+ label: string;
32
+ /** Whether the surface this speaks for is waiting. */
33
+ loading: boolean;
34
+ /** What to say while it is — "Loading the runs…", in the surface's own words. */
35
+ text: string;
36
+ };
37
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
38
+ export default _default;
@@ -195,6 +195,9 @@ function removeKvRow(idx) {
195
195
  kvRows.value = kvRows.value.filter((_, i) => i !== idx);
196
196
  commitKvRows();
197
197
  }
198
+ const isMultiline = computed(
199
+ () => Boolean(props.field.multiline) && (props.field.type === "string" || props.field.type === "expression")
200
+ );
198
201
  const isQueryField = computed(
199
202
  () => props.field.type === "array" && props.field.items?.type !== "object" && /(^|__)queries$/.test(props.field.key)
200
203
  );
@@ -383,6 +386,7 @@ function setArrayItemField(index, fieldKey, v) {
383
386
  :upstream-outputs="upstreamOutputs"
384
387
  :invalid="invalid"
385
388
  :label="resolveLocalized(field.label)"
389
+ :multiline="isMultiline"
386
390
  @update:model-value="(v) => write(v)"
387
391
  />
388
392
 
@@ -409,6 +413,25 @@ function setArrayItemField(index, fieldKey, v) {
409
413
  :upstream-outputs="upstreamOutputs"
410
414
  :invalid="invalid"
411
415
  :label="resolveLocalized(field.label)"
416
+ :multiline="isMultiline"
417
+ @update:model-value="(v) => write(v)"
418
+ />
419
+
420
+ <!-- plain string the node says takes several lines. Same accessible name as
421
+ the one-line box below, because it is the same setting. Opens at four
422
+ rows, grows with what is typed and scrolls past ten — the same rule the
423
+ expression editor follows, so the two controls for one kind of setting
424
+ do not behave differently. The two heights are lines × 1.25rem plus the
425
+ 1rem of vertical padding; `rows` alone would not set them, because the
426
+ shared control sizes itself to its content and ignores it. -->
427
+ <Textarea
428
+ v-else-if="field.type === 'string' && isMultiline"
429
+ :aria-label="resolveLocalized(field.label)"
430
+ :model-value="value ?? ''"
431
+ :placeholder="resolveLocalized(field.placeholder)"
432
+ :rows="4"
433
+ class="min-h-24 max-h-[13.5rem] resize-none rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
434
+ :class="invalid ? 'border-rose-400 focus-visible:ring-rose-400/40' : ''"
412
435
  @update:model-value="(v) => write(v)"
413
436
  />
414
437
 
@@ -29,13 +29,13 @@ type __VLS_Props = {
29
29
  };
30
30
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
31
31
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
- close: () => any;
33
32
  remove: () => any;
33
+ close: () => any;
34
34
  navigate: (id: string) => any;
35
35
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
36
36
  onUpdate?: ((next: WorkflowTrigger | WorkflowNode) => any) | undefined;
37
- onClose?: (() => any) | undefined;
38
37
  onRemove?: (() => any) | undefined;
38
+ onClose?: (() => any) | undefined;
39
39
  onNavigate?: ((id: string) => any) | undefined;
40
40
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
41
41
  export default _default;
@@ -39,6 +39,8 @@ import {
39
39
  } from "../../data/nodeInputData";
40
40
  import { portDotClass } from "../../data/portTone";
41
41
  import { computeLoopBodies, manifestIterates } from "../../data/loopBody";
42
+ import { deepClone } from "../../utils/deepClone";
43
+ import { copyToClipboard } from "../../utils/clipboard";
42
44
  import { nodeDisplayLabel } from "../../utils/validationFindings";
43
45
  import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
44
46
  import {
@@ -49,6 +51,7 @@ import {
49
51
  } from "../../data/nodeRunData";
50
52
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
51
53
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
54
+ import { errorReason } from "../../utils/apiError";
52
55
  const t = useIntegrationsT();
53
56
  const props = defineProps({
54
57
  selection: { type: [Object, null], required: true },
@@ -64,19 +67,16 @@ const props = defineProps({
64
67
  refusedFields: { type: Object, required: false }
65
68
  });
66
69
  const emit = defineEmits(["update", "close", "remove", "navigate"]);
67
- function clone(v) {
68
- return JSON.parse(JSON.stringify(v));
69
- }
70
- const draft = ref(clone(props.selection));
70
+ const draft = ref(deepClone(props.selection));
71
71
  watch(() => props.selection?.id, () => {
72
- if (props.selection && props.selection.id !== draft.value.id) draft.value = clone(props.selection);
72
+ if (props.selection && props.selection.id !== draft.value.id) draft.value = deepClone(props.selection);
73
73
  });
74
74
  const isNode = computed(() => "nodeSlug" in draft.value);
75
75
  const draftNode = computed(() => isNode.value ? draft.value : null);
76
76
  const draftTrigger = computed(() => isNode.value ? null : draft.value);
77
77
  const dirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(props.selection));
78
78
  function save() {
79
- if (dirty.value) emit("update", clone(draft.value));
79
+ if (dirty.value) emit("update", deepClone(draft.value));
80
80
  emit("close");
81
81
  }
82
82
  const nameInput = ref(null);
@@ -203,12 +203,10 @@ function patchTrigger(patch) {
203
203
  }
204
204
  async function copyConfig() {
205
205
  if (!draftNode.value) return;
206
- try {
207
- await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
208
- toast.success(t("integration.nodeInspector.toast.configCopied"));
209
- } catch {
210
- toast.error(t("integration.nodeInspector.toast.configCopyFailed"));
211
- }
206
+ await copyToClipboard(JSON.stringify(draftNode.value.config ?? {}, null, 2), {
207
+ copied: t("integration.nodeInspector.toast.configCopied"),
208
+ failed: t("integration.nodeInspector.toast.configCopyFailed")
209
+ });
212
210
  }
213
211
  async function pasteConfig() {
214
212
  if (!draftNode.value) return;
@@ -440,28 +438,24 @@ async function rotateEndpointSecret() {
440
438
  toast.error(t("integration.nodeInspector.toast.rotateNoSecret"));
441
439
  }
442
440
  } catch (e) {
443
- toast.error(e.message || t("integration.nodeInspector.toast.rotateFailed"));
441
+ toast.error(errorReason(e, t("integration.nodeInspector.toast.rotateFailed")));
444
442
  } finally {
445
443
  rotateBusy.value = false;
446
444
  }
447
445
  }
448
446
  async function copySecret() {
449
447
  if (!rotatedSecret.value) return;
450
- try {
451
- await navigator.clipboard.writeText(rotatedSecret.value);
452
- toast.success(t("integration.nodeInspector.toast.secretCopied"));
453
- } catch {
454
- toast.error(t("integration.nodeInspector.toast.secretCopyFailed"));
455
- }
448
+ await copyToClipboard(rotatedSecret.value, {
449
+ copied: t("integration.nodeInspector.toast.secretCopied"),
450
+ failed: t("integration.nodeInspector.toast.secretCopyFailed")
451
+ });
456
452
  }
457
453
  async function copyWebhookUrl() {
458
454
  if (!webhookUrl.value) return;
459
- try {
460
- await navigator.clipboard.writeText(webhookUrl.value);
461
- toast.success(t("integration.nodeInspector.toast.webhookUrlCopied"));
462
- } catch {
463
- toast.error(t("integration.nodeInspector.toast.webhookUrlCopyFailed"));
464
- }
455
+ await copyToClipboard(webhookUrl.value, {
456
+ copied: t("integration.nodeInspector.toast.webhookUrlCopied"),
457
+ failed: t("integration.nodeInspector.toast.webhookUrlCopyFailed")
458
+ });
465
459
  }
466
460
  const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
467
461
  } };
@@ -526,7 +520,7 @@ async function loadRunData() {
526
520
  runData.value = map;
527
521
  runInfo.value = { id: source.id, startedAt: source.startedAt };
528
522
  } catch (e) {
529
- runError.value = e?.message ?? t("integration.nodeInspector.input.runData.loadFailed");
523
+ runError.value = errorReason(e, t("integration.nodeInspector.input.runData.loadFailed"));
530
524
  } finally {
531
525
  runLoading.value = false;
532
526
  }
@@ -640,11 +634,10 @@ function insertExpression(expression) {
640
634
  el.focus();
641
635
  return;
642
636
  }
643
- try {
644
- void navigator.clipboard?.writeText(expression);
645
- } catch {
646
- }
647
- toast(t("integration.nodeInspector.toast.expressionCopied"));
637
+ void copyToClipboard(expression, {
638
+ copied: t("integration.nodeInspector.toast.expressionCopied"),
639
+ failed: t("integration.nodeInspector.toast.expressionCopyFailed")
640
+ });
648
641
  }
649
642
  const chipClass = "max-w-full cursor-grab truncate rounded-md border border-black/[0.06] bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 transition hover:border-black/[0.12] hover:bg-slate-100 active:cursor-grabbing";
650
643
  const rowClass = "flex w-full min-w-0 cursor-grab items-center gap-1.5 rounded-md px-1.5 py-[3px] text-left font-mono text-[11px] text-slate-700 transition hover:bg-slate-100 active:cursor-grabbing";
@@ -767,7 +760,7 @@ const hasNext = computed(() => seqIndex.value >= 0 && seqIndex.value < sequence.
767
760
  function navigate(offset) {
768
761
  const target = sequence.value[seqIndex.value + offset];
769
762
  if (!target) return;
770
- if (dirty.value) emit("update", clone(draft.value));
763
+ if (dirty.value) emit("update", deepClone(draft.value));
771
764
  emit("navigate", target);
772
765
  }
773
766
  const outputView = ref("schema");