@revenexx/studio-integrations 1.4.1 → 2.1.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 (80) hide show
  1. package/CHANGELOG.md +297 -0
  2. package/README.md +2 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -6
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +134 -131
  6. package/dist/runtime/components/Integrations/CredentialLogo.vue +1 -1
  7. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +1 -1
  8. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +6 -6
  9. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +2 -0
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue +90 -11
  11. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +2 -0
  12. package/dist/runtime/components/Integrations/GroupFrameView.vue +121 -121
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue +20 -5
  14. package/dist/runtime/components/Integrations/NodeInspector.vue +44 -41
  15. package/dist/runtime/components/Integrations/NodePalette.vue +52 -37
  16. package/dist/runtime/components/Integrations/RunActionsMenu.vue +6 -5
  17. package/dist/runtime/components/Integrations/RunDetailPanel.d.vue.ts +7 -0
  18. package/dist/runtime/components/Integrations/RunDetailPanel.vue +14 -2
  19. package/dist/runtime/components/Integrations/RunDetailPanel.vue.d.ts +7 -0
  20. package/dist/runtime/components/Integrations/RunsTable.vue +72 -70
  21. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +20 -6
  22. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -0
  23. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +10 -6
  24. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +0 -34
  25. package/dist/runtime/components/Integrations/WorkflowEditor.vue +226 -277
  26. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +0 -34
  27. package/dist/runtime/components/Integrations/WorkflowExportDialog.d.vue.ts +6 -0
  28. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue +71 -0
  29. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue.d.ts +6 -0
  30. package/dist/runtime/components/Integrations/WorkflowImportFileStep.d.vue.ts +8 -0
  31. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue +95 -0
  32. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue.d.ts +8 -0
  33. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +5 -4
  34. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +0 -7
  35. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +2 -2
  36. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +2 -3
  37. package/dist/runtime/components/Integrations/WorkflowsTable.vue +14 -8
  38. package/dist/runtime/composables/useIntegrationsNodes.js +4 -1
  39. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +3 -1
  40. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -22
  41. package/dist/runtime/composables/useNow.d.ts +17 -0
  42. package/dist/runtime/composables/useNow.js +16 -0
  43. package/dist/runtime/composables/useWorkflowFile.d.ts +21 -0
  44. package/dist/runtime/composables/useWorkflowFile.js +63 -0
  45. package/dist/runtime/data/expressionEditor.d.ts +11 -1
  46. package/dist/runtime/data/expressionEditor.js +2 -1
  47. package/dist/runtime/data/nodeIcons.d.ts +14 -0
  48. package/dist/runtime/data/nodeIcons.js +57 -14
  49. package/dist/runtime/data/nodeRunData.js +1 -1
  50. package/dist/runtime/data/payloadForm.js +1 -1
  51. package/dist/runtime/i18n/messages/de.json +47 -11
  52. package/dist/runtime/i18n/messages/en.json +47 -11
  53. package/dist/runtime/pages/integrations/audit.vue +3 -3
  54. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +2 -2
  55. package/dist/runtime/pages/integrations/credentials.vue +17 -5
  56. package/dist/runtime/pages/integrations/index.vue +5 -10
  57. package/dist/runtime/pages/integrations/schedules.vue +24 -13
  58. package/dist/runtime/pages/integrations/templates/index.vue +40 -40
  59. package/dist/runtime/pages/integrations/workflows/[id].vue +324 -324
  60. package/dist/runtime/types/integrations.d.ts +13 -0
  61. package/dist/runtime/utils/cockpitSources.d.ts +68 -1
  62. package/dist/runtime/utils/credentialState.d.ts +15 -7
  63. package/dist/runtime/utils/credentialState.js +4 -12
  64. package/dist/runtime/utils/localize.d.ts +12 -0
  65. package/dist/runtime/utils/localize.js +1 -1
  66. package/dist/runtime/utils/paletteOrder.d.ts +29 -0
  67. package/dist/runtime/utils/paletteOrder.js +46 -0
  68. package/dist/runtime/utils/relativeTime.d.ts +29 -0
  69. package/dist/runtime/utils/relativeTime.js +23 -0
  70. package/dist/runtime/utils/runFeedQuery.d.ts +12 -6
  71. package/dist/runtime/utils/runFeedQuery.js +3 -18
  72. package/dist/runtime/utils/settingValue.d.ts +33 -0
  73. package/dist/runtime/utils/settingValue.js +8 -0
  74. package/dist/runtime/utils/statusStyle.d.ts +8 -8
  75. package/dist/runtime/utils/statusStyle.js +4 -1
  76. package/dist/runtime/utils/workflowFile.d.ts +119 -0
  77. package/dist/runtime/utils/workflowFile.js +192 -0
  78. package/package.json +10 -5
  79. package/dist/runtime/utils/errorPathAdvice.d.ts +0 -60
  80. package/dist/runtime/utils/errorPathAdvice.js +0 -19
