@revenexx/studio-integrations 1.4.0 → 2.0.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.
@@ -51,8 +51,7 @@ import { toast } from "vue-sonner";
51
51
  import WorkflowEdgeView from "./WorkflowEdgeView.vue";
52
52
  import { layoutWorkflowNodes } from "./workflowLayout";
53
53
  import { defaultsFor } from "../../utils/configDefaults";
54
- import { nodeDisplayLabel } from "../../utils/validationFindings";
55
- import { TRIGGER_TEMPLATES, findManifest, newestExecutableVersion } from "../../data/integrationNodeCatalog";
54
+ import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
56
55
  import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
57
56
  import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
58
57
  import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
@@ -865,17 +864,7 @@ function revealNode(id) {
865
864
  selectedId.value = id;
866
865
  }
867
866
  }
868
- function showNodeOnCanvas(id) {
869
- const folded = collapsedGroupOf().get(id);
870
- if (folded) patchGroup(folded.id, { collapsed: false });
871
- if (blob.value.nodes.some((n) => n.id === id)) {
872
- markBorn(id);
873
- syncFromBlob();
874
- }
875
- if (paneReady.value) centerOnNode(id);
876
- else pendingReveal.value = id;
877
- }
878
- defineExpose({ revealNode, showNodeOnCanvas, attachErrorStop });
867
+ defineExpose({ revealNode });
879
868
  const selectedUpstreamOutputs = computed(() => {
880
869
  const id = selectedId.value;
881
870
  if (!id) return [];
@@ -1108,45 +1097,6 @@ function spliceIntoEdge(edgeId, payload) {
1108
1097
  syncFromBlob();
1109
1098
  push();
1110
1099
  }
1111
- const STOP_AND_ERROR_SLUG = "revenexx:stop-and-error";
1112
- function attachErrorStop(sourceNodeId, port) {
1113
- const source = blob.value.nodes.find((n) => n.id === sourceNodeId);
1114
- const version = newestExecutableVersion(STOP_AND_ERROR_SLUG);
1115
- const manifest = version ? findManifest(STOP_AND_ERROR_SLUG, version) : null;
1116
- if (!source || !manifest) return;
1117
- const at = nodePosition(sourceNodeId);
1118
- const id = `n-${Date.now()}`;
1119
- blob.value.nodes.push({
1120
- id,
1121
- nodeSlug: manifest.slug,
1122
- nodeVersion: manifest.version,
1123
- config: {
1124
- ...defaultsFor(manifest.config ?? []),
1125
- // The template convention verbatim: name the step that failed, then quote
1126
- // its own message, so the run record says what went wrong rather than only
1127
- // that something did. `errorCode` keeps the node's own default.
1128
- message: t("integration.editor.errorPath.message", {
1129
- label: nodeDisplayLabel(source),
1130
- nodeId: source.id,
1131
- port
1132
- })
1133
- },
1134
- // Below the source and on the grid, the way the templates place theirs.
1135
- // `freeSpotNear` keeps two of them off each other.
1136
- position: freeSpotNear({ x: at.x, y: at.y + 180 })
1137
- });
1138
- blob.value.edges.push({
1139
- id: `e-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`,
1140
- from: { nodeId: sourceNodeId, output: port },
1141
- to: { nodeId: id, input: void 0 }
1142
- });
1143
- markBorn(id);
1144
- syncFromBlob();
1145
- if (paneReady.value) centerOnNode(id);
1146
- else pendingReveal.value = id;
1147
- push();
1148
- toast.info(t("integration.editor.errorPath.toast.added", { label: nodeDisplayLabel(source) }));
1149
- }
1150
1100
  function nodeDims(id) {
1151
1101
  const d = flow.findNode(id)?.dimensions;
1152
1102
  return d && d.width > 0 ? d : { width: 220, height: 64 };
@@ -42,42 +42,8 @@ type __VLS_Props = {
42
42
  * id that is on the canvas is in the blob by construction.
43
43
  */
44
44
  declare function revealNode(id: string): void;
45
- /**
46
- * Point at a node without opening it — for a reader who has to look at the
47
- * card's *surroundings* rather than at its settings.
48
- *
49
- * The deliberate difference from {@link revealNode} is the missing selection,
50
- * and with it the missing settings lightbox. That is the whole reason there are
51
- * two functions: the validation banner sends someone to a field at fault, and
52
- * the dialog is where that field is. The error-path advice sends them to a port
53
- * with nothing on it, and the question they have to answer — where should this
54
- * failure go? — is answered by the graph around the card, which a dialog over
55
- * the canvas covers up.
56
- *
57
- * Marked with the same pulse a newly created card gets, because the job is the
58
- * same one: say which of thirty cards is the one meant.
59
- */
60
- declare function showNodeOnCanvas(id: string): void;
61
- /**
62
- * Hang a Stop-and-Error step off a node's error output and wire it up
63
- * (specs/workflow-advice.md AC-3).
64
- *
65
- * The advice strip can tell an author their error output leads nowhere; on its
66
- * own that is a reprimand without a remedy, because the repair — drop a node,
67
- * pick the right one, connect it, write an expression quoting the failing step's
68
- * message — is four steps of tedium they have to already know. Every template we
69
- * ship does exactly this, twenty-five times over, so the shape is settled and
70
- * worth one click.
71
- *
72
- * Leaves the result unsaved and unopened, like every other insertion here: the
73
- * new step is highlighted and the author decides whether to keep it. Deleting it
74
- * is the undo, because this editor has no undo stack to hook into.
75
- */
76
- declare function attachErrorStop(sourceNodeId: string, port: string): void;
77
45
  declare const _default: import("vue").DefineComponent<__VLS_Props, {
78
46
  revealNode: typeof revealNode;
79
- showNodeOnCanvas: typeof showNodeOnCanvas;
80
- attachErrorStop: typeof attachErrorStop;
81
47
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
82
48
  run: () => any;
83
49
  toggleActive: () => any;
@@ -28,8 +28,8 @@
28
28
  "integration.audit.table.column.actor": "Akteur",
29
29
  "integration.audit.table.column.resource": "Ressource",
30
30
  "integration.audit.table.column.when": "Wann",
31
- "integration.audit.toast.copyFailed": "Kopieren fehlgeschlagen",
32
31
  "integration.audit.toast.copied": "Kopiert",
32
+ "integration.audit.toast.copyFailed": "Kopieren fehlgeschlagen",
33
33
  "integration.canvas.badge.trigger": "Trigger",
34
34
  "integration.canvas.color.amber": "Bernstein",
35
35
  "integration.canvas.color.blue": "Blau",
@@ -170,6 +170,8 @@
170
170
  "integration.credentials.error.oauthConnect": "OAuth-Verbindung konnte nicht gestartet werden.",
171
171
  "integration.credentials.error.save": "Zugangsdaten konnten nicht gespeichert werden.",
172
172
  "integration.credentials.error.testFailed": "Verbindungstest fehlgeschlagen.",
173
+ "integration.credentials.list.action.connect": "Verbinden",
174
+ "integration.credentials.list.action.connect.aria": "{name} verbinden",
173
175
  "integration.credentials.list.action.delete.aria": "{name} löschen",
174
176
  "integration.credentials.list.action.new": "Zugangsdaten anlegen",
175
177
  "integration.credentials.list.action.reconnect": "Erneut verbinden",
@@ -244,6 +246,7 @@
244
246
  "integration.credentials.sftpImport.empty": "Keine aktiven Plattform-SFTP-Konten. Zuerst eines unter Integration Studio → SFTP anlegen.",
245
247
  "integration.credentials.sftpImport.error.importFailed": "Import fehlgeschlagen.",
246
248
  "integration.credentials.sftpImport.error.readPassword": "Das Kontopasswort konnte nicht gelesen werden.",
249
+ "integration.credentials.sftpImport.state.loadFailed": "Die Plattform-SFTP-Konten konnten nicht geladen werden: {message}",
247
250
  "integration.credentials.sftpImport.state.loading": "SFTP-Konten werden geladen…",
248
251
  "integration.credentials.sftpImport.title": "Plattform-SFTP-Konto importieren",
249
252
  "integration.credentials.shared.action.testConnection": "Verbindung testen",
@@ -377,13 +380,6 @@
377
380
  "integration.editor.dropGhost.label": "Loslassen zum Hinzufügen",
378
381
  "integration.editor.emptyState.hint": "Ziehen Sie einen Trigger und eine Aktion aus dem Panel links.",
379
382
  "integration.editor.emptyState.title": "Leere Arbeitsfläche",
380
- "integration.editor.errorPath.action.stop": "Fehlerausgang stoppen lassen",
381
- "integration.editor.errorPath.advice": "{n} Node leitet Fehler auf einen Ausgang, an dem nichts hängt — der Fehler wird dort nicht behandelt. | {n} Nodes leiten Fehler auf einen Ausgang, an dem nichts hängt — der Fehler wird dort nicht behandelt.",
382
- "integration.editor.errorPath.ariaFix": "Ausgang „{port}“ von {label} auf Stop und Fehler legen",
383
- "integration.editor.errorPath.ariaReveal": "{label} auf der Canvas zeigen — der Ausgang „{port}“ ist nicht verbunden",
384
- "integration.editor.errorPath.message": "{label} fehlgeschlagen: ${{ nodes.{nodeId}.outputs.{port}.message }}",
385
- "integration.editor.errorPath.more": "…und {n} weitere",
386
- "integration.editor.errorPath.toast.added": "Stop und Fehler an {label} gehängt — noch nicht gespeichert.",
387
383
  "integration.editor.event.toast.noneReceived": "Noch kein {topic}-Event empfangen.",
388
384
  "integration.editor.executionMode.async.hint": "Runs werden immer in die Warteschlange gestellt; der Trigger antwortet sofort.",
389
385
  "integration.editor.executionMode.async.label": "Nur asynchron",
@@ -983,10 +979,8 @@
983
979
  "integration.runs.toast.action.requested": "{action} angefordert",
984
980
  "integration.runs.toast.action.started": "Run #{id} gestartet",
985
981
  "integration.runs.toast.terminate.confirm": "Run #{id} terminieren?",
986
- "integration.schedules.cell.cronExpression.aria": "Cron-Ausdruck",
987
982
  "integration.schedules.cell.customSchedule": "Individueller Zeitplan",
988
983
  "integration.schedules.cell.noSchedule": "Kein Zeitplan festgelegt",
989
- "integration.schedules.cell.whenItFires.aria": "Wann er ausgelöst wird",
990
984
  "integration.schedules.cron.dailyAt": "Täglich um {time}",
991
985
  "integration.schedules.cron.day.daily": "Täglich",
992
986
  "integration.schedules.cron.day.weekdays": "Wochentags",
@@ -1229,6 +1223,7 @@
1229
1223
  "integration.workflows.state.action.remove.hint": "Das Entfernen nimmt diesem Workflow den Zugriff. Die gespeicherten Einträge bleiben bestehen.",
1230
1224
  "integration.workflows.state.dialog.description": "Was sich dieser Workflow zwischen Läufen merken darf. Er erreicht ausschließlich die hier deklarierten Namespaces — alles andere wird beim Lauf abgewiesen.",
1231
1225
  "integration.workflows.state.dialog.title": "Zustands-Namespaces",
1226
+ "integration.workflows.state.empty": "Dieser Workflow merkt sich noch nichts zwischen Läufen.",
1232
1227
  "integration.workflows.state.error.invalidName": "Kleinbuchstaben, Ziffern und . - _, beginnend mit einem Buchstaben — zum Beispiel article oder crm.customers.",
1233
1228
  "integration.workflows.state.error.nameExists": "Dieser Workflow deklariert diesen Namespace bereits.",
1234
1229
  "integration.workflows.state.field.newName.label": "Namespace deklarieren",
@@ -1246,7 +1241,6 @@
1246
1241
  "integration.workflows.state.role.mapping.hint": "Verknüpft je eine ID auf beiden Seiten, damit ein späterer Lauf weiß, ob er anlegen oder aktualisieren muss.",
1247
1242
  "integration.workflows.state.role.mapping.label": "Zuordnung",
1248
1243
  "integration.workflows.state.row.aria": "Zustands-Namespace {namespace}",
1249
- "integration.workflows.state.empty": "Dieser Workflow merkt sich noch nichts zwischen Läufen.",
1250
1244
  "integration.workflows.state.visibility.private.hint": "Nur dieser Workflow liest und schreibt ihn, und er wird mit dem Workflow gelöscht.",
1251
1245
  "integration.workflows.state.visibility.private.label": "Diesen Workflow",
1252
1246
  "integration.workflows.state.visibility.shared.hint": "Jeder Workflow dieses Mandanten liest und schreibt diese Einträge, und der Namespace bleibt bestehen, wenn dieser Workflow gelöscht wird.",
@@ -28,8 +28,8 @@
28
28
  "integration.audit.table.column.actor": "Actor",
29
29
  "integration.audit.table.column.resource": "Resource",
30
30
  "integration.audit.table.column.when": "When",
31
- "integration.audit.toast.copyFailed": "Could not copy",
32
31
  "integration.audit.toast.copied": "Copied",
32
+ "integration.audit.toast.copyFailed": "Could not copy",
33
33
  "integration.canvas.badge.trigger": "Trigger",
34
34
  "integration.canvas.color.amber": "Amber",
35
35
  "integration.canvas.color.blue": "Blue",
@@ -170,6 +170,8 @@
170
170
  "integration.credentials.error.oauthConnect": "Could not start OAuth connect.",
171
171
  "integration.credentials.error.save": "Could not save credential.",
172
172
  "integration.credentials.error.testFailed": "Connection test failed.",
173
+ "integration.credentials.list.action.connect": "Connect",
174
+ "integration.credentials.list.action.connect.aria": "Connect {name}",
173
175
  "integration.credentials.list.action.delete.aria": "Delete {name}",
174
176
  "integration.credentials.list.action.new": "New credential",
175
177
  "integration.credentials.list.action.reconnect": "Reconnect",
@@ -244,6 +246,7 @@
244
246
  "integration.credentials.sftpImport.empty": "No active platform SFTP accounts. Create one under Integration Studio → SFTP first.",
245
247
  "integration.credentials.sftpImport.error.importFailed": "Import failed.",
246
248
  "integration.credentials.sftpImport.error.readPassword": "Could not read the account password.",
249
+ "integration.credentials.sftpImport.state.loadFailed": "The platform SFTP accounts could not be loaded: {message}",
247
250
  "integration.credentials.sftpImport.state.loading": "Loading the SFTP accounts…",
248
251
  "integration.credentials.sftpImport.title": "Import platform SFTP account",
249
252
  "integration.credentials.shared.action.testConnection": "Connect",
@@ -377,13 +380,6 @@
377
380
  "integration.editor.dropGhost.label": "Drop to add",
378
381
  "integration.editor.emptyState.hint": "Drag a trigger and an action from the panel on the left.",
379
382
  "integration.editor.emptyState.title": "Empty canvas",
380
- "integration.editor.errorPath.action.stop": "Stop the run on error",
381
- "integration.editor.errorPath.advice": "{n} node routes failures to an output with nothing attached — nothing handles the failure there. | {n} nodes route failures to an output with nothing attached — nothing handles the failure there.",
382
- "integration.editor.errorPath.ariaFix": "Route {label}'s \"{port}\" output to Stop and Error",
383
- "integration.editor.errorPath.ariaReveal": "Show {label} on the canvas — its \"{port}\" output is not connected",
384
- "integration.editor.errorPath.message": "{label} failed: ${{ nodes.{nodeId}.outputs.{port}.message }}",
385
- "integration.editor.errorPath.more": "…and {n} more",
386
- "integration.editor.errorPath.toast.added": "Stop and Error attached to {label} — not saved yet.",
387
383
  "integration.editor.event.toast.noneReceived": "No {topic} event received yet.",
388
384
  "integration.editor.executionMode.async.hint": "Runs are always queued; the trigger returns immediately.",
389
385
  "integration.editor.executionMode.async.label": "Async only",
@@ -983,10 +979,8 @@
983
979
  "integration.runs.toast.action.requested": "{action} requested",
984
980
  "integration.runs.toast.action.started": "Started run #{id}",
985
981
  "integration.runs.toast.terminate.confirm": "Terminate run #{id}?",
986
- "integration.schedules.cell.cronExpression.aria": "Cron expression",
987
982
  "integration.schedules.cell.customSchedule": "Custom schedule",
988
983
  "integration.schedules.cell.noSchedule": "No schedule set",
989
- "integration.schedules.cell.whenItFires.aria": "When it fires",
990
984
  "integration.schedules.cron.dailyAt": "Daily at {time}",
991
985
  "integration.schedules.cron.day.daily": "Daily",
992
986
  "integration.schedules.cron.day.weekdays": "Weekdays",
@@ -1229,6 +1223,7 @@
1229
1223
  "integration.workflows.state.action.remove.hint": "Removing the declaration takes this workflow's access away. The stored entries stay where they are.",
1230
1224
  "integration.workflows.state.dialog.description": "What this workflow may remember between runs. It can only reach the namespaces declared here — anything else is refused when the workflow runs.",
1231
1225
  "integration.workflows.state.dialog.title": "State namespaces",
1226
+ "integration.workflows.state.empty": "This workflow remembers nothing between runs yet.",
1232
1227
  "integration.workflows.state.error.invalidName": "Use lowercase letters, digits and . - _, starting with a letter — for example article or crm.customers.",
1233
1228
  "integration.workflows.state.error.nameExists": "This workflow already declares that namespace.",
1234
1229
  "integration.workflows.state.field.newName.label": "Declare a namespace",
@@ -1246,7 +1241,6 @@
1246
1241
  "integration.workflows.state.role.mapping.hint": "Correlates an id on each side, so a later run knows whether to create or update.",
1247
1242
  "integration.workflows.state.role.mapping.label": "Mapping",
1248
1243
  "integration.workflows.state.row.aria": "State namespace {namespace}",
1249
- "integration.workflows.state.empty": "This workflow remembers nothing between runs yet.",
1250
1244
  "integration.workflows.state.visibility.private.hint": "Only this workflow reads and writes it, and it is deleted along with the workflow.",
1251
1245
  "integration.workflows.state.visibility.private.label": "This workflow",
1252
1246
  "integration.workflows.state.visibility.shared.hint": "Every workflow in this tenant reads and writes these entries, and the namespace stays when this workflow is deleted.",
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, onMounted, reactive, ref } from "vue";
2
+ import { computed, nextTick, onMounted, reactive, ref } from "vue";
3
3
  import {
4
4
  RefreshCircle as Loader2,
5
5
  AddCircle as Plus,
@@ -31,8 +31,8 @@ import {
31
31
  SelectValue
32
32
  } from "@revenexx/studio";
33
33
  import { toast } from "../../utils/notify";
34
- import { apiFieldErrors, errorReason } from "../../utils/apiError";
35
- import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
34
+ import { apiRefusal, errorReason } from "../../utils/apiError";
35
+ import { CREDENTIAL_STATES, credentialState, credentialStateLabel, providerStepWord } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
38
38
  const t = useIntegrationsT();
@@ -51,6 +51,17 @@ function typeName(slug) {
51
51
  function isOAuthAuthcode(t2) {
52
52
  return t2?.auth_kind === "oauth2-authcode";
53
53
  }
54
+ function providerStep(c) {
55
+ if (!isOAuthAuthcode(typeFor(c.credential_type_slug))) return null;
56
+ const state = credentialState(c).key;
57
+ const word = providerStepWord(state);
58
+ if (!word) return null;
59
+ return {
60
+ label: t(`integration.credentials.list.action.${word}`),
61
+ aria: t(`integration.credentials.list.action.${word}.aria`, { name: c.name }),
62
+ expired: state === "needs_reauth"
63
+ };
64
+ }
54
65
  function formatDateTime(iso) {
55
66
  if (!iso) return "\u2014";
56
67
  try {
@@ -92,19 +103,36 @@ const form = reactive({ name: "", config: {} });
92
103
  const saving = ref(false);
93
104
  const testing = ref(false);
94
105
  const testResult = ref(null);
95
- const saveError = ref(null);
96
- const fieldErrors = ref({});
106
+ const saveRefusal = ref(null);
97
107
  const sourceActive = ref(false);
98
108
  const isEdit = computed(() => editing.value !== null);
99
109
  function fieldsOf(t2) {
100
110
  return t2?.fields ?? [];
101
111
  }
112
+ const drawnKeys = computed(() => [
113
+ "name",
114
+ ...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
115
+ ]);
116
+ const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
117
+ const fieldErrors = computed(() => refusal.value.atFields);
118
+ const summaryReasons = computed(() => {
119
+ if (!saveRefusal.value) return [];
120
+ if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
121
+ if (Object.keys(refusal.value.atFields).length) return [];
122
+ return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
123
+ });
102
124
  function configFieldAria(key) {
103
125
  return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
104
126
  }
127
+ async function focusFirstRefusedField() {
128
+ await nextTick();
129
+ const key = drawnKeys.value.find((k) => k in fieldErrors.value);
130
+ if (!key) return;
131
+ const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
132
+ document.getElementById(id)?.focus();
133
+ }
105
134
  function clearRefusal() {
106
- saveError.value = null;
107
- fieldErrors.value = {};
135
+ saveRefusal.value = null;
108
136
  }
109
137
  function openCreate(t2) {
110
138
  editing.value = null;
@@ -176,9 +204,9 @@ async function submit() {
176
204
  }
177
205
  }
178
206
  } catch (e) {
179
- fieldErrors.value = apiFieldErrors(e);
180
- saveError.value = errorReason(e, t("integration.credentials.error.save"));
181
- toast.error(saveError.value);
207
+ saveRefusal.value = e;
208
+ void focusFirstRefusedField();
209
+ toast.error(errorReason(e, t("integration.credentials.error.save")));
182
210
  } finally {
183
211
  saving.value = false;
184
212
  }
@@ -289,15 +317,16 @@ async function confirmDelete() {
289
317
  <template #cell-actions="{ row: c }">
290
318
  <div class="flex items-center justify-end gap-2 text-sm" @click.stop>
291
319
  <Button
292
- v-if="c.status === 'needs_reauth' && isOAuthAuthcode(typeFor(c.credential_type_slug))"
320
+ v-if="providerStep(c)"
293
321
  variant="outline"
294
322
  size="sm"
295
- class="h-7 text-amber-600"
323
+ class="h-7"
324
+ :class="providerStep(c).expired ? 'text-amber-600' : ''"
296
325
  :disabled="connecting === c.id"
297
- :aria-label="t('integration.credentials.list.action.reconnect.aria', { name: c.name })"
326
+ :aria-label="providerStep(c).aria"
298
327
  @click="connect(c)"
299
328
  >
300
- {{ t("integration.credentials.list.action.reconnect") }}
329
+ {{ providerStep(c).label }}
301
330
  </Button>
302
331
  <button type="button" class="flex size-7 items-center justify-center rounded text-foreground/40 hover:text-rose-600 hover:bg-rose-500/10" :title="t('integration.credentials.list.action.delete.aria', { name: c.name })" :aria-label="t('integration.credentials.list.action.delete.aria', { name: c.name })" @click="pendingDelete = c">
303
332
  <TrashBin :size="13" />
@@ -351,7 +380,10 @@ async function confirmDelete() {
351
380
  <IntegrationsCredentialRevenexxSource
352
381
  v-if="hasRevenexxSource(pickedType?.slug)"
353
382
  :type="pickedType"
354
- @update:active="(v) => sourceActive = v"
383
+ @update:active="(v) => {
384
+ sourceActive = v;
385
+ clearRefusal();
386
+ }"
355
387
  @apply="(c2) => Object.assign(form.config, c2)"
356
388
  />
357
389
 
@@ -438,14 +470,16 @@ async function confirmDelete() {
438
470
 
439
471
  </div>
440
472
 
441
- <!-- The save was refused. Outside the scrolling form on purpose: the Save
442
- button sits in the footer below, so a summary at the end of a form that
443
- scrolls — five fields already do — would be a refusal rendered under the
444
- fold, which is the defect this whole change is about. Stated here as well
445
- as at the field it belongs to, so a refusal with no field — a conflict, a
446
- gateway error — is not silent either. -->
447
- <div v-if="saveError && pickedType" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
448
- {{ saveError }}
473
+ <!-- The save was refused for something no field shows. Outside the scrolling
474
+ form on purpose: the Save button sits in the footer below, so a summary at
475
+ the end of a form that scrolls — five fields already do — would be a
476
+ refusal rendered under the fold. What is marked at a field is not repeated
477
+ here; what has no field to be marked at — a service-side field, a conflict,
478
+ a gateway error — would otherwise be silent. -->
479
+ <div v-if="summaryReasons.length && pickedType" role="alert" class="space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
480
+ <!-- Keyed by position: two undrawn keys can be refused with the same
481
+ sentence, and a duplicate key is how one of them goes unrendered. -->
482
+ <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
449
483
  </div>
450
484
 
451
485
  <DialogFooter v-if="pickedType" class="sm:justify-between">
@@ -147,16 +147,16 @@ const columns = computed(() => [
147
147
  at several named times is a long sentence, and half of one is
148
148
  the wrong half. Its title is the expression, so there is no
149
149
  tooltip left to carry the rest of the words. -->
150
- <div v-if="describeCron(e.cron)" :aria-label="t('integration.schedules.cell.whenItFires.aria')" :title="e.cron ?? void 0" class="text-sm leading-snug">
150
+ <div v-if="describeCron(e.cron)" :title="e.cron ?? void 0" class="text-sm leading-snug">
151
151
  {{ describeCron(e.cron) }}
152
152
  </div>
153
153
  <template v-else-if="e.cron">
154
- <div :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm truncate">
154
+ <div class="text-sm truncate">
155
155
  {{ t("integration.schedules.cell.customSchedule") }}
156
156
  </div>
157
- <code :aria-label="t('integration.schedules.cell.cronExpression.aria')" class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
157
+ <code class="block font-mono text-xs text-muted-foreground truncate">{{ e.cron }}</code>
158
158
  </template>
159
- <div v-else :aria-label="t('integration.schedules.cell.whenItFires.aria')" class="text-sm text-muted-foreground truncate">
159
+ <div v-else class="text-sm text-muted-foreground truncate">
160
160
  {{ t("integration.schedules.cell.noSchedule") }}
161
161
  </div>
162
162
  </div>
@@ -170,12 +170,13 @@ const columns = computed(() => [
170
170
  </template>
171
171
 
172
172
  <!--
173
- The two time cells carry the name their column header states, so each is
174
- addressable by what it is rather than by how it is typeset. It is not the
175
- accessible name a screen reader hears — the cell is a plain span, and
176
- ARIA prohibits naming a generic element — that needs row and cell
177
- semantics in the shared table (PO-233, and a gap in
178
- `specs/accessibility.md`).
173
+ Neither time cell carries a name of its own, and that is the fix rather
174
+ than an omission. Since studio-shared 0.3.2 the cell is a `role="cell"`
175
+ under a `columnheader`, so a screen reader reads "Next run, in 5h" from
176
+ the table itself. A name written here would be read *instead of* the
177
+ value — the row announced "Next run" where the time should be — so the
178
+ labels these cells used to carry to be addressable by a test were
179
+ removed with the semantics that replaced them (PO-233).
179
180
  -->
180
181
  <!--
181
182
  The instant the platform holds, shown as a distance from now, with the
@@ -187,7 +188,6 @@ const columns = computed(() => [
187
188
  -->
188
189
  <template #cell-nextRun="{ row: e }">
189
190
  <span
190
- :aria-label="t('integration.schedules.table.column.nextRun')"
191
191
  :title="e.nextRunLocal ?? void 0"
192
192
  class="text-sm tabular-nums"
193
193
  >{{ formatRelative(e.nextRunAt, e.timezone) }}</span>
@@ -202,7 +202,6 @@ const columns = computed(() => [
202
202
  -->
203
203
  <template #cell-lastRun="{ row: e }">
204
204
  <span
205
- :aria-label="t('integration.schedules.table.column.lastRun')"
206
205
  :title="e.lastRunAt ?? void 0"
207
206
  class="text-sm tabular-nums text-muted-foreground"
208
207
  >{{ lastRunText(e) }}</span>
@@ -46,8 +46,6 @@ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBui
46
46
  import { splitRunOutputs } from "../../../utils/runOutputs";
47
47
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
48
48
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
49
- import { unwiredErrorPorts } from "../../../utils/errorPathAdvice";
50
- import { newestExecutableVersion } from "../../../data/integrationNodeCatalog";
51
49
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
52
50
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
53
51
  const t = useIntegrationsT();
@@ -110,27 +108,6 @@ async function revealNode(nodeId) {
110
108
  await nextTick();
111
109
  editorRef.value?.revealNode(nodeId);
112
110
  }
113
- const errorPathAdvice = computed(() => {
114
- const blob = shownBlob.value;
115
- if (!blob) return [];
116
- const byId = new Map(blob.nodes.map((n) => [n.id, n]));
117
- return unwiredErrorPorts(blob).flatMap((a) => {
118
- const node = byId.get(a.nodeId);
119
- return node ? [{ ...a, label: nodeDisplayLabel(node) }] : [];
120
- });
121
- });
122
- const adviceExpanded = ref(false);
123
- const canAttachErrorStop = computed(() => newestExecutableVersion("revenexx:stop-and-error") !== null);
124
- async function showNodeOnCanvas(nodeId) {
125
- tab.value = "editor";
126
- await nextTick();
127
- editorRef.value?.showNodeOnCanvas(nodeId);
128
- }
129
- async function attachErrorStop(nodeId, port) {
130
- tab.value = "editor";
131
- await nextTick();
132
- editorRef.value?.attachErrorStop(nodeId, port);
133
- }
134
111
  const { load: loadNodeCatalog } = useIntegrationsNodes();
135
112
  onMounted(() => {
136
113
  void loadNodeCatalog();
@@ -200,7 +177,6 @@ watch(id, async () => {
200
177
  if (!w) await leaveTo("/integrations/workflows");
201
178
  draft.value = null;
202
179
  warningsExpanded.value = false;
203
- adviceExpanded.value = false;
204
180
  if (tab.value === "runs") void loadRuns(1);
205
181
  settleBuildStatus();
206
182
  }, { immediate: true });
@@ -1017,62 +993,6 @@ function formatDateTime(iso) {
1017
993
  </ul>
1018
994
  </div>
1019
995
 
1020
- <!-- Error-path advice (PO-443, specs/workflow-advice.md).
1021
- Slate, NOT amber: amber already means "fix this before it runs" twice
1022
- over in this editor (the reauth strip, the validation banner) and on
1023
- the node cards, so a third would leave the colour meaning nothing.
1024
- This blocks nothing — the activation control stays operable while it
1025
- shows, which is the point of it not being a finding.
1026
-
1027
- Editor tab only, unlike the validation banner above: every line here
1028
- acts on the canvas — reveal a card, attach a step to it — so over the
1029
- run list or the history preview it offers moves to a canvas that is not
1030
- on screen, and its node names read as part of whatever is. That is not
1031
- hypothetical: it put a current node's name over a preview of an
1032
- archived revision (workflow-history.md AC-3). -->
1033
- <div
1034
- v-if="tab === 'editor' && !openRunId && errorPathAdvice.length"
1035
- class="border-b border-slate-500/20 bg-slate-500/5 px-4 py-2 text-sm text-slate-700"
1036
- >
1037
- <p class="font-medium">
1038
- {{ t("integration.editor.errorPath.advice", errorPathAdvice.length) }}
1039
- </p>
1040
- <ul
1041
- class="mt-1 space-y-0.5"
1042
- :class="adviceExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
1043
- >
1044
- <li
1045
- v-for="a in adviceExpanded ? errorPathAdvice : errorPathAdvice.slice(0, WARN_COLLAPSED)"
1046
- :key="`${a.nodeId}-${a.port}`"
1047
- class="flex items-baseline gap-1.5"
1048
- >
1049
- <span class="select-none text-slate-400">•</span>
1050
- <button
1051
- type="button"
1052
- class="text-left font-medium underline decoration-transparent underline-offset-2 hover:decoration-slate-700"
1053
- :aria-label="t('integration.editor.errorPath.ariaReveal', { label: a.label, port: a.port })"
1054
- @click="showNodeOnCanvas(a.nodeId)"
1055
- >
1056
- {{ a.label }}
1057
- </button>
1058
- <button
1059
- v-if="canAttachErrorStop"
1060
- type="button"
1061
- class="text-slate-600 underline underline-offset-2 hover:text-slate-900"
1062
- :aria-label="t('integration.editor.errorPath.ariaFix', { label: a.label, port: a.port })"
1063
- @click="attachErrorStop(a.nodeId, a.port)"
1064
- >
1065
- {{ t("integration.editor.errorPath.action.stop") }}
1066
- </button>
1067
- </li>
1068
- <li v-if="!adviceExpanded && errorPathAdvice.length > WARN_COLLAPSED" class="pl-3">
1069
- <button type="button" class="text-slate-600 underline underline-offset-2 hover:text-slate-900" @click="adviceExpanded = true">
1070
- {{ t("integration.editor.errorPath.more", { n: errorPathAdvice.length - WARN_COLLAPSED }) }}
1071
- </button>
1072
- </li>
1073
- </ul>
1074
- </div>
1075
-
1076
996
  <!-- Only the FAILED build state banners (its message matters); the
1077
997
  working states live as the header chip — and inside a run the
1078
998
  workflow's build state is noise, so nothing shows at all. -->
@@ -35,21 +35,48 @@ export declare function apiValidationIssues(err: unknown): {
35
35
  path: string;
36
36
  message: string;
37
37
  }[];
38
+ /** One message the service keyed to a field, as it keyed it. */
39
+ export interface RefusalMessage {
40
+ key: string;
41
+ message: string;
42
+ }
43
+ /**
44
+ * A refusal split by what the form on screen can show of it.
45
+ *
46
+ * `atFields` is what goes under an input, keyed the way the form draws it; `unshown`
47
+ * is everything left over — the reasons that have no input to stand at, in the order
48
+ * the service listed them.
49
+ */
50
+ export interface Refusal {
51
+ atFields: Record<string, string>;
52
+ unshown: RefusalMessage[];
53
+ }
38
54
  /**
39
- * Field-keyed validation messages from a rejection, as `{ field: message }`.
55
+ * A rejection split into the messages a form can show at a field and the messages it
56
+ * cannot, given the keys that form actually draws.
40
57
  *
41
58
  * The counterpart to `apiValidationIssues` above, for the ordinary case that has
42
59
  * nothing to do with the workflow blob: a form field the service refused, named the
43
60
  * way the form names it. Laravel answers a `422` with `errors` keyed by field and a
44
- * list of messages per key; the first message is the one a person reads, so that is
45
- * the one kept.
61
+ * list of messages per key; at a field the first message is the one a person reads,
62
+ * so that is the one kept.
63
+ *
64
+ * **`drawn` is the form's own list, in the order the form draws it** — the name, then
65
+ * the fields of the chosen type, and nothing while those are hidden behind another
66
+ * source. A key absent from it has no input to be shown at, whether because no form
67
+ * draws that field (`config` as a whole, a field the type never declared, a key that
68
+ * is not a field at all) or because this form is currently drawing something else.
69
+ * Both cases go to `unshown`, which is what the surface states below the form: a
70
+ * refusal nobody can see is the same as no answer at all (PO-275, PO-477).
71
+ *
72
+ * A container that is only at fault because one of its members is — `config` beside
73
+ * `config.authKey`, which the service reports as two — is dropped wherever the member
74
+ * is reported too: it says the same thing twice, and the member carries it either way.
46
75
  *
47
- * A form uses this to put the refusal where the operator is looking — "The name has
48
- * already been taken." under the Name field, rather than as a sentence somewhere
49
- * else about a field they then have to find (PO-275). Keys that address the blob are
50
- * left out: those belong to `apiValidationIssues`, which resolves them to nodes.
76
+ * Keys that address the blob are left out: those belong to `apiValidationIssues`,
77
+ * which resolves them to nodes. No credential surface can receive one.
51
78
  */
52
- export declare function apiFieldErrors(err: unknown): Record<string, string>;
79
+ export declare function apiRefusal(err: unknown, drawn: readonly string[]): Refusal;
53
80
  /**
54
81
  * The reason a rejection gives, without the count of everything else it found.
55
82
  *