@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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,198 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - 8397d7b: Every studio list is read as a table, its search box states its own name, and the Runs
8
+ filter works again on `@revenexx/studio-shared` 0.3.2 (PO-233).
9
+
10
+ **A row of any studio list used to be an unnamed sequence of values.** The shared table
11
+ rendered anonymous boxes, so nothing connected a column heading to the cell beneath it:
12
+ a schedule read as _"Order sync, Daily at 03:00, Europe/Berlin, in 18m, 1h ago, live"_,
13
+ with nothing saying which of the two times was the next run. Taking `studio-shared`
14
+ 0.3.2 gives every list — Workflows, Runs, Schedules, Credentials, Secrets and the Audit
15
+ Log — `table`, `row`, `columnheader` and `cell`, so each value is announced under the
16
+ heading it belongs to. The search above each list now states its name on the field
17
+ itself, in the words of its own placeholder, instead of leaving the browser to work one
18
+ out from that placeholder — which is a fallback rather than a name, and a documented
19
+ failure pattern when it is the only one.
20
+
21
+ **Two cells on the Schedules page lost the labels they carried.** They were there so a
22
+ test could find them, and a name on a cell is read _instead of_ its contents — so with
23
+ the semantics in place they announced "Next run" where the time should be. The heading
24
+ above the column says which time it is now, which is what it is for.
25
+
26
+ **The Runs filter is rebuilt as two selects.** In 0.3.2 the shared filter trigger opens
27
+ a popover rather than a dropdown menu, and the sub-menus this page put inside it had no
28
+ menu to attach to: they rendered nothing at all, so the control was there and the
29
+ narrowing was gone. Outcome and trigger kind are now chosen from a select each, `All`
30
+ included, and the narrowing reaches the platform exactly as before.
31
+
32
+ **A host also owes the shared table two new catalogue entries.** `studio-shared`
33
+ translates through the host's own `shared.*` catalogue and renders the key itself when
34
+ an entry is missing, and 0.3.2 asks for `shared.datatable.toolbar.filter.apply` and
35
+ `…toolbar.filter.reset` — the two buttons of the filter panel, which a host without
36
+ them draws as the key names. The cockpit already carries both, in either language; any
37
+ other host has to add them. (`…toolbar.search.aria.label` is asked for too, but no list
38
+ in this module reaches it: the table names the search box after its own placeholder
39
+ unless a surface says otherwise, and all six pass a placeholder.)
40
+
41
+ **This is a major for two of the reasons [`docs/versioning.md`](docs/versioning.md)
42
+ names.** The peer floor on `@revenexx/studio` rises from `*` to `^0.2.3`, so a host on
43
+ the old range can no longer install — and four accessible names are removed rather than
44
+ renamed: `Next run`, `Last run`, `When it fires` and `Cron expression`, which anything
45
+ addressing a schedules cell by name was using. Both are things a host has to react to.
46
+
47
+ **Hosts must be on `@revenexx/studio` ^0.2.3.** The peer range was `*` and is now the
48
+ version that carries the search field's `label` prop; below it the name falls through to
49
+ a wrapper and the box stays anonymous.
50
+
51
+ - c651aac: The editor no longer points out an error output nobody connected. The run says it instead
52
+ (PO-479).
53
+
54
+ **What goes.** A strip above the canvas read _"{n} node routes failures to an output with
55
+ nothing attached — nothing handles the failure there."_, named each step, and offered to
56
+ attach a **Stop and Error** step to the port in one click. The strip, that action, and the
57
+ five promises in `specs/workflow-advice.md` are withdrawn.
58
+
59
+ **Why, and what an author loses.** The strip and the platform behaviour it described
60
+ shipped together under PO-443. The platform half is the one that can speak with authority:
61
+ a failure routed to an output nothing leaves now **ends the run**, naming the step, what it
62
+ failed with, and which output had nowhere to send it — the `node-failure-handling` spec of
63
+ `revenexx/integrations`, AC-8. Before PO-443 such a failure vanished and the run was
64
+ recorded as a success, which is the world the strip was built for and which no longer
65
+ exists. What is genuinely lost is the timing: an author used to be told while drawing, and
66
+ is now told by the first run that actually fails.
67
+
68
+ **`revenexx:stop-and-error` is untouched.** It is still in the palette, still what all eight
69
+ shipped templates put behind each of their twenty-five error ports, and still the right
70
+ thing to attach. Only the editor's one-click attachment is gone.
71
+
72
+ **A major, for the reason [`docs/versioning.md`](docs/versioning.md) gives.** Seven strings
73
+ are removed rather than renamed, and three of them are accessible names or an action label
74
+ that a host or a test could be addressing:
75
+
76
+ | Removed | What it was |
77
+ | ------------------------------------------ | ------------------------------------------- |
78
+ | `integration.editor.errorPath.advice` | the strip's own sentence |
79
+ | `integration.editor.errorPath.action.stop` | _Stop the run on error_ |
80
+ | `integration.editor.errorPath.ariaReveal` | accessible name of the step link |
81
+ | `integration.editor.errorPath.ariaFix` | accessible name of the attach action |
82
+ | `integration.editor.errorPath.more` | _…and {n} more_ |
83
+ | `integration.editor.errorPath.message` | the pre-filled message on the attached step |
84
+ | `integration.editor.errorPath.toast.added` | the toast after attaching |
85
+
86
+ Nothing else in the editor moves: the validation banner, the reauth strip and the build
87
+ notice keep their place above the canvas, and the activation gate is unchanged — the strip
88
+ never touched it, which was the whole point of it.
89
+
90
+ ### Patch Changes
91
+
92
+ - a79380d: The dialog that imports a platform SFTP account can be drawn by any host built to the
93
+ written contract, and its wait is now a promise with a test behind it (PO-387).
94
+
95
+ **The studio's written contract for a host was five members behind what this dialog
96
+ reads.** `src/runtime/utils/cockpitSources.ts` says what the module expects of a cockpit
97
+ host, and its `sftp` entry declared neither `loading` nor `activeTenantSlug`, made
98
+ `load`'s tenant argument required where the cockpit makes it optional, and left `server`
99
+ and `home_directory` off an account. The dialog read all five. The cockpit has provided
100
+ all five throughout, so **nothing was ever wrong for an operator** — what was wrong is
101
+ that a host built to the contract instead of to the cockpit threw
102
+ `TypeError: Cannot read properties of undefined` while drawing the dialog. Vue's error
103
+ handler turns that into a console message and unmounts the tree, so the page went blank
104
+ with no error anything could catch.
105
+
106
+ **The consequence was that no test could reach the surface at all.** The playground's
107
+ stand-in for the cockpit is built from the contract, so switching it on crashed the
108
+ dialog and leaving it off gave a dialog that never waits — the only two states the suite
109
+ could produce. The waiting announcement PO-251 added here was therefore recorded as a
110
+ gap rather than a criterion. The contract and the stand-in now carry all five members,
111
+ the dialog takes its type from the contract rather than declaring its own, and
112
+ `specs/node-configuration.md` carries the wait as AC-33 with a test that fails when the
113
+ announcement is taken away.
114
+
115
+ **A failed fetch no longer reads as a tenant with no accounts.** The surrounding
116
+ application catches its own fetch errors, so the studio is handed an empty list and no
117
+ exception — and the dialog drew the same "No active platform SFTP accounts. Create one
118
+ under Integration Studio → SFTP first." for both. One of those two asks the operator to
119
+ go and create an account; the other does not, and nothing on screen let them tell which
120
+ they were looking at. The contract gains the `error` the cockpit has always exposed —
121
+ **optional**, because the standalone dev studio's stand-ins provide the other four
122
+ members and not that one, and a dialog that insisted on it would lose that host the
123
+ whole surface. Where it is absent the dialog keeps the empty state it showed before.
124
+ That is AC-34, and it adds one string in both languages: **"The platform SFTP accounts could not be loaded:
125
+ {message}"** / "Die Plattform-SFTP-Konten konnten nicht geladen werden: {message}".
126
+
127
+ **A patch.** `CockpitSftpAccount` gains two required members, which
128
+ [`docs/versioning.md`](docs/versioning.md) would put in the major column as a narrowed
129
+ type — but only for somebody who can reach it. Nobody can: `exports` in `package.json`
130
+ declares `"."` alone and no `typesVersions`, so every other subpath is sealed and
131
+ `require.resolve` of the file answers `ERR_PACKAGE_PATH_NOT_EXPORTED`. The type ships
132
+ inside the tarball and is importable from nowhere. The dialog reads exactly what it read
133
+ before, so a host that worked goes on working unchanged.
134
+
135
+ - 8975a1f: The Credentials list offers the provider step to a credential that never took it, not
136
+ only to one whose authorisation expired (PO-478).
137
+
138
+ **A three-legged credential is created before its provider is asked.** The dialog closes
139
+ on the creation, and the window that asks for consent is opened after that — so when
140
+ that second step fails, there is no dialog left to say so. What remained was a
141
+ credential that cannot work, wearing the same _Not Connected_ badge as one nobody has
142
+ tested, with nothing in its row to finish what was started. The only way on was to
143
+ delete it and make it again.
144
+
145
+ **The row now offers the step wherever a three-legged credential is not known to be
146
+ connected**, and the wording says which of the two it is: **Connect** where nothing has
147
+ ever proved the credential connected, **Reconnect** where it was working and stopped —
148
+ an authorisation that expired, or a connection test that failed. The amber stays on the
149
+ expired one, which is the case this surface has always marked for attention. A
150
+ credential whose access data is a key, a host or a password never carries either, which
151
+ is what keeps the offer from becoming background noise.
152
+
153
+ It is generous by exactly one case: a credential that _was_ authorised but never tested
154
+ also carries the offer, because nothing the service returns tells that apart from one
155
+ that was never authorised. Taking the step again is the same window the operator was
156
+ expecting, so the cost of that guess is a click.
157
+
158
+ Promised behaviour: `specs/credentials.md` AC-2. The `## Gaps` entry in
159
+ `specs/credential-create.md` that asked what this surface says when the step after the
160
+ close fails is answered, and now points here.
161
+
162
+ - c787791: No product change: a spec's `## Gaps` entry and a test comment now say only what was
163
+ measured about `canvas-navigation` AC-4 (PO-479).
164
+
165
+ ## 1.4.1
166
+
167
+ ### Patch Changes
168
+
169
+ - 7bb1913: A refused credential save states each reason once: at its field, or below the form
170
+ when no field can show it (PO-477).
171
+
172
+ **What a field can carry is marked there and nowhere else.** Until now the service's
173
+ lead reason was also repeated in a red box above the Save button, so the one sentence
174
+ the operator had already read was the one thing on the surface they had to read twice
175
+ — and it crowded out the reasons that had no other place to appear.
176
+
177
+ **What no field can carry is what the box is for now.** The service keys a refusal by
178
+ validation rule, and several of those shapes name nothing either dialog draws an input
179
+ for: the access data taken as a whole, a field the credential type never handed out, a
180
+ field hidden while a managed revenexx resource is being reused, or a refusal that is
181
+ not field-keyed at all, such as a conflict or a gateway error. Any of them was silent
182
+ unless the service happened to lead with it. All of them are now stated below the form,
183
+ every message of them, still without the service's count of everything else it found.
184
+
185
+ **The refusal also puts the operator in the first marked field.** The box was what
186
+ guaranteed a refusal could not be rendered below the fold of a form that scrolls; the
187
+ focus is what guarantees it now, and it leaves the cursor where the correction has to
188
+ be typed.
189
+
190
+ Both dialogs read the refusal through one seam, so the create-and-edit dialog on
191
+ Credentials and the credential detail dialog answer the same way.
192
+
193
+ Promised behaviour: `specs/credential-create.md` AC-8 and AC-11,
194
+ `specs/credential-detail.md` AC-6.
195
+
3
196
  ## 1.4.0