@@ -91,143 +91,143 @@ function onResizeEnd() {
91
91
 
92
92
  <template>
93
93
  <div class="relative">
94
- <NodeToolbar :is-visible="toolbarVisible" :position="Position.Top" :offset="8">
95
- <div
96
- class="flex items-center gap-1 rounded-lg border border-black/[0.06] bg-white px-1.5 py-1 shadow-md"
97
- @mouseenter="onHoverEnter"
98
- @mouseleave="onHoverLeave"
99
- >
100
- <button
101
- v-for="(c, i) in GROUP_COLORS"
102
- :key="i"
103
- type="button"
104
- class="size-6 rounded-full border transition hover:scale-110"
105
- :class="(data.group.color ?? 4) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
106
- :style="{ backgroundColor: c.chip, borderColor: c.border }"
107
- :title="t(c.nameKey)"
108
- :aria-label="t(c.nameKey)"
109
- @click="data.actions?.setColor(i)"
110
- />
111
- <div class="mx-0.5 h-4 w-px bg-black/10" />
112
- <button
113
- type="button"
114
- class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
115
- :title="collapsed ? t('integration.canvas.group.action.expand') : t('integration.canvas.group.action.collapse')"
116
- @click="data.actions?.toggleCollapse()"
117
- >
118
- <component :is="collapsed ? ChevronsUpDown : ChevronsDownUp" :size="13" />
119
- </button>
120
- <button
121
- type="button"
122
- class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
123
- :title="t('integration.canvas.group.action.ungroup')"
124
- @click="data.actions?.ungroup()"
94
+ <NodeToolbar :is-visible="toolbarVisible" :position="Position.Top" :offset="8">
95
+ <div
96
+ class="flex items-center gap-1 rounded-lg border border-black/[0.06] bg-white px-1.5 py-1 shadow-md"
97
+ @mouseenter="onHoverEnter"
98
+ @mouseleave="onHoverLeave"
125
99
  >
126
- <Ungroup :size="13" />
127
- </button>
128
- <button
129
- type="button"
130
- class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
131
- :title="t('integration.canvas.group.action.removeFrame')"
132
- @click="data.actions?.ungroup()"
133
- >
134
- <TrashBin :size="13" />
135
- </button>
136
- </div>
137
- </NodeToolbar>
100
+ <button
101
+ v-for="(c, i) in GROUP_COLORS"
102
+ :key="i"
103
+ type="button"
104
+ class="size-6 rounded-full border transition hover:scale-110"
105
+ :class="(data.group.color ?? 4) === i ? 'ring-2 ring-black/25 ring-offset-1' : ''"
106
+ :style="{ backgroundColor: c.chip, borderColor: c.border }"
107
+ :title="t(c.nameKey)"
108
+ :aria-label="t(c.nameKey)"
109
+ @click="data.actions?.setColor(i)"
110
+ />
111
+ <div class="mx-0.5 h-4 w-px bg-black/10" />
112
+ <button
113
+ type="button"
114
+ class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
115
+ :title="collapsed ? t('integration.canvas.group.action.expand') : t('integration.canvas.group.action.collapse')"
116
+ @click="data.actions?.toggleCollapse()"
117
+ >
118
+ <component :is="collapsed ? ChevronsUpDown : ChevronsDownUp" :size="13" />
119
+ </button>
120
+ <button
121
+ type="button"
122
+ class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
123
+ :title="t('integration.canvas.group.action.ungroup')"
124
+ @click="data.actions?.ungroup()"
125
+ >
126
+ <Ungroup :size="13" />
127
+ </button>
128
+ <button
129
+ type="button"
130
+ class="flex size-6 items-center justify-center rounded-md text-muted-foreground transition hover:bg-rose-50 hover:text-rose-600"
131
+ :title="t('integration.canvas.group.action.removeFrame')"
132
+ @click="data.actions?.ungroup()"
133
+ >
134
+ <TrashBin :size="13" />
135
+ </button>
136
+ </div>
137
+ </NodeToolbar>
138
138
 
139
- <!-- Collapsed: compact stand-in card with generic edge handles. -->
140
- <template v-if="collapsed">
141
- <Handle
142
- :id="inputHandleId('top')"
143
- type="target"
144
- :position="Position.Top"
145
- class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
146
- />
147
- <!-- The collapsed card stops its own double-click unconditionally, which is
139
+ <!-- Collapsed: compact stand-in card with generic edge handles. -->
140
+ <template v-if="collapsed">
141
+ <Handle
142
+ :id="inputHandleId('top')"
143
+ type="target"
144
+ :position="Position.Top"
145
+ class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
146
+ />
147
+ <!-- The collapsed card stops its own double-click unconditionally, which is
148
148
  safe: both read-only panels build every frame with `collapsed: false`, so
149
149
  this surface is the editor's alone. -->
150
- <div
151
- class="w-[220px] rounded-lg border-2 bg-white transition-all duration-150"
152
- :style="{ borderColor: color.border }"
153
- :class="selected ? 'shadow-[0_0_0_2px_rgba(59,130,246,0.2)]' : 'shadow-sm hover:shadow-md'"
154
- @mouseenter="onHoverEnter"
155
- @mouseleave="onHoverLeave"
156
- @dblclick.stop="data.actions?.toggleCollapse()"
157
- >
158
- <div class="flex items-center gap-2.5 px-3 py-2.5">
159
- <span class="flex size-8 shrink-0 items-center justify-center rounded-md text-white" :style="{ backgroundColor: color.chip }">
160
- <ChevronsUpDown :size="15" />
161
- </span>
162
- <div class="min-w-0 flex-1">
163
- <div class="truncate text-sm font-semibold leading-tight text-foreground">{{ label }}</div>
164
- <div class="mt-0.5 text-sm text-muted-foreground">{{ memberCountText }}</div>
150
+ <div
151
+ class="w-[220px] rounded-lg border-2 bg-white transition-all duration-150"
152
+ :style="{ borderColor: color.border }"
153
+ :class="selected ? 'shadow-[0_0_0_2px_rgba(59,130,246,0.2)]' : 'shadow-sm hover:shadow-md'"
154
+ @mouseenter="onHoverEnter"
155
+ @mouseleave="onHoverLeave"
156
+ @dblclick.stop="data.actions?.toggleCollapse()"
157
+ >
158
+ <div class="flex items-center gap-2.5 px-3 py-2.5">
159
+ <span class="flex size-8 shrink-0 items-center justify-center rounded-md text-white" :style="{ backgroundColor: color.chip }">
160
+ <ChevronsUpDown :size="15" />
161
+ </span>
162
+ <div class="min-w-0 flex-1">
163
+ <div class="truncate text-sm font-semibold leading-tight text-foreground">{{ label }}</div>
164
+ <div class="mt-0.5 text-sm text-muted-foreground">{{ memberCountText }}</div>
165
+ </div>
165
166
  </div>
166
167
  </div>
167
- </div>
168
- <Handle
169
- :id="outputHandleId('default', 'bottom')"
170
- type="source"
171
- :position="Position.Bottom"
172
- class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
173
- />
174
- </template>
168
+ <Handle
169
+ :id="outputHandleId('default', 'bottom')"
170
+ type="source"
171
+ :position="Position.Bottom"
172
+ class="!size-3 !rounded-full !border-2 !border-white !bg-slate-500 !shadow-sm"
173
+ />
174
+ </template>
175
175
 
176
- <!-- Expanded: the frame behind the members. -->
177
- <div
178
- v-else
179
- class="pointer-events-none relative rounded-xl border-2 border-dashed transition-colors"
180
- :class="selected ? 'shadow-[0_0_0_2px_rgba(59,130,246,0.15)]' : ''"
181
- :style="{
176
+ <!-- Expanded: the frame behind the members. -->
177
+ <div
178
+ v-else
179
+ class="pointer-events-none relative rounded-xl border-2 border-dashed transition-colors"
180
+ :class="selected ? 'shadow-[0_0_0_2px_rgba(59,130,246,0.15)]' : ''"
181
+ :style="{
182
182
  width: `${frameW}px`,
183
183
  height: `${frameH}px`,
184
184
  backgroundColor: color.bg,
185
185
  borderColor: color.border
186
186
  }"
