@revenexx/studio-integrations 0.6.0 → 0.7.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 (110) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/README.md +31 -0
  3. package/dist/module.d.mts +17 -1
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +10 -2
  6. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +29 -28
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +38 -37
  8. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -58
  9. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +10 -9
  10. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +18 -17
  11. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -3
  12. package/dist/runtime/components/Integrations/ExpressionInput.vue +16 -15
  13. package/dist/runtime/components/Integrations/GroupFrameView.vue +29 -19
  14. package/dist/runtime/components/Integrations/Layout.vue +28 -3
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +15 -14
  16. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +9 -8
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +43 -42
  18. package/dist/runtime/components/Integrations/NodeInspector.vue +225 -216
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +1 -1
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +3 -2
  21. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +1 -1
  22. package/dist/runtime/components/Integrations/NodePalette.vue +30 -29
  23. package/dist/runtime/components/Integrations/QueryBuilder.vue +44 -43
  24. package/dist/runtime/components/Integrations/RunActionsMenu.vue +36 -12
  25. package/dist/runtime/components/Integrations/RunDetailPanel.vue +114 -62
  26. package/dist/runtime/components/Integrations/RunPayloadInput.vue +14 -13
  27. package/dist/runtime/components/Integrations/RunResponseModal.vue +11 -10
  28. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +0 -1
  29. package/dist/runtime/components/Integrations/RunsTable.vue +38 -16
  30. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +0 -1
  31. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +8 -7
  32. package/dist/runtime/components/Integrations/Sidebar.vue +14 -10
  33. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +14 -4
  34. package/dist/runtime/components/Integrations/StickyNoteView.vue +37 -23
  35. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +14 -4
  36. package/dist/runtime/components/Integrations/TemplateCard.vue +16 -6
  37. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +10 -9
  38. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +34 -33
  39. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +36 -35
  40. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +8 -7
  41. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +6 -4
  42. package/dist/runtime/components/Integrations/WorkflowEditor.vue +82 -79
  43. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +24 -23
  44. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +7 -6
  45. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +19 -20
  46. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +52 -39
  47. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +20 -19
  48. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +9 -8
  49. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +12 -9
  50. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +27 -26
  51. package/dist/runtime/components/Integrations/WorkflowsTable.vue +83 -45
  52. package/dist/runtime/composables/integrationsApi.js +4 -2
  53. package/dist/runtime/composables/useCredentialTypes.js +4 -2
  54. package/dist/runtime/composables/useIntegrationsCredentials.js +6 -4
  55. package/dist/runtime/composables/useIntegrationsFocus.d.ts +12 -0
  56. package/dist/runtime/composables/useIntegrationsLocale.d.ts +26 -0
  57. package/dist/runtime/composables/useIntegrationsLocale.js +8 -0
  58. package/dist/runtime/composables/useIntegrationsNodes.js +3 -1
  59. package/dist/runtime/composables/useIntegrationsRealtime.d.ts +149 -0
  60. package/dist/runtime/composables/useIntegrationsRealtime.js +80 -0
  61. package/dist/runtime/composables/useIntegrationsRevisions.js +9 -7
  62. package/dist/runtime/composables/useIntegrationsRuns.d.ts +1 -1
  63. package/dist/runtime/composables/useIntegrationsRuns.js +3 -1
  64. package/dist/runtime/composables/useIntegrationsSecrets.js +4 -2
  65. package/dist/runtime/composables/useIntegrationsT.d.ts +35 -0
  66. package/dist/runtime/composables/useIntegrationsT.js +9 -0
  67. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +3 -3
  68. package/dist/runtime/composables/useIntegrationsTemplates.js +16 -8
  69. package/dist/runtime/composables/useIntegrationsTriggers.js +4 -2
  70. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -3
  71. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  72. package/dist/runtime/data/integrationNodeCatalog.d.ts +41 -0
  73. package/dist/runtime/data/integrationNodeCatalog.js +14 -0
  74. package/dist/runtime/data/integrationTemplates.d.ts +9 -0
  75. package/dist/runtime/data/integrationTemplates.js +7 -1
  76. package/dist/runtime/data/payloadForm.js +2 -1
  77. package/dist/runtime/data/workflowIconCatalog.js +2 -1
  78. package/dist/runtime/i18n/messages/de.json +1207 -0
  79. package/dist/runtime/i18n/messages/en.json +1207 -0
  80. package/dist/runtime/i18n/translate.d.ts +83 -0
  81. package/dist/runtime/i18n/translate.js +41 -0
  82. package/dist/runtime/pages/integrations/audit.vue +52 -34
  83. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +3 -2
  84. package/dist/runtime/pages/integrations/credentials.vue +70 -52
  85. package/dist/runtime/pages/integrations/index.vue +50 -56
  86. package/dist/runtime/pages/integrations/runs.vue +33 -21
  87. package/dist/runtime/pages/integrations/schedules.vue +49 -28
  88. package/dist/runtime/pages/integrations/secrets.vue +34 -34
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +77 -68
  90. package/dist/runtime/pages/integrations/templates/index.vue +35 -27
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +164 -99
  92. package/dist/runtime/pages/integrations/workflows/index.vue +10 -5
  93. package/dist/runtime/plugins/locale.d.ts +10 -0
  94. package/dist/runtime/plugins/locale.js +6 -0
  95. package/dist/runtime/plugins/realtime.client.d.ts +32 -0
  96. package/dist/runtime/plugins/realtime.client.js +4 -0
  97. package/dist/runtime/utils/credentialState.d.ts +24 -1
  98. package/dist/runtime/utils/credentialState.js +26 -11
  99. package/dist/runtime/utils/localize.d.ts +18 -5
  100. package/dist/runtime/utils/localize.js +32 -9
  101. package/dist/runtime/utils/pollBudget.d.ts +72 -0
  102. package/dist/runtime/utils/pollBudget.js +17 -0
  103. package/dist/runtime/utils/schedule.d.ts +0 -25
  104. package/dist/runtime/utils/schedule.js +37 -25
  105. package/dist/runtime/utils/statusStyle.d.ts +26 -2
  106. package/dist/runtime/utils/statusStyle.js +9 -1
  107. package/dist/runtime/utils/validationFindings.d.ts +2 -2
  108. package/dist/runtime/utils/validationFindings.js +4 -3
  109. package/dist/types.d.mts +2 -6
  110. package/package.json +3 -2
