@revenexx/studio-integrations 1.2.0 → 1.4.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 (82) hide show
  1. package/CHANGELOG.md +581 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.d.vue.ts +9 -0
  4. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue +53 -0
  5. package/dist/runtime/components/Integrations/CanvasShortcutsDialog.vue.d.ts +9 -0
  6. package/dist/runtime/components/Integrations/EditorTip.vue +2 -15
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -2
  8. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +15 -1
  9. package/dist/runtime/components/Integrations/NodeConfigField.vue +158 -49
  10. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +15 -1
  11. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +5 -1
  12. package/dist/runtime/components/Integrations/NodeInspector.vue +136 -81
  13. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +5 -1
  14. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.d.vue.ts +21 -0
  15. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue +133 -0
  16. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue.d.ts +21 -0
  17. package/dist/runtime/components/Integrations/RunDetailPanel.vue +80 -33
  18. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.d.vue.ts +16 -0
  19. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +111 -0
  20. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue.d.ts +16 -0
  21. package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +0 -4
  22. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -6
  23. package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +0 -4
  24. package/dist/runtime/components/Integrations/TemplateInstallStep.d.vue.ts +25 -0
  25. package/dist/runtime/components/Integrations/TemplateInstallStep.vue +198 -0
  26. package/dist/runtime/components/Integrations/TemplateInstallStep.vue.d.ts +25 -0
  27. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +2 -2
  28. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +22 -40
  29. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +2 -2
  30. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +94 -65
  31. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +36 -2
  32. package/dist/runtime/components/Integrations/WorkflowEditor.vue +200 -54
  33. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +36 -2
  34. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +6 -2
  35. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -106
  36. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +6 -2
  37. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.d.vue.ts +13 -0
  38. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +216 -0
  39. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue.d.ts +13 -0
  40. package/dist/runtime/components/Integrations/WorkflowsTable.vue +22 -11
  41. package/dist/runtime/composables/useCanvasShortcuts.d.ts +34 -0
  42. package/dist/runtime/composables/useCanvasShortcuts.js +11 -8
  43. package/dist/runtime/composables/useCredentialTypes.js +3 -9
  44. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -14
  45. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  46. package/dist/runtime/composables/useIntegrationsRuns.js +8 -13
  47. package/dist/runtime/composables/useIntegrationsSecrets.js +5 -5
  48. package/dist/runtime/composables/useIntegrationsTemplates.js +10 -8
  49. package/dist/runtime/composables/useIntegrationsTriggers.js +1 -4
  50. package/dist/runtime/composables/useIntegrationsWorkflows.js +11 -7
  51. package/dist/runtime/data/canvasShortcuts.d.ts +208 -0
  52. package/dist/runtime/data/canvasShortcuts.js +68 -0
  53. package/dist/runtime/data/integrationNodeCatalog.d.ts +25 -0
  54. package/dist/runtime/data/integrationNodeCatalog.js +8 -0
  55. package/dist/runtime/data/nodeVersionMove.d.ts +72 -0
  56. package/dist/runtime/data/nodeVersionMove.js +65 -0
  57. package/dist/runtime/i18n/messages/de.json +83 -20
  58. package/dist/runtime/i18n/messages/en.json +83 -20
  59. package/dist/runtime/pages/integrations/templates/[slug].vue +22 -132
  60. package/dist/runtime/pages/integrations/workflows/[id].vue +158 -53
  61. package/dist/runtime/pages/integrations/workflows/index.vue +23 -4
  62. package/dist/runtime/types/integrations.d.ts +44 -1
  63. package/dist/runtime/utils/apiEnvelope.d.ts +27 -0
  64. package/dist/runtime/utils/apiEnvelope.js +20 -0
  65. package/dist/runtime/utils/buildSettle.d.ts +68 -0
  66. package/dist/runtime/utils/buildSettle.js +29 -0
  67. package/dist/runtime/utils/configDefaults.d.ts +18 -0
  68. package/dist/runtime/utils/configDefaults.js +7 -0
  69. package/dist/runtime/utils/errorPathAdvice.d.ts +60 -0
  70. package/dist/runtime/utils/errorPathAdvice.js +19 -0
  71. package/dist/runtime/utils/notify.d.ts +2 -2
  72. package/dist/runtime/utils/runEnvelope.d.ts +12 -0
  73. package/dist/runtime/utils/runEnvelope.js +2 -1
  74. package/dist/runtime/utils/selectOptions.d.ts +48 -0
  75. package/dist/runtime/utils/selectOptions.js +19 -0
  76. package/dist/runtime/utils/settingCondition.d.ts +48 -0
  77. package/dist/runtime/utils/settingCondition.js +84 -0
  78. package/dist/runtime/utils/shortcutLabel.d.ts +40 -0
  79. package/dist/runtime/utils/shortcutLabel.js +48 -0
  80. package/dist/runtime/utils/stateNamespaceName.d.ts +23 -0
  81. package/dist/runtime/utils/stateNamespaceName.js +8 -0
  82. package/package.json +1 -1