187
- >
188
- <div
189
- class="pointer-events-auto flex cursor-grab items-center gap-1.5 px-3 pt-1.5 active:cursor-grabbing"
190
- @mouseenter="onHoverEnter"
191
- @mouseleave="onHoverLeave"
192
187
  >
193
- <input
194
- v-if="editing"
195
- ref="inputEl"
196
- v-model="draft"
197
- :aria-label="t('integration.canvas.group.input.nameAria')"
198
- 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"
199
- @blur="commitRename"
200
- @keydown.enter.prevent="commitRename"
201
- @keydown.stop
202
- @mousedown.stop
188
+ <div
189
+ class="pointer-events-auto flex cursor-grab items-center gap-1.5 px-3 pt-1.5 active:cursor-grabbing"
190
+ @mouseenter="onHoverEnter"
191
+ @mouseleave="onHoverLeave"
203
192
  >
204
- <span
205
- v-else
206
- class="select-none text-sm font-semibold"
193
+ <input
194
+ v-if="editing"
195
+ ref="inputEl"
196
+ v-model="draft"
197
+ :aria-label="t('integration.canvas.group.input.nameAria')"
198
+ 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"
199
+ @blur="commitRename"
200
+ @keydown.enter.prevent="commitRename"
201
+ @keydown.stop
202
+ @mousedown.stop
203
+ >
204
+ <span
205
+ v-else
206
+ class="select-none text-sm font-semibold"
207
+ :style="{ color: color.chip }"
208
+ @dblclick="onLabelDoubleClick"
209
+ >
210
+ {{ label }}
211
+ </span>
212
+ <span class="select-none text-xs text-muted-foreground">· {{ memberCountText }}</span>
213
+ </div>
214
+
215
+ <!-- Bottom-right resize handle (editable frames only). -->
216
+ <div
217
+ v-if="editable"
218
+ class="nodrag pointer-events-auto absolute bottom-0 right-0 flex size-5 cursor-se-resize items-end justify-end p-1"
207
219
  :style="{ color: color.chip }"