@@ -1,11 +1,12 @@
1
1
  <script setup>
2
2
  import { onMounted, ref, watch } from "vue";
3
- import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as Trash2 } from "#solar-icons";
3
+ import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
4
  import { Badge, Button } from "@revenexx/studio";
5
5
  import { toast } from "vue-sonner";
6
6
  const props = defineProps({
7
7
  workflowId: { type: Number, required: true }
8
8
  });
9
+ const t = useIntegrationsT();
9
10
  const { list, replay, discard } = useIntegrationsDeadLetters();
10
11
  const PER_PAGE = 25;
11
12
  const items = ref([]);
@@ -25,7 +26,7 @@ async function load(p = page.value) {
25
26
  lastPage.value = res.lastPage;
26
27
  page.value = res.page;
27
28
  } catch (e) {
28
- error.value = e?.message ?? "Could not load dead letters.";
29
+ error.value = e?.message ?? t("integration.deadLetters.error.load");
29
30
  } finally {
30
31
  loading.value = false;
31
32
  }
@@ -41,10 +42,10 @@ async function onReplay(dl) {
41
42
  try {
42
43
  const res = await replay(dl.id);
43
44
  const newId = res && typeof res === "object" && "id" in res ? String(res.id) : null;
44
- toast.success(newId ? `Replayed \u2014 started run #${newId}` : "Replayed");
45
+ toast.success(newId ? t("integration.deadLetters.toast.replay.started", { id: newId }) : t("integration.deadLetters.toast.replay.done"));
45
46
  await load();
46
47
  } catch (e) {
47
- toast.error(e?.message ?? "Could not replay.");
48
+ toast.error(e?.message ?? t("integration.deadLetters.toast.replay.error"));
48
49
  } finally {
49
50
  busyId.value = null;
50
51
  }
@@ -53,10 +54,10 @@ async function onDiscard(dl) {
53
54
  busyId.value = dl.id;
54
55
  try {
55
56
  await discard(dl.id);
56
- toast.success("Discarded");
57
+ toast.success(t("integration.deadLetters.toast.discard.done"));
57
58
  await load();
58
59
  } catch (e) {
59
- toast.error(e?.message ?? "Could not discard.");
60
+ toast.error(e?.message ?? t("integration.deadLetters.toast.discard.error"));
60
61
  } finally {
61
62
  busyId.value = null;
62
63
  }
@@ -80,14 +81,14 @@ function fmt(iso) {
80
81
  <div class="space-y-3">
81
82
  <div class="flex items-center justify-between">
82
83
  <div>
83
- <h3 class="text-sm font-semibold">Missed runs</h3>
84
+ <h3 class="text-sm font-semibold">{{ t("integration.deadLetters.panel.title") }}</h3>
84
85
  <p class="mt-0.5 text-sm text-muted-foreground">
85
- Triggers that fired while this workflow couldn't run (bundle building or failed). Replay starts a fresh run from the captured payload.
86
+ {{ t("integration.deadLetters.panel.description") }}
86
87
  </p>
87
88
  </div>
88
89
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="loading" @click="() => load()">
89
90
  <Loader2 v-if="loading" :size="12" class="animate-spin" />
90
- Refresh
91
+ {{ t("integration.common.action.refresh") }}
91
92
  </Button>
92
93
  </div>
93
94
 
@@ -107,7 +108,7 @@ function fmt(iso) {
107
108
  </div>
108
109
  </div>
109
110
  <div v-else-if="!items.length" class="rounded-lg border border-black/[0.06] bg-white p-6 text-center text-sm text-muted-foreground">
110
- No missed runs. Every trigger firing made it into a run.
111
+ {{ t("integration.deadLetters.state.empty") }}
111
112
  </div>
112
113
 
113
114
  <div v-else class="rounded-lg border border-black/[0.06] bg-white divide-y divide-black/[0.04]">
@@ -118,7 +119,7 @@ function fmt(iso) {
118
119
  >
119
120
  <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', statusClass(dl.status)]">{{ dl.status }}</Badge>
120
121
  <div class="min-w-0 flex-1">
121
- <div class="text-sm break-words">{{ dl.reason || "No reason recorded." }}</div>
122
+ <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
122
123
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
123
124
  <span>#{{ dl.id }}</span>
124
125
  <span v-if="dl.source">{{ dl.source }}</span>
@@ -130,21 +131,21 @@ function fmt(iso) {
130
131
  <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="busyId === dl.id" @click="onReplay(dl)">
131
132
  <Loader2 v-if="busyId === dl.id" :size="12" class="animate-spin" />
132
133
  <ReplayIcon v-else :size="12" />
133
- Replay
134
+ {{ t("integration.deadLetters.action.replay") }}
134
135
  </Button>
135
136
  <Button variant="ghost" size="sm" class="h-7 text-sm text-rose-600 hover:bg-rose-500/10" :disabled="busyId === dl.id" @click="onDiscard(dl)">
136
- <Trash2 :size="12" />
137
- Discard
137
+ <TrashBin :size="12" />
138
+ {{ t("integration.deadLetters.action.discard") }}
138
139
  </Button>
139
140
  </div>
140
141
  </div>
141
142
  </div>
142
143
 
143
144
  <div v-if="lastPage > 1" class="flex items-center justify-between text-sm text-muted-foreground">
144
- <span class="tabular-nums">Page {{ page }} / {{ lastPage }} · {{ total }} total</span>
145
+ <span class="tabular-nums">{{ t("integration.deadLetters.pagination.summary", { page, lastPage, total }) }}</span>
145
146
  <div class="flex gap-2">
146
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="page <= 1 || loading" @click="goPage(page - 1)">← Newer</Button>
147
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="page >= lastPage || loading" @click="goPage(page + 1)">Older →</Button>
147
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="page <= 1 || loading" @click="goPage(page - 1)">{{ t("integration.deadLetters.pagination.newer") }}</Button>
148
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="page >= lastPage || loading" @click="goPage(page + 1)">{{ t("integration.deadLetters.pagination.older") }}</Button>
148
149
  </div>
149
150
  </div>
150
151
  </div>
@@ -6,6 +6,7 @@ const props = defineProps({
6
6
  inputId: { type: String, required: false }
7
7
  });
8
8
  const emit = defineEmits(["update:modelValue"]);
9
+ const t = useIntegrationsT();
9
10
  const catalog = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), listens: ref([]), loading: ref(false), load: async () => {
10
11
  } };
11
12
  onMounted(() => {
@@ -15,8 +16,8 @@ const focused = ref(false);
15
16
  const options = computed(() => {
16
17
  const out = /* @__PURE__ */ new Map();
17
18
  for (const e of catalog.emits.value) out.set(e.topic, { topic: e.topic, source: e.app });
18
- for (const t of catalog.listens.value) {
19
- if (!out.has(t)) out.set(t, { topic: t, source: null });
19
+ for (const t2 of catalog.listens.value) {
20
+ if (!out.has(t2)) out.set(t2, { topic: t2, source: null });
20
21
  }
21
22
  return [...out.values()].sort((a, b) => a.topic.localeCompare(b.topic));
22
23
  });
@@ -48,7 +49,7 @@ function pick(topic) {
48
49
  class="absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-md border border-black/[0.08] bg-white py-1 shadow-md"
49
50
  >
50
51
  <div v-if="catalog.loading.value && !filtered.length" class="px-2.5 py-1.5 text-sm text-muted-foreground">
51
- Loading event catalogue…
52
+ {{ t("integration.canvas.eventCombobox.loading") }}
52
53
  </div>
53
54
  <button
54
55
  v-for="o in filtered"
@@ -17,6 +17,7 @@ const props = defineProps({
17
17
  label: { type: String, required: false }
18
18
  });
19
19
  const emit = defineEmits(["update:modelValue"]);
20
+ const t = useIntegrationsT();
20
21
  const text = computed(() => String(props.modelValue ?? ""));
21
22
  const OPEN = "${{ ";
22
23
  const CLOSE = " }}";
@@ -34,12 +35,12 @@ function pillLabel(expr) {
34
35
  }
35
36
  if (path[0] === "trigger" && path[1] === "payload") {
36
37
  const rest = path.slice(2).join(".");
37
- return rest ? "Trigger \xB7 " + rest : "Trigger payload";
38
+ return rest ? t("integration.nodeConfig.expression.pill.triggerPath", { rest }) : t("integration.nodeConfig.expression.pill.triggerPayload");
38
39
  }
39
- if (path[0] === "vars") return "Variable \xB7 " + path.slice(1).join(".");
40
+ if (path[0] === "vars") return t("integration.nodeConfig.expression.pill.variable", { path: path.slice(1).join(".") });
40
41
  if (path[0] === "in") {
41
42
  const rest = path.slice(1).join(".");
42
- return rest ? "Input \xB7 " + rest : "Input payload";
43
+ return rest ? t("integration.nodeConfig.expression.pill.inputPath", { rest }) : t("integration.nodeConfig.expression.pill.inputPayload");
43
44
  }
44
45
  return expr;
45
46
  }
@@ -104,9 +105,9 @@ onBeforeUnmount(() => {
104
105
  if (insertHandler?.value === insertToken) insertHandler.value = null;
105
106
  editor.destroy();
106
107
  });
107
- watch(text, (t) => {
108
- if (docToText(editor.getJSON()) !== t) {
109
- editor.commands.setContent(textToDoc(t));
108
+ watch(text, (t2) => {
109
+ if (docToText(editor.getJSON()) !== t2) {
110
+ editor.commands.setContent(textToDoc(t2));
110
111
  }
111
112
  });
112
113
  function insertToken(expr) {
@@ -126,7 +127,7 @@ const sections = computed(() => {
126
127
  label: key,
127
128
  expr: tok("vars." + key)
128
129
  }));
129
- if (varItems.length) out.push({ heading: "VARIABLES", items: varItems });
130
+ if (varItems.length) out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.variables"), items: varItems });
130
131
  const upstream = props.upstreamOutputs ?? [];
131
132
  const nodeItems = upstream.flatMap((u) => {
132
133
  const base = "nodes." + u.nodeId + ".outputs." + u.output;
@@ -139,13 +140,13 @@ const sections = computed(() => {
139
140
  }
140
141
  return rows;
141
142
  });
142
- if (nodeItems.length) out.push({ heading: "NODE OUTPUTS", items: nodeItems });
143
+ if (nodeItems.length) out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.nodeOutputs"), items: nodeItems });
143
144
  const primary = upstream[0];
144
- const inItems = [{ label: "input payload", expr: tok("in") }];
145
+ const inItems = [{ label: t("integration.nodeConfig.expression.suggestion.inputPayload"), expr: tok("in") }];
145
146
  for (const f of primary?.fields ?? []) {
146
147
  inItems.push({ label: "input." + f, expr: tok("in." + f) });
147
148
  }
148
- out.push({ heading: "INPUT", items: inItems });
149
+ out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.input"), items: inItems });
149
150
  return out;
150
151
  });
151
152
  const hasSuggestions = computed(() => sections.value.some((s) => s.items.length > 0));
@@ -201,7 +202,7 @@ function insert(expr) {
201
202
  </div>
202
203
  <button
203
204
  type="button"
204
- title="Insert expression"
205
+ :title="t('integration.nodeConfig.expression.insertTitle')"
205
206
  class="flex h-8 shrink-0 items-center rounded-md border border-black/[0.08] bg-white px-1.5 font-mono text-sm text-muted-foreground transition hover:text-foreground hover:bg-muted/30"
206
207
  :class="open ? 'ring-2 ring-emerald-500/40' : ''"
207
208
  @mousedown.prevent="toggle"
@@ -213,7 +214,7 @@ function insert(expr) {
213
214
  <p
214
215
  v-if="preview"
215
216
  class="mt-1 flex min-w-0 items-baseline gap-1 text-[11px]"
216
- :title="preview.misses ? 'Parts of this expression have no sample data yet' : preview.resolved"
217
+ :title="preview.misses ? t('integration.nodeConfig.expression.preview.missTitle') : preview.resolved"
217
218
  >
218
219
  <span class="shrink-0 text-muted-foreground">→</span>
219
220
  <span class="min-w-0 truncate font-mono" :class="preview.misses ? 'text-amber-600' : 'text-emerald-700'">{{ preview.resolved }}</span>
@@ -226,15 +227,15 @@ function insert(expr) {
226
227
  <div class="shrink-0 border-b border-black/[0.06] p-1">
227
228
  <input
228
229
  v-model="query"
229
- aria-label="Filter available data"
230
- placeholder="Filter…"
230
+ :aria-label="t('integration.nodeConfig.expression.picker.filterAria')"
231
+ :placeholder="t('integration.nodeConfig.expression.picker.filterPlaceholder')"
231
232
  class="w-full rounded px-1.5 py-1 text-sm outline-none placeholder:text-muted-foreground/60 focus:ring-2 focus:ring-primary/40"
232
233
  @mousedown.stop
233
234
  >
234
235
  </div>
235
236
  <div class="min-h-0 flex-1 overflow-y-auto py-1">
236
237
  <p v-if="!filteredSections.length" class="px-2 py-1.5 text-muted-foreground">
237
- Nothing matches “{{ query }}”.
238
+ {{ t("integration.nodeConfig.expression.picker.empty", { query }) }}
238
239
  </p>
239
240
  <template v-for="section in filteredSections" :key="section.heading">
240
241
  <div class="px-2 pb-0.5 pt-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
@@ -2,7 +2,8 @@
2
2
  import { computed, nextTick, ref } from "vue";
3
3
  import { Handle, Position, useVueFlow } from "@vue-flow/core";
4
4
  import { NodeToolbar } from "@vue-flow/node-toolbar";
5
- import { ChevronsDownUp, ChevronsUpDown, Trash2, Ungroup } from "lucide-vue-next";
5
+ import { ChevronsDownUp, ChevronsUpDown, Ungroup } from "lucide-vue-next";
6
+ import { TrashBinMinimalistic as TrashBin } from "#solar-icons";
6
7
  import { resolveDisplayName } from "../../utils/localize";
7
8
  const props = defineProps({
8
9
  id: { type: String, required: true },
@@ -10,22 +11,28 @@ const props = defineProps({
10
11
  selected: { type: Boolean, required: false }
11
12
  });
12
13
  defineEmits(["updateNodeInternals"]);
14
+ const t = useIntegrationsT();
13
15
  const GROUP_COLORS = [
14
- { name: "Amber", bg: "rgba(254,243,199,0.45)", border: "#fcd34d", chip: "#f59e0b" },
15
- { name: "Orange", bg: "rgba(255,237,213,0.45)", border: "#fdba74", chip: "#f97316" },
16
- { name: "Rose", bg: "rgba(255,228,230,0.45)", border: "#fda4af", chip: "#f43f5e" },
17
- { name: "Green", bg: "rgba(220,252,231,0.45)", border: "#86efac", chip: "#10b981" },
18
- { name: "Blue", bg: "rgba(219,234,254,0.45)", border: "#93c5fd", chip: "#3b82f6" },
19
- { name: "Violet", bg: "rgba(237,233,254,0.45)", border: "#c4b5fd", chip: "#8b5cf6" },
20
- { name: "Gray", bg: "rgba(245,245,244,0.6)", border: "#d6d3d1", chip: "#78716c" }
16
+ { nameKey: "integration.canvas.color.amber", bg: "rgba(254,243,199,0.45)", border: "#fcd34d", chip: "#f59e0b" },
17
+ { nameKey: "integration.canvas.color.orange", bg: "rgba(255,237,213,0.45)", border: "#fdba74", chip: "#f97316" },
18
+ { nameKey: "integration.canvas.color.rose", bg: "rgba(255,228,230,0.45)", border: "#fda4af", chip: "#f43f5e" },
19
+ { nameKey: "integration.canvas.color.green", bg: "rgba(220,252,231,0.45)", border: "#86efac", chip: "#10b981" },
20
+ { nameKey: "integration.canvas.color.blue", bg: "rgba(219,234,254,0.45)", border: "#93c5fd", chip: "#3b82f6" },
21
+ { nameKey: "integration.canvas.color.violet", bg: "rgba(237,233,254,0.45)", border: "#c4b5fd", chip: "#8b5cf6" },
22
+ { nameKey: "integration.canvas.color.gray", bg: "rgba(245,245,244,0.6)", border: "#d6d3d1", chip: "#78716c" }
21
23
  ];
22
24
  const color = computed(() => GROUP_COLORS[props.data.group.color ?? 4] ?? GROUP_COLORS[4]);
23
- const label = computed(() => resolveDisplayName(props.data.group.label) || "Group");
25
+ const label = computed(() => resolveDisplayName(props.data.group.label) || t("integration.canvas.group.defaultLabel"));
24
26
  const collapsed = computed(() => props.data.group.collapsed === true);
25
27
  const editable = computed(() => !!props.data.actions);
26
28
  const editing = ref(false);
27
29
  const draft = ref("");
28
30
  const inputEl = ref(null);
31
+ function onLabelDoubleClick(event) {
32
+ if (!editable.value) return;
33
+ event.stopPropagation();
34
+ startRename();
35
+ }
29
36
  function startRename() {
30
37
  if (!editable.value) return;
31
38
  draft.value = label.value;
@@ -51,7 +58,7 @@ function onHoverLeave() {
51
58
  }, 250);
52
59
  }
53
60
  const toolbarVisible = computed(() => editable.value && !editing.value && (hovered.value || !!props.selected));
54
- const memberCountText = computed(() => `${props.data.memberCount} node${props.data.memberCount === 1 ? "" : "s"}`);
61
+ const memberCountText = computed(() => t("integration.canvas.group.memberCount", props.data.memberCount));
55
62
  const flow = useVueFlow();
56
63
  const resizing = ref(false);
57
64
  const liveW = ref(0);
@@ -96,15 +103,15 @@ function onResizeEnd() {
96
103
  class="size-4.5 rounded-full border transition hover:scale-110"
97
104
  :class="(data.group.color ?? 4) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
98
105
  :style="{ backgroundColor: c.chip, borderColor: c.border, width: '16px', height: '16px' }"
99
- :title="c.name"
100
- :aria-label="c.name"
106
+ :title="t(c.nameKey)"
107
+ :aria-label="t(c.nameKey)"
101
108
  @click="data.actions?.setColor(i)"
102
109
  />
103
110
  <div class="mx-0.5 h-4 w-px bg-black/10" />
104
111
  <button
105
112
  type="button"
106
113
  class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
107
- :title="collapsed ? 'Expand group' : 'Collapse group'"
114
+ :title="collapsed ? t('integration.canvas.group.action.expand') : t('integration.canvas.group.action.collapse')"
108
115
  @click="data.actions?.toggleCollapse()"
109
116
  >
110
117
  <component :is="collapsed ? ChevronsUpDown : ChevronsDownUp" :size="13" />
@@ -112,7 +119,7 @@ function onResizeEnd() {
112
119
  <button
113
120
  type="button"
114
121
  class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
115
- title="Ungroup (keep the nodes)"
122
+ :title="t('integration.canvas.group.action.ungroup')"
116
123
  @click="data.actions?.ungroup()"
117
124
  >
118
125
  <Ungroup :size="13" />
@@ -120,10 +127,10 @@ function onResizeEnd() {
120
127
  <button
121
128
  type="button"
122
129
  class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
123
- title="Remove group frame"
130
+ :title="t('integration.canvas.group.action.removeFrame')"
124
131
  @click="data.actions?.ungroup()"
125
132
  >
126
- <Trash2 :size="13" />
133
+ <TrashBin :size="13" />
127
134
  </button>
128
135
  </div>
129
136
  </NodeToolbar>
@@ -136,6 +143,9 @@ function onResizeEnd() {
136
143
  :position="Position.Top"
137
144
  class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
138
145
  />
146
+ <!-- The collapsed card stops its own double-click unconditionally, which is
147
+ safe: both read-only panels build every frame with `collapsed: false`, so
148
+ this surface is the editor's alone. -->
139
149
  <div
140
150
  class="w-[220px] rounded-lg border-2 bg-white transition-all duration-150"
141
151
  :style="{ borderColor: color.border }"
@@ -183,7 +193,7 @@ function onResizeEnd() {
183
193
  v-if="editing"
184
194
  ref="inputEl"
185
195
  v-model="draft"
186
- aria-label="Group name"
196
+ :aria-label="t('integration.canvas.group.input.nameAria')"
187
197
  class="nodrag w-40 rounded border border-black/[0.1] bg-white px-1 py-0.5 text-sm font-semibold focus:outline-none focus:ring-2 focus:ring-primary/40"
188
198
  @blur="commitRename"
189
199
  @keydown.enter.prevent="commitRename"
@@ -194,7 +204,7 @@ function onResizeEnd() {
194
204
  v-else
195
205
  class="select-none text-sm font-semibold"
196
206
  :style="{ color: color.chip }"
197
- @dblclick.stop="startRename"
207
+ @dblclick="onLabelDoubleClick"
198
208
  >
199
209
  {{ label }}
200
210
  </span>
@@ -206,7 +216,7 @@ function onResizeEnd() {
206
216
  v-if="editable"
207
217
  class="nodrag pointer-events-auto absolute bottom-0 right-0 flex size-5 cursor-se-resize items-end justify-end p-1"
208
218
  :style="{ color: color.chip }"
209
- title="Resize group"
219
+ :title="t('integration.canvas.group.action.resize')"
210
220
  @pointerdown="onResizeStart"
211
221
  @pointermove="onResizeMove"
212
222
  @pointerup="onResizeEnd"
@@ -1,6 +1,12 @@
1
1
  <script setup>
2
+ import { nextTick, onMounted, ref } from "vue";
2
3
  import Sidebar from "./Sidebar.vue";
4
+ const t = useIntegrationsT();
3
5
  const { focused } = useIntegrationsFocus();
6
+ const animate = ref(false);
7
+ onMounted(() => nextTick(() => {
8
+ animate.value = true;
9
+ }));
4
10
  </script>
5
11
 
6
12
  <template>
@@ -10,11 +16,30 @@ const { focused } = useIntegrationsFocus();
10
16
  href="#integrations-main"
11
17
  class="sr-only focus:not-sr-only focus:absolute focus:left-3 focus:top-3 focus:z-50 focus:rounded-md focus:bg-white focus:px-3 focus:py-1.5 focus:text-sm focus:font-medium focus:ring-2 focus:ring-primary/40"
12
18
  >
13
- Skip to content
19
+ {{ t("integration.shell.action.skipToContent") }}
14
20
  </a>
21
+ <!-- `inert` + `aria-hidden` are what make the fold real. The inner wrapper
22
+ below keeps its own `w-64` so the slide-out animates, so a folded rail
23
+ is merely clipped by `overflow-hidden`: its links keep their size and
24
+ stay focusable at a negative x, and a keyboard walks into a rail nobody
25
+ can see (PO-273). `display: none` would fix that too and take the
26
+ animation with it.
27
+
28
+ Both attributes are written to be *absent* rather than false when the
29
+ rail stands: `inert` is presence-based, so a renderer that emitted
30
+ `inert="false"` as an attribute would hold the standing rail back
31
+ instead. That the standing rail really does take focus is asserted in
32
+ its own right — specs/studio-navigation.md AC-1 is the control for AC-5.
33
+
34
+ The id is how a test reaches the rail while it is folded: out of the
35
+ accessibility tree, it can no longer be addressed by its role, and
36
+ `nav` alone would catch any navigation the page grows later. -->
15
37
  <div
16
- class="shrink-0 overflow-hidden transition-[width] duration-200 ease-out border-r border-black/[0.06] bg-white"
17
- :class="focused ? 'w-0 !border-r-0' : 'w-64'"
38
+ id="integrations-nav"
39
+ class="shrink-0 overflow-hidden border-r border-black/[0.06] bg-white"
40
+ :class="[focused ? 'w-0 !border-r-0' : 'w-64', animate ? 'transition-[width] duration-200 ease-out' : '']"
41
+ :inert="focused || void 0"
42
+ :aria-hidden="focused ? 'true' : void 0"
18
43
  >
19
44
  <div class="h-full w-64 overflow-y-auto">
20
45
  <Sidebar />
@@ -11,6 +11,7 @@ const props = defineProps({
11
11
  modelValue: { type: null, required: true }
12
12
  });
13
13
  const emit = defineEmits(["update:modelValue"]);
14
+ const t = useIntegrationsT();
14
15
  const search = ref("");
15
16
  const focused = ref(false);
16
17
  const q = useNodeConfigResolve({
@@ -78,25 +79,25 @@ function toggleMulti(optValue) {
78
79
  <!-- gated: required dependencies still unset -->
79
80
  <template v-if="q.gated.value">
80
81
  <div :class="disabledControlClass">—</div>
81
- <p class="mt-1 text-sm text-amber-600">Set {{ q.unsatisfied.value.join(", ") }} first</p>
82
+ <p class="mt-1 text-sm text-amber-600">{{ t("integration.nodeConfig.dynamicField.gated", { keys: q.unsatisfied.value.join(", ") }) }}</p>
82
83
  </template>
83
84
 
84
85
  <!-- first load: no data yet -->
85
86
  <template v-else-if="q.isFetching.value && options.length === 0">
86
- <div :class="disabledControlClass">resolving options…</div>
87
+ <div :class="disabledControlClass">{{ t("integration.nodeConfig.dynamicField.resolving") }}</div>
87
88
  </template>
88
89
 
89
90
  <!-- error: build/runtime/etc -->
90
91
  <template v-else-if="q.error.value">
91
92
  <div :class="disabledControlClass">—</div>
92
93
  <div class="mt-1 flex items-center gap-2 text-sm text-amber-600">
93
- <span>{{ q.error.value.kind === "building" ? "Node bundle still building." : q.error.value.message }}</span>
94
+ <span>{{ q.error.value.kind === "building" ? t("integration.nodeConfig.resolve.error.building") : q.error.value.message }}</span>
94
95
  <button
95
96
  type="button"
96
97
  class="rounded border border-black/[0.08] px-1.5 py-0.5 text-mono text-muted-foreground transition hover:text-foreground"
97
98
  @click="q.refetch()"
98
99
  >
99
- RETRY
100
+ {{ t("integration.nodeConfig.resolve.action.retry") }}
100
101
  </button>
101
102
  </div>
102
103
  </template>
@@ -106,13 +107,13 @@ function toggleMulti(optValue) {
106
107
  <Input
107
108
  v-if="options.length > 10"
108
109
  v-model="multiFilter"
109
- :aria-label="`Filter the options for ${resolveLocalized(field.label)}`"
110
- placeholder="Filter options"
110
+ :aria-label="t('integration.nodeConfig.dynamicField.multiselect.filterAria', { label: resolveLocalized(field.label) })"
111
+ :placeholder="t('integration.nodeConfig.dynamicField.multiselect.filterPlaceholder')"
111
112
  class="mb-1.5 h-7 bg-white text-sm"
112
113
  />
113
114
  <div role="group" :aria-label="resolveLocalized(field.label)" class="flex flex-wrap gap-1">
114
115
  <span v-if="visibleMultiOptions.length === 0" class="text-sm text-muted-foreground">
115
- {{ options.length === 0 ? "no options available" : "no options match the filter" }}
116
+ {{ options.length === 0 ? t("integration.nodeConfig.dynamicField.empty") : t("integration.nodeConfig.dynamicField.emptyFiltered") }}
116
117
  </span>
117
118
  <button
118
119
  v-for="opt in visibleMultiOptions"
@@ -131,10 +132,10 @@ function toggleMulti(optValue) {
131
132
  :key="m"
132
133
  class="rounded-md border border-black/[0.08] bg-white px-1.5 py-0.5 text-sm text-muted-foreground opacity-60"
133
134
  >
134
- {{ m }} (not in list)
135
+ {{ t("integration.nodeConfig.dynamicField.notInList", { value: m }) }}
135
136
  </span>
136
137
  </div>
137
- <p v-if="q.isFetching.value && q.hasData.value" class="mt-1 text-sm text-muted-foreground">reloading…</p>
138
+ <p v-if="q.isFetching.value && q.hasData.value" class="mt-1 text-sm text-muted-foreground">{{ t("integration.nodeConfig.dynamicField.reloading") }}</p>
138
139
  </template>
139
140
 
140
141
  <!-- single select: searchable combobox; typing re-resolves with the term -->
@@ -143,7 +144,7 @@ function toggleMulti(optValue) {
143
144
  <Input
144
145
  :model-value="focused ? search : currentLabel"
145
146
  :aria-label="resolveLocalized(field.label)"
146
- :placeholder="resolveLocalized(field.placeholder) || 'Pick one'"
147
+ :placeholder="resolveLocalized(field.placeholder) || t('integration.nodeConfig.select.placeholder')"
147
148
  class="h-8 bg-white pr-7 text-sm"
148
149
  @update:model-value="(v) => {
149
150
  search = String(v);
@@ -157,7 +158,7 @@ function toggleMulti(optValue) {
157
158
  class="absolute left-0 right-0 top-full z-30 mt-1 max-h-56 overflow-y-auto rounded-md border border-black/[0.08] bg-white py-1 shadow-md"
158
159
  >
159
160
  <div v-if="q.isFetching.value" class="px-2.5 py-1.5 text-sm text-muted-foreground">
160
- {{ search ? "searching\u2026" : "resolving options\u2026" }}
161
+ {{ search ? t("integration.nodeConfig.dynamicField.searching") : t("integration.nodeConfig.dynamicField.resolving") }}
161
162
  </div>
162
163
  <button
163
164
  v-for="opt in visibleOptions"
@@ -170,14 +171,14 @@ function toggleMulti(optValue) {
170
171
  <span class="min-w-0 truncate">{{ resolveLocalized(opt.label) || String(opt.value) }}</span>
171
172
  </button>
172
173
  <div v-if="!q.isFetching.value && visibleOptions.length === 0" class="px-2.5 py-1.5 text-sm text-muted-foreground">
173
- {{ search ? 'nothing matches "' + search + '"' : "no options available" }}
174
+ {{ search ? t("integration.nodeConfig.dynamicField.noMatch", { query: search }) : t("integration.nodeConfig.dynamicField.empty") }}
174
175
  </div>
175
176
  </div>
176
177
  </div>
177
178
  <p v-if="currentValue !== '' && !currentKnown" class="mt-1 text-sm text-amber-600">
178
- current value not in resolved options
179
+ {{ t("integration.nodeConfig.dynamicField.currentNotResolved") }}
179
180
  </p>
180
- <p v-if="q.isFetching.value && q.hasData.value && !focused" class="mt-1 text-sm text-muted-foreground">reloading…</p>
181
+ <p v-if="q.isFetching.value && q.hasData.value && !focused" class="mt-1 text-sm text-muted-foreground">{{ t("integration.nodeConfig.dynamicField.reloading") }}</p>
181
182
  </template>
182
183
  </div>
183
184
  </template>
@@ -13,6 +13,7 @@ const props = defineProps({
13
13
  depth: { type: Number, required: false }
14
14
  });
15
15
  const emit = defineEmits(["set"]);
16
+ const t = useIntegrationsT();
16
17
  void IntegrationsApiError;
17
18
  const MAX_DEPTH = 5;
18
19
  const q = useNodeConfigResolve({
@@ -29,17 +30,17 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
29
30
  <div>
30
31
  <!-- gated: required dependencies still unset -->
31
32
  <p v-if="q.gated.value" class="text-sm text-amber-600">
32
- Set {{ q.unsatisfied.value.join(", ") }} first
33
+ {{ t("integration.nodeConfig.dynamicField.gated", { keys: q.unsatisfied.value.join(", ") }) }}
33
34
  </p>
34
35
 
35
36
  <!-- first load: no fields yet -->
36
37
  <p v-else-if="q.isFetching.value && !resolvedFields.length" class="text-sm text-muted-foreground">
37
- Resolving fields…
38
+ {{ t("integration.nodeConfig.dynamicSchema.resolving") }}
38
39
  </p>
39
40
 
40
41
  <!-- error: build/runtime/etc -->
41
42
  <div v-else-if="q.error.value" class="flex items-center gap-2 text-sm text-amber-600">
42
- <span>{{ q.error.value.kind === "building" ? "Node bundle still building." : q.error.value.message }}</span>
43
+ <span>{{ q.error.value.kind === "building" ? t("integration.nodeConfig.resolve.error.building") : q.error.value.message }}</span>
43
44
  <Button
44
45
  type="button"
45
46
  variant="outline"
@@ -47,13 +48,13 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
47
48
  class="h-6 px-1.5 text-mono text-muted-foreground"
48
49
  @click="q.refetch()"
49
50
  >
50
- RETRY
51
+ {{ t("integration.nodeConfig.resolve.action.retry") }}
51
52
  </Button>
52
53
  </div>
53
54
 
54
55
  <!-- empty: resolved but no fields -->
55
56
  <p v-else-if="!resolvedFields.length" class="text-sm text-muted-foreground">
56
- No fields resolved.
57
+ {{ t("integration.nodeConfig.dynamicSchema.empty") }}
57
58
  </p>
58
59
 
59
60
  <!-- fieldset: the resolved child fields, rendered into the flat node config -->
@@ -69,12 +70,12 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
69
70
  <template v-for="f in resolvedFields" :key="f.key">
70
71
  <!-- collision guard: a resolved key that shadows a static config field is skipped -->
71
72
  <p v-if="staticKeys.includes(f.key)" class="text-sm text-amber-600">
72
- resolved field '{{ f.key }}' collides with a static config field — skipped
73
+ {{ t("integration.nodeConfig.dynamicSchema.fieldCollision", { key: f.key }) }}
73
74
  </p>
74
75
 
75
76
  <!-- depth cap: don't expand nested dynamic-schema past MAX_DEPTH -->
76
77
  <p v-else-if="f.type === 'dynamic-schema' && (depth ?? 0) >= MAX_DEPTH" class="text-sm text-muted-foreground">
77
- nested dynamic-schema is not expanded further
78
+ {{ t("integration.nodeConfig.dynamicSchema.depthCapped") }}
78
79
  </p>
79
80
 
80
81
  <!-- a resolved field, rendered exactly like a static node config field -->
@@ -92,7 +93,7 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
92
93
  </template>
93
94
 
94
95
  <p v-if="q.isFetching.value && q.hasData.value" class="text-sm text-muted-foreground">
95
- reloading fields…
96
+ {{ t("integration.nodeConfig.dynamicSchema.reloading") }}
96
97
  </p>
97
98
  </fieldset>
98
99
  </div>