@@ -14,7 +14,6 @@ import {
14
14
  RefreshCircle as Loader2
15
15
  } from "#solar-icons";
16
16
  import { Badge, Button } from "@revenexx/studio";
17
- import { toast } from "vue-sonner";
18
17
  import "@vue-flow/core/dist/style.css";
19
18
  import "@vue-flow/core/dist/theme-default.css";
20
19
  import WorkflowNodeView from "./WorkflowNodeView.vue";
@@ -27,7 +26,6 @@ import { findManifest } from "../../data/integrationNodeCatalog";
27
26
  import { OPPOSITE_SIDE } from "./canvasSides";
28
27
  import { inputHandleId, outputHandleId } from "../../data/handleId";
29
28
  import { nodeLoopData } from "../../data/loopBody";
30
- import { triggerGlyph } from "../../data/triggerGlyphs";
31
29
  import { formatDuration } from "../../utils/formatDuration";
32
30
  import { SAFETY_POLL_MS } from "../../utils/pollBudget";
33
31
  import { runStatusClass, runStatusLabel } from "../../utils/statusStyle";
@@ -37,7 +35,7 @@ const props = defineProps({
37
35
  initialRunId: { type: [String, null], required: false },
38
36
  single: { type: Boolean, required: false }
39
37
  });
40
- const { loadForWorkflow, getSteps } = useIntegrationsRuns();
38
+ const { getSteps } = useIntegrationsRuns();
41
39
  const t = useIntegrationsT();
42
40
  const TERMINAL = /* @__PURE__ */ new Set(["completed", "succeeded", "failed", "cancelled", "terminated"]);
43
41
  const STEPS_POLL_MS = 4e3;
@@ -46,33 +44,7 @@ const { delivering: realtimeDelivering, runTick, lastRunEvent } = useIntegration
46
44
  function stepsPollDelay() {
47
45
  return realtimeDelivering.value ? SAFETY_POLL_MS : STEPS_POLL_MS;
48
46
  }
49
- const runs = ref([]);
50
- const runsPage = ref(1);
51
- const runsLastPage = ref(1);
52
- const runsLoading = ref(false);
53
47
  const selectedRunId = ref(null);
54
- async function loadRuns(reset = false) {
55
- if (runsLoading.value) return;
56
- runsLoading.value = true;
57
- try {
58
- const res = await loadForWorkflow(props.workflow.id, props.workflow.name, {
59
- page: reset ? 1 : runsPage.value,
60
- reconcile: true
61
- });
62
- runs.value = reset ? res.runs : [...runs.value, ...res.runs];
63
- runsPage.value = res.page;
64
- runsLastPage.value = res.lastPage;
65
- } catch (e) {
66
- toast.error(errorReason(e, t("integration.replay.toast.loadRunsFailed")));
67
- } finally {
68
- runsLoading.value = false;
69
- }
70
- }
71
- function loadMoreRuns() {
72
- if (runsPage.value >= runsLastPage.value) return;
73
- runsPage.value += 1;
74
- void loadRuns();
75
- }
76
48
  const steps = ref([]);
77
49
  const stepsSource = ref(null);
78
50
  const runStatus = ref(null);