208
- @dblclick="onLabelDoubleClick"
220
+ :title="t('integration.canvas.group.action.resize')"
221
+ @pointerdown="onResizeStart"
222
+ @pointermove="onResizeMove"
223
+ @pointerup="onResizeEnd"
224
+ @pointercancel="onResizeEnd"
209
225
  >
210
- {{ label }}
211
- </span>
212
- <span class="select-none text-xs text-muted-foreground">· {{ memberCountText }}</span>
213
- </div>
214
-
215
- <!-- Bottom-right resize handle (editable frames only). -->
216
- <div
217
- v-if="editable"
218
- class="nodrag pointer-events-auto absolute bottom-0 right-0 flex size-5 cursor-se-resize items-end justify-end p-1"
219
- :style="{ color: color.chip }"
220
- :title="t('integration.canvas.group.action.resize')"
221
- @pointerdown="onResizeStart"
222
- @pointermove="onResizeMove"
223
- @pointerup="onResizeEnd"
224
- @pointercancel="onResizeEnd"
225
- >
226
- <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
227
- <path d="M7 1 1 7" />
228
- <path d="M7 4.5 4.5 7" />
229
- </svg>
226
+ <svg viewBox="0 0 8 8" class="size-2.5" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round">
227
+ <path d="M7 1 1 7" />
228
+ <path d="M7 4.5 4.5 7" />
229
+ </svg>
230
+ </div>
230
231
  </div>
231
232
  </div>
232
- </div>
233
233
  </template>
@@ -19,6 +19,7 @@ import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOpt
19
19
  import { credentialState } from "../../utils/credentialState";
20
20
  import { defaultsFor } from "../../utils/configDefaults";
21
21
  import { applyingFields, settingApplies, staleKeys } from "../../utils/settingCondition";
22
+ import { coerceSettingValue } from "../../utils/settingValue";
22
23
  const props = defineProps({
23
24
  field: { type: Object, required: true },
24
25
  config: { type: Object, required: true },
@@ -38,8 +39,20 @@ const emit = defineEmits(["set", "declare-state"]);
38
39
  const t = useIntegrationsT();
39
40
  const value = computed(() => props.config[props.field.key]);
40
41
  function write(v) {
42
+ emit("set", props.field.key, coerceSettingValue(props.field, v));
43
+ }
44
+ function writeTyped(v) {
41
45
  emit("set", props.field.key, v);
42
46
  }
47
+ function commitTyped() {
48
+ const committed = coerceSettingValue(props.field, value.value);
49
+ if (committed !== value.value) emit("set", props.field.key, committed);
50
+ }
51
+ const expressionText = computed(() => {
52
+ const v = value.value;
53
+ if (typeof v === "string") return v;
54
+ return typeof v === "number" ? String(v) : "";
55
+ });
43
56
  const exprHint = "${{ \u2026 }}";
44
57
  const descriptionText = computed(() => resolveLocalized(props.field.description));
45
58
  const inRow = computed(() => props.rowIndex !== void 0);
@@ -65,9 +78,10 @@ const formatError = computed(() => {
65
78
  }
66
79
  }
67
80
  }
68
- if (typeof v === "number") {
69
- if (val.min != null && v < val.min) return t("integration.nodeConfig.validation.min", { min: val.min });
70
- if (val.max != null && v > val.max) return t("integration.nodeConfig.validation.max", { max: val.max });
81
+ const n = coerceSettingValue(props.field, v);
82
+ if (typeof n === "number") {
83
+ if (val.min != null && n < val.min) return t("integration.nodeConfig.validation.min", { min: val.min });
84
+ if (val.max != null && n > val.max) return t("integration.nodeConfig.validation.max", { max: val.max });
71
85
  }
72
86
  return null;
73
87
  });
