@revenexx/studio-integrations 1.4.0 → 1.4.1

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,36 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 1.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 7bb1913: A refused credential save states each reason once: at its field, or below the form
8
+ when no field can show it (PO-477).
9
+
10
+ **What a field can carry is marked there and nowhere else.** Until now the service's
11
+ lead reason was also repeated in a red box above the Save button, so the one sentence
12
+ the operator had already read was the one thing on the surface they had to read twice
13
+ — and it crowded out the reasons that had no other place to appear.
14
+
15
+ **What no field can carry is what the box is for now.** The service keys a refusal by
16
+ validation rule, and several of those shapes name nothing either dialog draws an input
17
+ for: the access data taken as a whole, a field the credential type never handed out, a
18
+ field hidden while a managed revenexx resource is being reused, or a refusal that is
19
+ not field-keyed at all, such as a conflict or a gateway error. Any of them was silent
20
+ unless the service happened to lead with it. All of them are now stated below the form,
21
+ every message of them, still without the service's count of everything else it found.
22
+
23
+ **The refusal also puts the operator in the first marked field.** The box was what
24
+ guaranteed a refusal could not be rendered below the fold of a form that scrolls; the
25
+ focus is what guarantees it now, and it leaves the cursor where the correction has to
26
+ be typed.
27
+
28
+ Both dialogs read the refusal through one seam, so the create-and-edit dialog on
29
+ Credentials and the credential detail dialog answer the same way.
30
+
31
+ Promised behaviour: `specs/credential-create.md` AC-8 and AC-11,
32
+ `specs/credential-detail.md` AC-6.
33
+
3
34
  ## 1.4.0
4
35
 
5
36
  ### Minor Changes
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": "1.4.1",
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">
@@ -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,7 +31,7 @@ import {
31
31
  SelectValue
32
32
  } from "@revenexx/studio";
33
33
  import { toast } from "../../utils/notify";
34
- import { apiFieldErrors, errorReason } from "../../utils/apiError";
34
+ import { apiRefusal, errorReason } from "../../utils/apiError";
35
35
  import { CREDENTIAL_STATES, credentialState, credentialStateLabel } from "../../utils/credentialState";
36
36
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
37
37
  import { resolveLocalized } from "../../utils/localize";
@@ -92,19 +92,36 @@ const form = reactive({ name: "", config: {} });
92
92
  const saving = ref(false);
93
93
  const testing = ref(false);
94
94
  const testResult = ref(null);
95
- const saveError = ref(null);
96
- const fieldErrors = ref({});
95
+ const saveRefusal = ref(null);
97
96
  const sourceActive = ref(false);
98
97
  const isEdit = computed(() => editing.value !== null);
99
98
  function fieldsOf(t2) {
100
99
  return t2?.fields ?? [];
101
100
  }
101
+ const drawnKeys = computed(() => [
102
+ "name",
103
+ ...sourceActive.value ? [] : fieldsOf(pickedType.value).map((f) => `config.${f.key}`)
104
+ ]);
105
+ const refusal = computed(() => apiRefusal(saveRefusal.value, drawnKeys.value));
106
+ const fieldErrors = computed(() => refusal.value.atFields);
107
+ const summaryReasons = computed(() => {
108
+ if (!saveRefusal.value) return [];
109
+ if (refusal.value.unshown.length) return refusal.value.unshown.map((u) => u.message);
110
+ if (Object.keys(refusal.value.atFields).length) return [];
111
+ return [errorReason(saveRefusal.value, t("integration.credentials.error.save"))];
112
+ });
102
113
  function configFieldAria(key) {
103
114
  return fieldErrors.value[`config.${key}`] ? { "aria-invalid": "true", "aria-describedby": `cred-field-${key}-error` } : {};
104
115
  }
116
+ async function focusFirstRefusedField() {
117
+ await nextTick();
118
+ const key = drawnKeys.value.find((k) => k in fieldErrors.value);
119
+ if (!key) return;
120
+ const id = key === "name" ? "cred-name" : `cred-field-${key.slice("config.".length)}`;
121
+ document.getElementById(id)?.focus();
122
+ }
105
123
  function clearRefusal() {
106
- saveError.value = null;
107
- fieldErrors.value = {};
124
+ saveRefusal.value = null;
108
125
  }