4
197
 
5
198
  ### Minor Changes
package/README.md CHANGED
@@ -74,7 +74,7 @@ component styling silently breaks:
74
74
  Adjust the relative paths to reach `node_modules` from your CSS file.
75
75
 
76
76
  ## Peers
77
- `@revenexx/studio`, `@revenexx/studio-shared` ^0.2 (module form), `nuxt` ^4, `vue` ^3.5,
77
+ `@revenexx/studio` ^0.2.3, `@revenexx/studio-shared` ^0.3.2 (module form), `nuxt` ^4, `vue` ^3.5,
78
78
  `@vue-flow/*` + `@dagrejs/dagre` (workflow editor), `lucide-vue-next`; register
79
79
  `@solar-icons/nuxt` in the host.
80
80
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "1.4.0",
4
+ "version": "2.0.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -1,5 +1,5 @@
1
1
  <script setup>
2
- import { computed, reactive, ref, watch } from "vue";
2
+ import { computed, nextTick, reactive, ref, watch } from "vue";
3
3
  import {
4
4
  CheckCircle as CheckCircle2,
5
5
  CloseCircle as XCircle,
@@ -30,7 +30,7 @@ import {
30
30
  SelectValue
31
31
  } from "@revenexx/studio";
32
32
  import { toast } from "../../utils/notify";
33
- import { apiFieldErrors, errorReason } from "../../utils/apiError";
33
+ import { apiRefusal, errorReason } from "../../utils/apiError";
34
34
  import { credentialState, relativeTime } from "../../utils/credentialState";
35
35
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
36
36
  import { resolveLocalized } from "../../utils/localize";
@@ -53,14 +53,31 @@ const saving = ref(false);
53
53
  const deleting = ref(false);
54
54
  const testing = ref(false);
55
55
  const liveTest = ref(null);
56
- const saveError = ref(null);
57
- const fieldErrors = ref({});
56
+ const saveRefusal = ref(null);
57
+ const drawnKeys = computed(() => [
58
+ "name",
59
+ ...sourceActive.value ? [] : fieldsOf(props.type).map((f) => `config.${f.key}`)
60
+ ]);
61
+ const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
62
+ const fieldErrors = computed(() => refusal.value.atFields);
63
+ const summaryReasons = computed(() => {
64
+ if (!saveRefusal.value) return [];
65
+ if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
66
+ if (Object.keys(refusal.value.atFields).length) return [];
67
+ return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
68
+ });
58
69
  function configFieldAria(key) {
59
70
  return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `detail-field-${key}-error` } : {};
60
71
  }