@@ -433,14 +447,15 @@ function setArrayItemField(index, fieldKey, v) {
433
447
  <!-- typed control flipped to expression mode -->
434
448
  <IntegrationsExpressionInput
435
449
  v-else-if="showExpression"
436
- :model-value="typeof value === 'string' ? value : ''"
450
+ :model-value="expressionText"
437
451
  :placeholder="resolveLocalized(field.placeholder)"
438
452
  :variables="variables"
439
453
  :upstream-outputs="upstreamOutputs"
440
454
  :invalid="invalid"
441
455
  :label="controlName"
442
456
  :multiline="isMultiline"
443
- @update:model-value="(v) => write(v)"
457
+ @update:model-value="(v) => writeTyped(v)"
458
+ @blur="commitTyped"
444
459
  />
445
460
 
446
461
  <!-- dynamic options — resolved at author time via config:resolve. Ahead of
@@ -193,9 +193,12 @@ const parameterFields = computed(() => applyingConfig.value.filter((f) => !SETTI
193
193
  const settingsFields = computed(() => applyingConfig.value.filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
194
194
  const FOLD_ABOVE = 4;
195
195
  const ALWAYS_DRAWN = 3;
196
- const foldedFields = computed(
197
- () => parameterFields.value.length > FOLD_ABOVE ? parameterFields.value.slice(ALWAYS_DRAWN).filter((f) => !f.required) : []
198
- );
196
+ const foldedFields = computed(() => {
197
+ const fields = parameterFields.value;
198
+ if (fields.length <= FOLD_ABOVE) return [];
199
+ const lastRequired = fields.findLastIndex((f) => f.required);
200
+ return fields.slice(Math.max(ALWAYS_DRAWN, lastRequired + 1));
201
+ });
199
202
  const foldedKeys = computed(() => new Set(foldedFields.value.map((f) => f.key)));
200
203
  const foldAnchorKey = computed(() => foldedFields.value[0]?.key);
201
204
  function optionalShouldOpen() {
@@ -1215,7 +1218,7 @@ async function runTestStep() {
1215
1218
  </span>
1216
1219
  </button>
1217
1220
  <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
1218
- <!-- These ports belong to an UPSTREAM node, so the sentence is that
1221
+ <!-- These ports belong to an UPSTREAM node, so the sentence is that
1219
1222
  node's. On a 300px column it is a tooltip rather than text: the
1220
1223
  values below are what this panel is for, and a sentence per port
1221
1224
  pushes them out of view. The readable copy is the Output tab
@@ -1233,50 +1236,50 @@ async function runTestStep() {
1233
1236
  sentence is read by resting on the port's name or the space around
1234
1237
  its rows rather than on a row itself. Where a reader is owed the
1235
1238
  text outright, that is the Output tab. -->
1236
- <div
1237
- v-for="p in s.ports"
1238
- :key="p.name"
1239
- class="space-y-1 pl-0.5"
1240
- :title="resolveLocalized(p.description) || void 0"
1241
- >
1242
- <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
1243
- <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1244
- <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
1245
- </div>
1246
- <!-- Schema rows, n8n-style: glyph · key · sampled value. -->
1247
- <div class="space-y-px">
1248
- <button
1249
- type="button"
1250
- :class="rowClass"
1251
- :aria-label="nodeOutputExpr(s.id, p.name)"
1252
- :title="nodeOutputExpr(s.id, p.name)"
1253
- draggable="true"
1254
- @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
1255
- @click="insertExpression(nodeOutputExpr(s.id, p.name))"
1256
- >
1257
- <span class="w-3 shrink-0 text-center font-semibold text-slate-400">{}</span>
1258
- <span class="shrink-0 font-medium"><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1259
- </button>
1260
- <div class="space-y-px pl-3">
1239
+ <div
1240
+ v-for="p in s.ports"
1241
+ :key="p.name"
1242
+ class="space-y-1 pl-0.5"
1243
+ :title="resolveLocalized(p.description) || void 0"
1244
+ >
1245
+ <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
1246
+ <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1247
+ <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
1248
+ </div>
1249
+ <!-- Schema rows, n8n-style: glyph · key · sampled value. -->
1250
+ <div class="space-y-px">
1261
1251
  <button
1262
- v-for="f in p.fields"
1263
- :key="f.name"
1264
1252
  type="button"
1265
1253
  :class="rowClass"
1266
- :aria-label="nodeOutputExpr(s.id, p.name, f.name)"
1267
- :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
1254
+ :aria-label="nodeOutputExpr(s.id, p.name)"
1255
+ :title="nodeOutputExpr(s.id, p.name)"
1268
1256
  draggable="true"
1269
- @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
1270
- @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
1257
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
1258
+ @click="insertExpression(nodeOutputExpr(s.id, p.name))"
1271
1259
  >
1272
- <span class="w-3 shrink-0 text-center font-semibold" :class="typeGlyph(f.dataType)?.cls ?? 'text-slate-300'">{{ typeGlyph(f.dataType)?.char ?? "\xB7" }}</span>
1273
- <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1274
- <span v-if="chipValue(s.id, p.name, f.name) !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ chipValue(s.id, p.name, f.name) }}</span>
1260
+ <span class="w-3 shrink-0 text-center font-semibold text-slate-400">{}</span>
1261
+ <span class="shrink-0 font-medium"><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1275
1262
  </button>
1263
+ <div class="space-y-px pl-3">
1264
+ <button
1265
+ v-for="f in p.fields"
1266
+ :key="f.name"
1267
+ type="button"
1268
+ :class="rowClass"
1269
+ :aria-label="nodeOutputExpr(s.id, p.name, f.name)"
1270
+ :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
1271
+ draggable="true"
1272
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
1273
+ @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
1274
+ >
1275
+ <span class="w-3 shrink-0 text-center font-semibold" :class="typeGlyph(f.dataType)?.cls ?? 'text-slate-300'">{{ typeGlyph(f.dataType)?.char ?? "\xB7" }}</span>
1276
+ <span class="shrink-0"><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
1277
+ <span v-if="chipValue(s.id, p.name, f.name) !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ chipValue(s.id, p.name, f.name) }}</span>
1278
+ </button>
1279
+ </div>
1276
1280
  </div>
1277
1281
  </div>
1278
1282
  </div>
1279
- </div>
1280
1283
  </div>
1281
1284
  </div>
1282
1285
 
@@ -1838,9 +1841,9 @@ async function runTestStep() {
1838
1841
  :depth="0"
1839
1842
  :variables="variables"
1840
1843
  :state-namespaces="offeredStateNamespaces"
1841
- @declare-state="stageStateNamespace"
1842
1844
  :upstream-outputs="upstreamOutputs"
1843
1845
  :refused="refusedFields?.[field.key]"
1846
+ @declare-state="stageStateNamespace"
1844
1847
  @set="(k, v) => writeConfig(k, v)"
1845
1848
  />
1846
1849
  </template>
@@ -1881,9 +1884,9 @@ async function runTestStep() {
1881
1884
  :depth="0"
1882
1885
  :variables="variables"
1883
1886
  :state-namespaces="offeredStateNamespaces"
1884
- @declare-state="stageStateNamespace"
1885
1887
  :upstream-outputs="upstreamOutputs"
1886
1888
  :refused="refusedFields?.[field.key]"
1889
+ @declare-state="stageStateNamespace"
1887
1890
  @set="(k, v) => writeConfig(k, v)"
1888
1891
  />
1889
1892
  </div>
@@ -4,7 +4,8 @@ import { AltArrowLeft as ArrowLeft, Bolt as Zap, Magnifier as Search } from "#so
4
4
  import { triggerGlyph } from "../../data/triggerGlyphs";
5
5
  import * as Lucide from "lucide-vue-next";
6
6
  import { INTEGRATION_NODE_CATALOG, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
7
- import { resolveLocalized } from "../../utils/localize";
7
+ import { resolveLocalized, studioLocale } from "../../utils/localize";
8
+ import { orderFolderLeaves, orderFolders } from "../../utils/paletteOrder";
8
9
  const { load: loadNodes } = useIntegrationsNodes();
9
10
  onMounted(() => {
10
11
  void loadNodes();
@@ -14,29 +15,39 @@ const emit = defineEmits(["insert"]);
14
15
  const search = ref("");
15
16
  const searchInput = ref(null);
16
17
  defineExpose({ focusSearch: () => searchInput.value?.focus() });
17
- const CORE_GROUP_BY_CATEGORY = {
18
+ const CORE_FOLDERS = {
19
+ Flow: { message: "integration.canvas.palette.folder.flow", icon: "Split", color: "bg-slate-100 text-rose-600/80" },
20
+ Data: { message: "integration.canvas.palette.folder.data", icon: "Database", color: "bg-slate-100 text-amber-700/80" },
21
+ "Text & Formats": { message: "integration.canvas.palette.folder.textAndFormats", icon: "Type", color: "bg-slate-100 text-orange-600/80" },
22
+ Files: { message: "integration.canvas.palette.folder.files", icon: "Files", color: "bg-slate-100 text-yellow-700/80" },
23
+ Web: { message: "integration.canvas.palette.folder.web", icon: "Globe", color: "bg-slate-100 text-sky-600/80" },
24
+ Messaging: { message: "integration.canvas.palette.folder.messaging", icon: "MessageCircle", color: "bg-slate-100 text-violet-600/80" },
25
+ "FTP / SFTP": { message: "integration.canvas.palette.folder.ftpSftp", icon: "FolderSync", color: "bg-slate-100 text-teal-600/80" },
26
+ Actions: { message: "integration.canvas.palette.folder.actions", icon: "Wand2", color: "bg-slate-100 text-sky-600/80" },
27
+ Other: { message: "integration.canvas.palette.folder.misc", icon: "FolderOpen", color: "bg-slate-100 text-slate-500" }
28
+ };
29
+ const CORE_FOLDER_BY_CATEGORY = {
18
30
  control: "Flow",
19
31
  transform: "Data",
20
32
  io: "Data",
21
- action: "Actions",
22
- trigger: "Triggers"
33
+ action: "Actions"
23
34
  };
24
35
  const CORE_LABEL_ALIAS = {
25
36
  "Flow control": "Flow",
26
- "Files": "Data",
27
- "Files & I/O": "Data",
28
- "Network": "Messaging"
29
- };
30
- const CORE_GROUP_META = {
31
- "Data": { icon: "Database", color: "bg-slate-100 text-amber-700/80" },
32
- "Flow": { icon: "Split", color: "bg-slate-100 text-rose-600/80" },
33
- "Messaging": { icon: "MessageCircle", color: "bg-slate-100 text-violet-600/80" },
34
- "Actions": { icon: "Wand2", color: "bg-slate-100 text-sky-600/80" }
37
+ "Files & I/O": "Files",
38
+ Network: "Web"
35
39
  };
36
- function coreGroupIcon(label) {
37
- const meta = CORE_GROUP_META[label];
40
+ function coreGroupIcon(key) {
41
+ const meta = key ? CORE_FOLDERS[key] : void 0;
38
42
  return meta && Lucide[meta.icon] || Lucide.FolderOpen;
39
43
  }
44
+ function coreFolder(m) {
45
+ const own = m.groups?.[0];
46
+ const ownKey = own ? resolveLocalized(own, "en") : "";
47
+ const key = ownKey && (CORE_LABEL_ALIAS[ownKey] ?? ownKey) || CORE_FOLDER_BY_CATEGORY[m.category] || "Other";
48
+ const known = CORE_FOLDERS[key];
49
+ return { key, label: known ? t(known.message) : resolveLocalized(own) };
50
+ }
40
51
  const VERB_PREFIX = /^(create|get|list|update|delete|search|send|sync|upsert|cancel)-(.+)$/;
41
52
  function titleize(kebab) {
42
53
  return kebab.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
@@ -58,7 +69,7 @@ function groupPath(m) {
58
69
  if (m.groups?.length) {
59
70
  const path = m.groups.map((g) => resolveLocalized(g)).filter(Boolean);
60
71
  if (isIntegration(m) || !path.length) return path;
61
- return [CORE_LABEL_ALIAS[path[0]] ?? path[0], ...path.slice(1)];
72
+ return [coreFolder(m).label, ...path.slice(1)];
62
73
  }
63
74
  if (isAppPackage(m.__package)) {
64
75
  const app = packageAppLabel(m.__package);
@@ -68,10 +79,10 @@ function groupPath(m) {
68
79
  const verb = VERB_PREFIX.exec(rest);
69
80
  return verb ? [app, titleize(verb[2])] : [app];
70
81
  }
71
- return [CORE_GROUP_BY_CATEGORY[m.category] ?? "Other"];
82
+ return [coreFolder(m).label];
72
83
  }
73
84
  function isIntegration(m) {
74
- return isAppPackage(m.__package) || (m.groups?.length ?? 0) > 1 || hasLogo(m);
85
+ return isAppPackage(m.__package) || hasLogo(m);
75
86
  }
76
87
  function makeGroup(label) {
77
88
  return { label, children: /* @__PURE__ */ new Map(), leaves: [], count: 0 };
@@ -124,32 +135,36 @@ const tree = computed(() => {
124
135
  const root = isIntegration(m) ? apps : core;
125
136
  let cursor = root;
126
137
  cursor.count++;
127
- for (const label of path) {
128
- if (!cursor.children.has(label)) cursor.children.set(label, makeGroup(label));
138
+ for (const [depth, label] of path.entries()) {
139
+ if (!cursor.children.has(label)) {
140
+ const group = makeGroup(label);
141
+ if (root === core && depth === 0) group.key = coreFolder(m).key;
142
+ cursor.children.set(label, group);
143
+ }
129
144
  cursor = cursor.children.get(label);
130
145
  cursor.count++;
131
146
  }
132
147
  cursor.leaves.push(m);
133
148
  }
149
+ const locale = studioLocale();
150
+ const order = (g) => {
151
+ g.leaves = orderFolderLeaves(g.leaves);
152
+ g.children = orderFolders(g.children, locale);
153
+ for (const child of g.children.values()) order(child);
154
+ };
155
+ order(apps);
156
+ order(core);
134
157
  return { apps, core };
135
158
  });
136
- const appRows = computed(() => {
137
- const rows = Array.from(tree.value.apps.children.entries());
138
- const sftpIdx = rows.findIndex(([label]) => label === "SFTP");
139
- const ftpIdx = rows.findIndex(([label]) => label === "FTP");
140
- if (ftpIdx !== -1 && sftpIdx !== -1 && sftpIdx !== ftpIdx + 1) {
141
- const [sftp] = rows.splice(sftpIdx, 1);
142
- rows.splice(rows.findIndex(([label]) => label === "FTP") + 1, 0, sftp);
143
- }
144
- return rows;
145
- });
159
+ const appRows = computed(() => Array.from(tree.value.apps.children.entries()));
146
160
  function subtreeManifests(g) {
147
161
  const out = [...g.leaves];
148
162
  for (const child of g.children.values()) out.push(...subtreeManifests(child));
149
163
  return out;
150
164
  }
151
165
  function groupLogoManifest(g) {
152
- return subtreeManifests(g).find(hasLogo) ?? null;
166
+ const all = subtreeManifests(g);
167
+ return all.find(hasLogo) ?? all.find((m) => (m.images ?? []).length > 0) ?? null;
153
168
  }
154
169
  const stack = ref([]);
155
170
  const direction = ref("push");
@@ -259,10 +274,10 @@ function insertManifest(leaf) {
259
274
  function insertTrigger(tpl) {
260
275
  emit("insert", { kind: "trigger", type: tpl.type });
261
276
  }
262
- function categoryColor(label) {
263
- const meta = CORE_GROUP_META[label];
277
+ function categoryColor(key) {
278
+ const meta = key ? CORE_FOLDERS[key] : void 0;
264
279
  if (meta) return meta.color;
265
- const cat = NODE_CATEGORIES.find((c) => c.label === label || CORE_GROUP_BY_CATEGORY[String(c.key)] === label);
280
+ const cat = NODE_CATEGORIES.find((c) => CORE_FOLDER_BY_CATEGORY[String(c.key)] === key);
266
281
  return cat?.color ?? "bg-slate-100 text-slate-500";
267
282
  }
268
283
  function nodeCategoryColor(m) {
@@ -397,7 +412,7 @@ const searching = computed(() => search.value.trim().length > 0);
397
412
  draggable="true"
398
413
  :title="resolveLocalized(tpl.name)"
399
414
  @dragstart="onTriggerDragStart(tpl, $event)"
400
- @dragend="onDragEnd"
415
+ @dragend="onDragEnd"
401
416
  @click="insertTrigger(tpl)"
402
417
  >
403
418
  <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
@@ -434,8 +449,8 @@ const searching = computed(() => search.value.trim().length > 0);
434
449
  class="flex w-full items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-left transition hover:border-black/[0.08] hover:bg-muted/40"
435
450
  @click="pushPath(['__core', label])"
436
451
  >
437
- <span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="categoryColor(label)">
438
- <component :is="coreGroupIcon(label)" :size="13" />
452
+ <span class="flex size-6 shrink-0 items-center justify-center rounded-md" :class="categoryColor(group.key)">
453
+ <component :is="coreGroupIcon(group.key)" :size="13" />
439
454
  </span>
440
455
  <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ label }}</span>
441
456
  <span class="text-sm tabular-nums text-muted-foreground">{{ group.count }}</span>