@@ -382,14 +354,6 @@ const edgeTypes = {
382
354
  preview.onNodeClick((evt) => {
383
355
  if (evt.node.type === "workflow") onNodeClick(evt.node.id);
384
356
  });
385
- function formatDateTime(iso) {
386
- if (!iso) return "\u2014";
387
- try {
388
- return new Date(iso).toLocaleString();
389
- } catch {
390
- return String(iso);
391
- }
392
- }
393
357
  function formatTime(iso) {
394
358
  if (!iso) return "\u2014";
395
359
  try {
@@ -405,30 +369,16 @@ function pretty(v) {
405
369
  return String(v);
406
370
  }
407
371
  }
408
- function triggerIcon(type) {
409
- return triggerGlyph(type);
410
- }
411
372
  const activeStepTitle = computed(() => {
412
373
  const s = activeStep.value;
413
374
  if (!s) return "";
414
375
  return `${s.node_slug || s.node_id}${s.attempt > 0 ? ` (#${s.attempt + 1})` : ""}`;
415
376
  });
416
- onMounted(async () => {
417
- if (props.single && props.initialRunId) {
418
- selectRun(props.initialRunId);
419
- return;
420
- }
421
- await loadRuns(true);
422
- const initial = props.initialRunId && runs.value.some((r) => r.id === props.initialRunId) ? props.initialRunId : runs.value[0]?.id;
423
- if (initial) selectRun(initial);
377
+ onMounted(() => {
378
+ if (props.initialRunId) selectRun(props.initialRunId);
424
379
  });
425
380
  watch(() => props.initialRunId, (id) => {
426
- if (!id) return;
427
- if (props.single) {
428
- selectRun(id);
429
- return;
430
- }
431
- if (runs.value.some((r) => r.id === id)) selectRun(id);
381
+ if (id) selectRun(id);
432
382
  });
433
383
  </script>
434
384
 
@@ -589,58 +539,6 @@ watch(() => props.initialRunId, (id) => {
589
539
  </p>
590
540
  </aside>
591
541
 
592
- <!-- Run list — hidden in single mode (the run detail replays ONE run). -->
593
- <aside v-if="!single" class="flex w-80 shrink-0 flex-col border-l border-black/[0.06] bg-white">
594
- <div class="border-b border-black/[0.06] px-3 py-2">
595
- <div class="text-sm font-semibold">{{ t("integration.replay.runsList.heading") }}</div>
596
- <p class="text-sm text-muted-foreground">
597
- {{ t("integration.replay.runsList.description") }}
598
- </p>
599
- </div>
600
-
601
- <div class="flex-1 space-y-1 overflow-y-auto p-2">
602
- <button
603
- v-for="r in runs"
604
- :key="r.id"
605
- type="button"
606
- class="w-full rounded-lg border p-2.5 text-left transition"
607
- :class="selectedRunId === r.id ? 'border-primary/40 bg-primary/[0.04]' : 'border-transparent hover:border-black/[0.08] hover:bg-muted/30'"
608
- @click="selectRun(r.id)"
609
- >
610
- <div class="flex items-center justify-between gap-2">
611
- <span class="flex min-w-0 items-center gap-1.5 text-sm font-medium">
612
- <component :is="triggerIcon(r.triggerType)" :size="12" class="shrink-0 text-muted-foreground" />
613
- <span class="truncate">#{{ r.id }}</span>
614
- </span>
615
- <Badge variant="outline" class="shrink-0 border text-xs font-medium uppercase" :class="runStatusClass(r.status)">
616
- {{ runStatusLabel(r.status) }}
617
- </Badge>
618
- </div>
619
- <div class="mt-0.5 flex items-center justify-between gap-2 text-sm text-muted-foreground">
620
- <span class="tabular-nums">{{ formatDateTime(r.startedAt) }}</span>
621
- <span class="tabular-nums">{{ formatDuration(r.durationMs) }}</span>
622
- </div>
623
- </button>
624
-
625
- <p v-if="!runs.length && !runsLoading" class="px-2 py-6 text-center text-sm text-muted-foreground">
626
- {{ t("integration.replay.runsList.empty") }}
627
- </p>
628
-
629
- <div v-if="runsLoading" class="flex justify-center py-3">
630
- <Loader2 :size="15" class="animate-spin text-muted-foreground" />
631
- </div>
632
- <Button
633
- v-else-if="runsPage < runsLastPage"
634
- variant="ghost"
635
- size="sm"
636
- class="w-full text-sm"
637
- @click="loadMoreRuns"
638
- >
639
- {{ t("integration.replay.runsList.loadMore") }}
640
- </Button>
641
- </div>
642
- </aside>
643
-
644
542
  </div>
645
543
  </template>
646
544
 
@@ -3,9 +3,13 @@ import '@vue-flow/core/dist/theme-default.css';
3
3
  import type { Workflow } from '../../types/integrations.js';
4
4
  type __VLS_Props = {
5
5
  workflow: Workflow;
6
- /** Preselect a run (deep-link from the Runs table). */
6
+ /** The run to replay (deep-link from the Runs table). */
7
7
  initialRunId?: string | null;
8
- /** Replay ONLY the initial run: no run list, no browsing (run detail view). */
8
+ /**
9
+ * @deprecated Accepted and ignored since PO-350 — the panel has no other mode
10
+ * to switch out of. Kept so a host passing it keeps working; drop it at the
11
+ * next major.
12
+ */
9
13
  single?: boolean;
10
14
  };
11
15
  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>;
@@ -0,0 +1,13 @@
1
+ import type { WorkflowStateNamespace } from '../../types/integrations.js';
2
+ type __VLS_Props = {
3
+ open: boolean;
4
+ state: WorkflowStateNamespace[];
5
+ };
6
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ "update:open": (args_0: boolean) => any;
8
+ "update:state": (next: WorkflowStateNamespace[]) => any;
9
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
+ "onUpdate:open"?: ((args_0: boolean) => any) | undefined;
11
+ "onUpdate:state"?: ((next: WorkflowStateNamespace[]) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ export default _default;
@@ -0,0 +1,216 @@
1
+ <script setup>
2
+ import { computed, ref, watch } from "vue";
3
+ import { AddCircle as Plus, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
+ import {
5
+ Button,
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ Input,
13
+ Label,
14
+ Select,
15
+ SelectContent,
16
+ SelectItem,
17
+ SelectTrigger,
18
+ SelectValue
19
+ } from "@revenexx/studio";
20
+ import { deepClone } from "../../utils/deepClone";
21
+ import { stateNamespaceErrorKey } from "../../utils/stateNamespaceName";
22
+ const props = defineProps({
23
+ open: { type: Boolean, required: true },
24
+ state: { type: Array, required: true }
25
+ });
26
+ const emit = defineEmits(["update:open", "update:state"]);
27
+ const t = useIntegrationsT();
28
+ const ROLES = ["mapping", "cursor", "dedupe", "digest"];
29
+ const working = ref([]);
30
+ watch(() => props.open, (isOpen) => {
31
+ if (!isOpen) return;
32
+ working.value = deepClone(props.state ?? []);
33
+ newName.value = "";
34
+ newRole.value = "mapping";
35
+ newVisibility.value = "private";
36
+ });
37
+ const changed = computed(() => JSON.stringify(working.value) !== JSON.stringify(props.state ?? []));
38
+ function applyAndClose() {
39
+ if (changed.value) emit("update:state", working.value);
40
+ emit("update:open", false);
41
+ }
42
+ function setVisibility(index, visibility) {
43
+ const next = [...working.value];
44
+ const row = next[index];
45
+ if (!row) return;
46
+ next[index] = { ...row, visibility };
47
+ working.value = next;
48
+ }
49
+ function removeNamespace(index) {
50
+ working.value = working.value.filter((_, i) => i !== index);
51
+ }
52
+ const newName = ref("");
53
+ const newRole = ref("mapping");
54
+ const newVisibility = ref("private");
55
+ const newNameError = computed(() => {
56
+ const key = stateNamespaceErrorKey(newName.value, working.value.map((n) => n.namespace));
57
+ return key ? t(key) : null;
58
+ });
59
+ function addNamespace() {
60
+ const namespace = newName.value.trim();
61
+ if (!namespace || newNameError.value) return;
62
+ working.value = [...working.value, { namespace, role: newRole.value, visibility: newVisibility.value }];
63
+ newName.value = "";
64
+ }
65
+ </script>
66
+
67
+ <template>
68
+ <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
69
+ <DialogContent class="max-w-[94vw] sm:max-w-3xl">
70
+ <DialogHeader>
71
+ <DialogTitle>{{ t("integration.workflows.state.dialog.title") }}</DialogTitle>
72
+ <DialogDescription class="text-sm">
73
+ {{ t("integration.workflows.state.dialog.description") }}
74
+ </DialogDescription>
75
+ </DialogHeader>
76
+
77
+ <div class="max-h-[62vh] space-y-3 overflow-y-auto pr-1">
78
+ <p v-if="!working.length" class="rounded-lg border border-dashed border-black/[0.12] px-4 py-6 text-center text-sm text-muted-foreground">
79
+ {{ t("integration.workflows.state.empty") }}
80
+ </p>
81
+
82
+ <div
83
+ v-for="(row, index) in working"
84
+ :key="row.namespace"
85
+ role="group"
86
+ :aria-label="t('integration.workflows.state.row.aria', { namespace: row.namespace })"
87
+ class="rounded-lg border border-black/[0.06] bg-white p-3"
88
+ >
89
+ <div class="flex items-center gap-2">
90
+ <code class="min-w-0 truncate font-mono text-sm font-semibold">{{ row.namespace }}</code>
91
+ <!-- The role is stated, not offered: entries written under one
92
+ meaning cannot be re-read under another (specs AC-3). -->
93
+ <span class="shrink-0 rounded-lg bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
94
+ {{ t(`integration.workflows.state.role.${row.role}.label`) }}
95
+ </span>
96
+ <div class="flex-1" />
97
+ <button
98
+ type="button"
99
+ class="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
100
+ :title="t('integration.workflows.state.action.remove')"
101
+ @click="removeNamespace(index)"
102
+ >
103
+ <TrashBin :size="13" />
104
+ </button>
105
+ </div>
106
+
107
+ <p class="mt-1 text-xs text-muted-foreground">
108
+ {{ t(`integration.workflows.state.role.${row.role}.hint`) }}
109
+ {{ t("integration.workflows.state.role.fixed") }}
110
+ </p>
111
+
112
+ <div class="mt-2 grid gap-2 sm:grid-cols-[10rem_1fr]">
113
+ <div class="space-y-1">
114
+ <Label :for="`state-${row.namespace}-visibility`" class="text-mono text-muted-foreground">
115
+ {{ t("integration.workflows.state.field.visibility.label") }}
116
+ </Label>
117
+ <Select
118
+ :model-value="row.visibility ?? 'private'"
119
+ @update:model-value="(v) => setVisibility(index, String(v))"
120
+ >
121
+ <SelectTrigger
122
+ :id="`state-${row.namespace}-visibility`"
123
+ :aria-label="t('integration.workflows.state.field.visibility.label')"
124
+ class="h-8 bg-white text-sm"
125
+ >
126
+ <SelectValue />
127
+ </SelectTrigger>
128
+ <SelectContent>
129
+ <SelectItem value="private">
130
+ {{ t("integration.workflows.state.visibility.private.label") }}
131
+ </SelectItem>
132
+ <SelectItem value="shared">
133
+ {{ t("integration.workflows.state.visibility.shared.label") }}
134
+ </SelectItem>
135
+ </SelectContent>
136
+ </Select>
137
+ </div>
138
+ <!-- What the choice costs, beside the choice — a shared namespace is
139
+ written by every workflow in the tenant and outlives this one
140
+ (specs AC-4). -->
141
+ <p class="self-end pb-1 text-xs text-muted-foreground">
142
+ {{ t(`integration.workflows.state.visibility.${row.visibility ?? "private"}.hint`) }}
143
+ </p>
144
+ </div>
145
+
146
+ <p class="mt-2 text-xs text-muted-foreground">
147
+ {{ t("integration.workflows.state.action.remove.hint") }}
148
+ </p>
149
+ </div>
150
+
151
+ <div class="rounded-lg border border-black/[0.06] bg-muted/30 p-3">
152
+ <Label for="state-new-name" class="text-mono text-muted-foreground">
153
+ {{ t("integration.workflows.state.field.newName.label") }}
154
+ </Label>
155
+ <div class="mt-1 grid gap-2 sm:grid-cols-[1fr_10rem_10rem_auto]">
156
+ <Input
157
+ id="state-new-name"
158
+ v-model="newName"
159
+ class="h-8 bg-white font-mono text-sm"
160
+ :placeholder="t('integration.workflows.state.field.newName.placeholder')"
161
+ @keydown.enter.prevent="addNamespace"
162
+ />
163
+ <!-- The role IS chosen here, and only here — the positive half of
164
+ AC-3, without which "no role control on a row" proves nothing. -->
165
+ <Select v-model="newRole">
166
+ <SelectTrigger
167
+ id="state-new-role"
168
+ :aria-label="t('integration.workflows.state.field.newRole.label')"
169
+ class="h-8 bg-white text-sm"
170
+ >
171
+ <SelectValue />
172
+ </SelectTrigger>
173
+ <SelectContent>
174
+ <SelectItem v-for="role in ROLES" :key="role" :value="role">
175
+ {{ t(`integration.workflows.state.role.${role}.label`) }}
176
+ </SelectItem>
177
+ </SelectContent>
178
+ </Select>
179
+ <Select v-model="newVisibility">
180
+ <SelectTrigger
181
+ id="state-new-visibility"
182
+ :aria-label="t('integration.workflows.state.field.newVisibility.label')"
183
+ class="h-8 bg-white text-sm"
184
+ >
185
+ <SelectValue />
186
+ </SelectTrigger>
187
+ <SelectContent>
188
+ <SelectItem value="private">
189
+ {{ t("integration.workflows.state.visibility.private.label") }}
190
+ </SelectItem>
191
+ <SelectItem value="shared">
192
+ {{ t("integration.workflows.state.visibility.shared.label") }}
193
+ </SelectItem>
194
+ </SelectContent>
195
+ </Select>
196
+ <Button size="sm" class="h-8" :disabled="!newName.trim() || !!newNameError" @click="addNamespace">
197
+ <Plus :size="14" class="mr-1" />
198
+ {{ t("integration.workflows.state.action.add") }}
199
+ </Button>
200
+ </div>
201
+ <p v-if="newNameError" class="mt-1 text-xs text-rose-600">{{ newNameError }}</p>
202
+ <p class="mt-1 text-xs text-muted-foreground">{{ t(`integration.workflows.state.role.${newRole}.hint`) }}</p>
203
+ </div>
204
+ </div>
205
+
206
+ <DialogFooter>
207
+ <Button variant="outline" size="sm" class="h-8" @click="emit('update:open', false)">
208
+ {{ t("integration.common.action.cancel") }}
209
+ </Button>
210
+ <Button size="sm" class="h-8" @click="applyAndClose">
211
+ {{ t("integration.workflows.state.action.done") }}
212
+ </Button>
213
+ </DialogFooter>
214
+ </DialogContent>
215
+ </Dialog>
216
+ </template>
@@ -0,0 +1,13 @@
1
+ import type { WorkflowStateNamespace } from '../../types/integrations.js';
2
+ type __VLS_Props = {
3
+ open: boolean;
4
+ state: WorkflowStateNamespace[];
5
+ };
6
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ "update:open": (args_0: boolean) => any;
8
+ "update:state": (next: WorkflowStateNamespace[]) => any;
9
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
+ "onUpdate:open"?: ((args_0: boolean) => any) | undefined;
11
+ "onUpdate:state"?: ((next: WorkflowStateNamespace[]) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ export default _default;
@@ -14,7 +14,9 @@ import {
14
14
  DropdownMenuItem,
15
15
  DropdownMenuLabel,
16
16
  DropdownMenuSeparator,
17
- DropdownMenuTrigger
17
+ DropdownMenuTrigger,
18
+ Label,
19
+ Switch
18
20
  } from "@revenexx/studio";
19
21
  defineProps({
20
22
  workflows: { type: Array, required: true },
@@ -247,16 +249,25 @@ const columns = computed(() => [
247
249
  @row-click="(wf2) => router.push(`/integrations/workflows/${wf2.id}`)"
248
250
  >
249
251
  <template #toolbar-left>
250
- <Button
251
- variant="outline"
252
- size="sm"
253
- class="h-7 gap-1.5 text-sm"
254
- :class="groupByTags ? 'border-primary/30 text-primary' : ''"
255
- @click="groupByTags = !groupByTags"
256
- >
257
- <Layers :size="13" />
258
- {{ t("integration.workflows.table.action.groupByTags") }}
259
- </Button>
252
+ <!-- A switch rather than a button: the control is a state, not an action.
253
+ A button labelled "Group by tags" ungroups once grouping is on, and
254
+ said so neither to a reader nor to a screen reader — the state lived
255
+ in a colour class alone. The Label is what names the switch. -->
256
+ <div class="flex h-7 items-center gap-2">
257
+ <Switch
258
+ id="wf-group-by-tags"
259
+ :model-value="groupByTags"
260
+ @update:model-value="(on) => groupByTags = on"
261
+ />
262
+ <Label
263
+ for="wf-group-by-tags"
264
+ class="flex cursor-pointer items-center gap-1.5 text-sm font-normal"
265
+ :class="groupByTags ? 'text-primary' : 'text-muted-foreground'"
266
+ >
267
+ <Layers :size="13" />
268
+ {{ t("integration.workflows.table.action.groupByTags") }}
269
+ </Label>
270
+ </div>
260
271
  </template>
261
272
 
262
273
  <!-- Name (icon + title + description) -->
@@ -32,6 +32,40 @@ export type ShortcutHandler = ((event: KeyboardEvent) => void) | {
32
32
  run: (event: KeyboardEvent) => void;
33
33
  };
34
34
  export type ShortcutMap = Record<string, ShortcutHandler>;
35
+ /**
36
+ * A map key in its comparable form.
37
+ *
38
+ * Exported for the unit suite: the whole layout question below is decided here
39
+ * and in {@link shortcutCandidates}, and both are cheaper to prove against
40
+ * hand-built events than against a browser per keyboard layout.
41
+ */
42
+ export declare function normalizeShortcut(shortcut: string): string;
43
+ /** Everything a match reads from a keydown — so the rule needs no DOM to prove. */
44
+ export interface ShortcutKeyEvent {
45
+ key: string;
46
+ code?: string;
47
+ shiftKey?: boolean;
48
+ altKey?: boolean;
49
+ metaKey?: boolean;
50
+ ctrlKey?: boolean;
51
+ }
52
+ /**
53
+ * The two forms a keydown is looked up under.
54
+ *
55
+ * `byKey` is what the layout PRODUCED and `byCode` is which key was pressed. Both
56
+ * are needed and neither is enough: a Dvorak or AZERTY layout moves the letters,
57
+ * so a letter binding is only found by key — while a character a layout reaches
58
+ * through a modifier, or a dead key that produces no useful character at all
59
+ * (⌥⇧T is `ˇ`), is only found by code.
60
+ *
61
+ * Shift is dropped from `byKey` alone, where it is part of the character. `byCode`
62
+ * stays strict: there the physical key says nothing about what was produced, so
63
+ * Shift is the only thing separating `shift+s` from `s`.
64
+ */
65
+ export declare function shortcutCandidates(event: ShortcutKeyEvent, isMac?: boolean): {
66
+ byKey: string;
67
+ byCode: string;
68
+ };
35
69
  export declare function useCanvasShortcuts(keymap: MaybeRefOrGetter<ShortcutMap>, options?: {
36
70
  disabled?: MaybeRefOrGetter<boolean>;
37
71
  }): void;
@@ -1,26 +1,29 @@
1
1
  import { computed, onBeforeUnmount, onMounted, toValue } from "vue";
2
- const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform ?? "");
2
+ import { IS_MAC, shortcutTokens } from "../utils/shortcutLabel.js";
3
3
  function canonical(parts) {
4
4
  return parts.map((p) => p.toLowerCase()).sort((a, b) => a.localeCompare(b)).join("+");
5
5
  }
6
- function normalizeShortcut(shortcut) {
7
- if (shortcut.length === 1) return shortcut.toLowerCase();
8
- return canonical(shortcut.split("+"));
6
+ export function normalizeShortcut(shortcut) {
7
+ return canonical(shortcutTokens(shortcut));
9
8
  }
10
9
  function codeToKey(code) {
11
10
  if (code.startsWith("Key")) return code.slice(3).toLowerCase();
12
11
  if (code.startsWith("Digit")) return code.slice(5);
13
12
  return code.toLowerCase();
14
13
  }
15
- function eventShortcuts(event) {
14
+ function shiftMadeTheCharacter(loweredKey) {
15
+ return loweredKey.length === 1 && !/^[\p{L}\p{N}]$/u.test(loweredKey);
16
+ }
17
+ export function shortcutCandidates(event, isMac = IS_MAC) {
16
18
  const mods = [];
17
19
  if (event.shiftKey) mods.push("shift");
18
20
  if (event.altKey) mods.push("alt");
19
- if (IS_MAC ? event.metaKey : event.ctrlKey) mods.push("mod");
21
+ if ((isMac ? event.metaKey : event.ctrlKey) ?? false) mods.push("mod");
20
22
  const key = event.key === " " ? "space" : event.key.toLowerCase();
21
23
  const code = codeToKey(event.code ?? "");
24
+ const keyMods = shiftMadeTheCharacter(key) ? mods.filter((m) => m !== "shift") : mods;
22
25
  return {
23
- byKey: canonical([...mods, key]),
26
+ byKey: canonical([...keyMods, key]),
24
27
  byCode: canonical([...mods, code])
25
28
  };
26
29
  }
@@ -48,7 +51,7 @@ export function useCanvasShortcuts(keymap, options) {
48
51
  if (toValue(options?.disabled)) return;
49
52
  if (event.defaultPrevented) return;
50
53
  if (isEditingContext(event)) return;
51
- const { byKey, byCode } = eventShortcuts(event);
54
+ const { byKey, byCode } = shortcutCandidates(event);
52
55
  const handler = normalized.value[byKey] ?? normalized.value[byCode];
53
56
  if (!handler) return;
54
57
  const run = typeof handler === "function" ? handler : handler.disabled() ? void 0 : handler.run;
@@ -1,6 +1,7 @@
1
1
  import { useIntegrationsApi } from "./integrationsApi.js";
2
2
  import { useIntegrationsT } from "./useIntegrationsT.js";
3
3
  import { errorReason } from "../utils/apiError.js";
4
+ import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
4
5
  import { useLoadGate } from "./useLoadGate.js";
5
6
  export function useCredentialTypes() {
6
7
  const types = useState("integrations.credentialTypes", () => []);
@@ -10,17 +11,11 @@ export function useCredentialTypes() {
10
11
  const gate = useLoadGate("integrations.credentialTypes", { loading, loaded });
11
12
  const api = useIntegrationsApi();
12
13
  const t = useIntegrationsT();
13
- function normalize(res) {
14
- const obj = res;
15
- if (Array.isArray(obj)) return obj;
16
- if (Array.isArray(obj?.data)) return obj.data;
17
- return [];
18
- }
19
14
  async function load(force = false) {
20
15
  if (!await gate.claim(force)) return types.value;
21
16
  error.value = null;
22
17
  try {
23
- types.value = normalize(await api.get("/credential-types"));
18
+ types.value = unwrapList(await api.get("/credential-types"));
24
19
  loaded.value = true;
25
20
  } catch (e) {
26
21
  error.value = errorReason(e, t("integration.credentials.error.loadTypes"));
@@ -36,8 +31,7 @@ export function useCredentialTypes() {
36
31
  const cached = bySlug(slug);
37
32
  if (cached) return cached;
38
33
  try {
39
- const res = await api.get(`/credential-types/${encodeURIComponent(slug)}`);
40
- return res?.data ?? res ?? null;
34
+ return unwrapRecord(await api.get(`/credential-types/${encodeURIComponent(slug)}`));
41
35
  } catch {
42
36
  return null;
43
37
  }
@@ -2,6 +2,7 @@ import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
4
  import { IntegrationsApiError, errorReason } from "../utils/apiError.js";
5
+ import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
5
6
  import { useLoadGate } from "./useLoadGate.js";
6
7
  export function useIntegrationsCredentials() {
7
8
  const credentials = useState("integrations.credentials", () => []);
@@ -11,20 +12,11 @@ export function useIntegrationsCredentials() {
11
12
  const gate = useLoadGate("integrations.credentials", { loading, loaded });
12
13
  const api = useIntegrationsApi();
13
14
  const t = useIntegrationsT();
14
- function normalize(res) {
15
- const obj = res;
16
- if (Array.isArray(obj)) return obj;
17
- if (Array.isArray(obj?.data)) return obj.data;
18
- return [];
19
- }
20
- function unwrap(res) {
21
- return res?.data ?? res;
22
- }
23
15
  async function load(force = false) {
24
16
  if (!await gate.claim(force)) return credentials.value;
25
17
  error.value = null;
26
18
  try {
27
- credentials.value = normalize(await api.get("/credentials"));
19
+ credentials.value = unwrapList(await api.get("/credentials"));
28
20
  loaded.value = true;
29
21
  } catch (e) {
30
22
  error.value = errorReason(e, t("integration.credentials.error.loadFailed"));
@@ -37,15 +29,15 @@ export function useIntegrationsCredentials() {
37
29
  return credentials.value.filter((c) => c.credential_type_slug === slug);
38
30
  }
39
31
  async function get(id) {
40
- return unwrap(await api.get(`/credentials/${encodeURIComponent(id)}`));
32
+ return unwrapRecord(await api.get(`/credentials/${encodeURIComponent(id)}`));
41
33
  }
42
34
  async function create(input) {
43
- const created = unwrap(await api.post("/credentials", input));
35
+ const created = unwrapRecord(await api.post("/credentials", input));
44
36
  await load(true);
45
37
  return created;
46
38
  }
47
39
  async function update(id, input) {
48
- const updated = unwrap(await api.patch(`/credentials/${encodeURIComponent(id)}`, input));
40
+ const updated = unwrapRecord(await api.patch(`/credentials/${encodeURIComponent(id)}`, input));
49
41
  await load(true);
50
42
  return updated;
51
43
  }
@@ -71,7 +63,7 @@ export function useIntegrationsCredentials() {
71
63
  }
72
64
  async function oauthAuthorizeUrl(id) {
73
65
  const res = await api.post(`/credentials/${encodeURIComponent(id)}/oauth/authorize-url`);
74
- const authorizeUrl = res?.authorize_url ?? res?.data?.authorize_url;
66
+ const authorizeUrl = unwrapRecord(res).authorize_url;
75
67
  if (!authorizeUrl) throw new IntegrationsApiError(0, res, t("integration.credentials.error.noAuthorizeUrl"));
76
68
  return authorizeUrl;
77
69
  }