72
+ async function focusFirstRefusedField() {
73
+ await nextTick();
74
+ const key = drawnKeys.value.find((k) => k in fieldErrors.value);
75
+ if (!key) return;
76
+ const id = key === "name" ? "detail-cred-name" : `detail-field-${key.slice("config.".length)}`;
77
+ document.getElementById(id)?.focus();
78
+ }
61
79
  function clearRefusal() {
62
- saveError.value = null;
63
- fieldErrors.value = {};
80
+ saveRefusal.value = null;
64
81
  }
65
82
  const connecting = ref(false);
66
83
  const typeName = computed(() => resolveLocalized(props.type?.name) || props.credential?.credential_type_slug || "");
@@ -103,6 +120,7 @@ async function runTest() {
103
120
  if (!props.credential) return;
104
121
  testing.value = true;
105
122
  liveTest.value = null;
123
+ clearRefusal();
106
124
  try {
107
125
  liveTest.value = await test(props.credential.id);
108
126
  await load(true);
@@ -121,9 +139,9 @@ async function save() {
121
139
  emit("update:open", false);
122
140
  toast.success(t("integration.credentials.toast.updated"));
123
141
  } catch (e) {
124
- fieldErrors.value = apiFieldErrors(e);
125
- saveError.value = errorReason(e, t("integration.credentials.error.save"));
126
- toast.error(saveError.value);
142
+ saveRefusal.value = e;
143
+ void focusFirstRefusedField();
144
+ toast.error(errorReason(e, t("integration.credentials.error.save")));
127
145
  } finally {
128
146
  saving.value = false;
129
147
  }
@@ -182,8 +200,11 @@ async function connect() {
182
200
  <!-- Standard cockpit tab bar (violet underline) -->
183
201
  <TabBar :model-value="tab" :tabs="tabs" @update:model-value="(k) => tab = k" />
184
202
 
185
- <!-- Tab 1: Credentials -->
186
- <div v-if="tab === 'credentials'" class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
203
+ <!-- Tab 1: Credentials. A `template` rather than a wrapping element: the
204
+ scrolling pane and the block below it are siblings, as they were when the
205
+ pane was the whole tab, so nothing about the spacing around them moves. -->
206
+ <template v-if="tab === 'credentials'">
207
+ <div class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
187
208
  <!-- Connection state — the headline answer: are these credentials correct? -->
188
209
  <div class="rounded-lg border p-3" :class="state.toneClass">
189
210
  <div class="flex items-center gap-2">
@@ -243,7 +264,10 @@ async function connect() {
243
264
  <IntegrationsCredentialRevenexxSource
244
265
  v-if="hasRevenexxSource(type?.slug)"
245
266
  :type="type"
246
- @update:active="(v) => sourceActive = v"
267
+ @update:active="(v) => {
268
+ sourceActive = v;
269
+ clearRefusal();
270
+ }"
247
271
  @apply="(c) => Object.assign(form.config, c)"
248
272
  />
249
273
 
@@ -313,27 +337,35 @@ async function connect() {
313
337
  {{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
314
338
  </div>
315
339
 
316
- <!-- The save was refused. Same rule as the create dialog: stated here, and at
317
- the field where the service keyed it. -->
318
- <div v-if="saveError" role="alert" class="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
319
- {{ saveError }}
320
- </div>
340
+ </div>
321
341
 
322
- <div class="flex items-center gap-2 pt-1">
323
- <div class="flex-1" />
324
- <Button variant="outline" :disabled="testing || saving" @click="runTest">
325
- <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
326
- <TestIcon v-else :size="14" class="mr-1" />
327
- {{ t("integration.credentials.shared.action.testConnection") }}
328
- </Button>
329
- <Button :disabled="saving || !form.name.trim()" @click="save">
330
- <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
331
- {{ t("integration.common.action.save") }}
332
- </Button>
333
- </div>
334
342
  </div>
335
343
 
344
+ <!-- Outside the scrolling pane on purpose, the way the create dialog has it:
345
+ a refusal puts the operator in the first marked field, which scrolls that
346
+ pane back to the top — and a summary rendered at the end of the form would
347
+ be carried out of sight by the very focus meant to make the refusal
348
+ visible. What is marked at a field is not repeated here; what has no field
349
+ to be marked at would otherwise be silent. -->
350
+ <div v-if="summaryReasons.length" role="alert" class="mt-3 space-y-1 rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-700">
351
+ <!-- Keyed by position: two undrawn keys can be refused with the same
352
+ sentence, and a duplicate key is how one of them goes unrendered. -->
353
+ <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
354
+ </div>
355
+
356
+ <div class="flex items-center gap-2 pt-3">
357
+ <div class="flex-1" />
358
+ <Button variant="outline" :disabled="testing || saving" @click="runTest">
359
+ <Loader2 v-if="testing" :size="14" class="mr-1 animate-spin" />
360
+ <TestIcon v-else :size="14" class="mr-1" />
361
+ {{ t("integration.credentials.shared.action.testConnection") }}
362
+ </Button>
363
+ <Button :disabled="saving || !form.name.trim()" @click="save">
364
+ <Loader2 v-if="saving" :size="14" class="mr-1 animate-spin" />
365
+ {{ t("integration.common.action.save") }}
366
+ </Button>
336
367
  </div>
368
+ </template>
337
369
 
338
370
  <!-- Tab 2: Details -->
339
371
  <div v-else class="space-y-5 max-h-[62vh] overflow-y-auto pr-1 pt-4">
@@ -2,11 +2,11 @@
2
2
  import { computed, onMounted, onUnmounted, ref } from "vue";
3
3
  import {
4
4
  Badge,
5
- DropdownMenuItem,
6
- DropdownMenuSeparator,
7
- DropdownMenuSub,
8
- DropdownMenuSubContent,
9
- DropdownMenuSubTrigger
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue
10
10
  } from "@revenexx/studio";
11
11
  import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../utils/runFeedQuery";
12
12
  import { RUN_STATUS_COLUMN_WIDTH, RUN_STATUS_WORDS, normalisedRunStatus, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
@@ -85,8 +85,9 @@ function setStatus(word) {
85
85
  function setTrigger(kind) {
86
86
  emit("narrow", { triggerType: kind ?? "" });
87
87
  }
88
- const statusApplied = computed(() => chosenStatus.value[0] ? runStatusLabel(chosenStatus.value[0]) : "");
89
- const triggerApplied = computed(() => props.triggerType ? triggerTypeLabel(props.triggerType) : "");
88
+ const ALL = "all";
89
+ const statusChoice = computed(() => chosenStatus.value[0] ?? ALL);
90
+ const triggerChoice = computed(() => props.triggerType || ALL);
90
91
  const searchText = ref("");
91
92
  function onTableState(state) {
92
93
  searchText.value = state.search;
@@ -171,46 +172,66 @@ const columns = computed(() => [
171
172
  (`filterTabs`) cannot be used here, because it compares the rows in hand and
172
173
  those are one page.
173
174
 
174
- Built to the same shape as that control on purpose — a dimension per
175
- sub-menu, "All" first, one value at a time — so this list is narrowed by the
176
- same gesture as the five that narrow what they hold, and one test helper
177
- still addresses all six (`e2e/support/studio-table.ts`). What is NOT copied
178
- is the count beside each option: the built-in counts the rows in hand, and
179
- counting a page here would put a number next to "failed" that describes this
180
- page rather than the history the option narrows.
175
+ It is no longer the same gesture as the other lists. Theirs tick values and
176
+ apply them together; this one picks one value per dimension and applies it on
177
+ the spot, because each pick is a request to the platform and staging them
178
+ would only delay the same round trip. So the shared narrowing helper does not
179
+ reach this page and `runs.spec.ts` carries its own — said in both places
180
+ rather than left to be discovered (`e2e/support/studio-table.ts`).
181
+
182
+ What is still NOT copied is the count beside each option: the built-in counts
183
+ the rows in hand, and counting a page here would put a number next to
184
+ "failed" that describes this page rather than the history the option narrows.
181
185
  -->
182
186
  <template v-if="serverMode" #filter-menu>
183
- <DropdownMenuSub>
184
- <DropdownMenuSubTrigger class="gap-2">
185
- <span class="flex-1">{{ t("integration.runs.table.column.status") }}</span>
186
- <span v-if="statusApplied" class="text-xs text-primary">{{ statusApplied }}</span>
187
- </DropdownMenuSubTrigger>
188
- <DropdownMenuSubContent class="w-48">
189
- <DropdownMenuItem @select="setStatus(null)">
190
- {{ t("integration.runs.table.filter.all") }}
191
- </DropdownMenuItem>
192
- <DropdownMenuSeparator />
193
- <DropdownMenuItem v-for="word in RUN_STATUS_WORDS" :key="word" @select="setStatus(word)">
194
- {{ runStatusLabel(word) }}
195
- </DropdownMenuItem>
196
- </DropdownMenuSubContent>
197
- </DropdownMenuSub>
187
+ <!--
188
+ Two selects rather than the sub-menus this slot used to hold. Since
189
+ `studio-shared` 0.3.2 the filter trigger opens a popover instead of a
190
+ dropdown menu, and a `DropdownMenuSub` outside a menu root has nothing to
191
+ attach to: it renders nothing at all, so the narrowing was there in the
192
+ markup and gone from the screen. A select is what the new surface is built
193
+ for — it sizes `[data-slot=select-trigger]` to its full width — and it
194
+ keeps what the menu promised, the widest setting included.
195
+ -->
196
+ <div class="space-y-1">
197
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.status") }}</span>
198
+ <Select
199
+ :model-value="statusChoice"
200
+ @update:model-value="(v) => setStatus(v === ALL ? null : String(v))"
201
+ >
202
+ <SelectTrigger :aria-label="t('integration.runs.table.column.status')" class="h-8 text-sm">
203
+ <SelectValue />
204
+ </SelectTrigger>
205
+ <SelectContent>
206
+ <SelectItem :value="ALL">
207
+ {{ t("integration.runs.table.filter.all") }}
208
+ </SelectItem>
209
+ <SelectItem v-for="word in RUN_STATUS_WORDS" :key="word" :value="word">
210
+ {{ runStatusLabel(word) }}
211
+ </SelectItem>
212
+ </SelectContent>
213
+ </Select>
214
+ </div>
198
215
 
199
- <DropdownMenuSub>
200
- <DropdownMenuSubTrigger class="gap-2">
201
- <span class="flex-1">{{ t("integration.runs.table.column.trigger") }}</span>
202
- <span v-if="triggerApplied" class="text-xs text-primary">{{ triggerApplied }}</span>
203
- </DropdownMenuSubTrigger>
204
- <DropdownMenuSubContent class="w-48">
205
- <DropdownMenuItem @select="setTrigger(null)">
206
- {{ t("integration.runs.table.filter.all") }}
207
- </DropdownMenuItem>
208
- <DropdownMenuSeparator />
209
- <DropdownMenuItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" @select="setTrigger(kind)">
210
- {{ triggerTypeLabel(kind) }}
211
- </DropdownMenuItem>
212
- </DropdownMenuSubContent>
213
- </DropdownMenuSub>
216
+ <div class="space-y-1">
217
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.trigger") }}</span>
218
+ <Select
219
+ :model-value="triggerChoice"
220
+ @update:model-value="(v) => setTrigger(v === ALL ? null : String(v))"
221
+ >
222
+ <SelectTrigger :aria-label="t('integration.runs.table.column.trigger')" class="h-8 text-sm">
223
+ <SelectValue />
224
+ </SelectTrigger>
225
+ <SelectContent>
226
+ <SelectItem :value="ALL">
227
+ {{ t("integration.runs.table.filter.all") }}
228
+ </SelectItem>
229
+ <SelectItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" :value="kind">
230
+ {{ triggerTypeLabel(kind) }}
231
+ </SelectItem>
232
+ </SelectContent>
233
+ </Select>
234
+ </div>
214
235
  </template>
215
236
 
216
237
  <template #cell-status="{ row: run }">
@@ -17,13 +17,24 @@ const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
17
17
  const { create } = useIntegrationsCredentials();
18
18
  const busyId = ref(null);
19
19
  const error = ref(null);
20
- watch(open, (o) => {
21
- if (o) {
22
- error.value = null;
23
- void sftp.load();
20
+ const loadPending = ref(false);
21
+ let openCycle = 0;
22
+ watch(open, async (o) => {
23
+ if (!o) return;
24
+ const cycle = ++openCycle;
25
+ error.value = null;
26
+ loadPending.value = true;
27
+ try {
28
+ await sftp.load();
29
+ } finally {
30
+ if (cycle === openCycle) loadPending.value = false;
24
31
  }
25
32
  });
26
33
  const activeAccounts = computed(() => sftp.accounts.value.filter((a) => a.status === "active" && a.login_username));
34
+ const waiting = computed(() => (loadPending.value || sftp.loading.value) && !activeAccounts.value.length);
35
+ const loadFailed = computed(
36
+ () => !waiting.value && !activeAccounts.value.length && sftp.activeTenantSlug.value && sftp.error?.value ? t("integration.credentials.sftpImport.state.loadFailed", { message: sftp.error.value }) : null
37
+ );
27
38
  async function importAccount(account) {
28
39
  const tenant = sftp.activeTenantSlug.value;
29
40
  if (!tenant || !account.login_username) return;
@@ -69,13 +80,16 @@ async function importAccount(account) {
69
80
  limit written down there (PO-251). -->
70
81
  <LoadingAnnouncement
71
82
  :label="t('integration.credentials.sftpImport.title')"
72
- :loading="sftp.loading.value && !activeAccounts.length"
83
+ :loading="waiting"
73
84
  :text="t('integration.credentials.sftpImport.state.loading')"
74
85
  />
75
86
 
76
- <div v-if="sftp.loading.value && !activeAccounts.length" aria-hidden="true" class="space-y-2">
87
+ <div v-if="waiting" aria-hidden="true" class="space-y-2">
77
88
  <div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
78
89
  </div>
90
+ <p v-else-if="loadFailed" class="py-4 text-center text-sm text-rose-600">
91
+ {{ loadFailed }}
92
+ </p>
79
93
  <p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
80
94
  {{ t("integration.credentials.sftpImport.empty") }}
81
95
  </p>
@@ -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;