109
126
  function openCreate(t2) {
110
127
  editing.value = null;
@@ -176,9 +193,9 @@ async function submit() {
176
193
  }
177
194
  }
178
195
  } catch (e) {
179
- fieldErrors.value = apiFieldErrors(e);
180
- saveError.value = errorReason(e, t("integration.credentials.error.save"));
181
- toast.error(saveError.value);
196
+ saveRefusal.value = e;
197
+ void focusFirstRefusedField();
198
+ toast.error(errorReason(e, t("integration.credentials.error.save")));
182
199
  } finally {
183
200
  saving.value = false;
184
201
  }
@@ -351,7 +368,10 @@ async function confirmDelete() {
351
368
  <IntegrationsCredentialRevenexxSource
352
369
  v-if="hasRevenexxSource(pickedType?.slug)"
353
370
  :type="pickedType"
354
- @update:active="(v) => sourceActive = v"
371
+ @update:active="(v) => {
372
+ sourceActive = v;
373
+ clearRefusal();
374
+ }"
355
375
  @apply="(c2) => Object.assign(form.config, c2)"
356
376
  />
357
377
 
@@ -438,14 +458,16 @@ async function confirmDelete() {
438
458
 
439
459
  </div>
440
460
 
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 }}
461
+ <!-- The save was refused for something no field shows. Outside the scrolling
462
+ form on purpose: the Save button sits in the footer below, so a summary at
463
+ the end of a form that scrolls — five fields already do — would be a
464
+ refusal rendered under the fold. What is marked at a field is not repeated
465
+ here; what has no field to be marked at — a service-side field, a conflict,
466
+ a gateway error — would otherwise be silent. -->
467
+ <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">
468
+ <!-- Keyed by position: two undrawn keys can be refused with the same
469
+ sentence, and a duplicate key is how one of them goes unrendered. -->
470
+ <p v-for="(reason, i) in summaryReasons" :key="i">{{ reason }}</p>
449
471
  </div>
450
472
 
451
473
  <DialogFooter v-if="pickedType" class="sm:justify-between">
@@ -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
  *
@@ -18,20 +18,20 @@ export function apiValidationIssues(err) {
18
18
  }
19
19
  return issues;
20
20
  }
21
- export function apiFieldErrors(err) {
22
- if (!(err instanceof IntegrationsApiError) || err.status !== 422) return {};
21
+ export function apiRefusal(err, drawn) {
22
+ const refusal = { atFields: {}, unshown: [] };
23
+ if (!(err instanceof IntegrationsApiError) || err.status !== 422) return refusal;
23
24
  const errors = err.body?.errors;
24
- if (!errors || typeof errors !== "object") return {};
25
- const out = {};
26
- for (const [key, val] of Object.entries(errors)) {
27
- if (key === "blob" || key.startsWith("blob.")) continue;
28
- const first = Array.isArray(val) ? val[0] : val;
29
- if (first !== void 0 && first !== null) out[key] = String(first);
30
- }
31
- for (const key of Object.keys(out)) {
32
- if (Object.keys(out).some((other) => other.startsWith(`${key}.`))) delete out[key];
25
+ if (!errors || typeof errors !== "object") return refusal;
26
+ const keys = Object.keys(errors).filter((key) => key !== "blob" && !key.startsWith("blob."));
27
+ for (const key of keys) {
28
+ if (keys.some((other) => other.startsWith(`${key}.`))) continue;
29
+ const messages = (Array.isArray(errors[key]) ? errors[key] : [errors[key]]).filter((message) => message !== void 0 && message !== null).map(String);
30
+ if (!messages.length) continue;
31
+ if (drawn.includes(key)) refusal.atFields[key] = messages[0];
32
+ else refusal.unshown.push(...messages.map((message) => ({ key, message })));
33
33
  }
34
- return out;
34
+ return refusal;
35
35
  }
36
36
  export function errorReason(err, fallback) {
37
37
  const raw = err?.message?.trim();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "1.4.0",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {