@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
@@ -84,9 +84,10 @@ async function runAction(a) {
84
84
  busy.value = false;
85
85
  }
86
86
  }
87
- function confirmTerminate() {
88
- toast(t("integration.runs.toast.terminate.confirm", { id: props.run.id }), {
89
- action: { label: actionLabel("terminate"), onClick: () => void runAction("terminate") }
87
+ function confirmStop(a) {
88
+ const question = a === "cancel" ? t("integration.runs.toast.cancel.confirm", { id: props.run.id }) : t("integration.runs.toast.terminate.confirm", { id: props.run.id });
89
+ toast(question, {
90
+ action: { label: actionLabel(a), onClick: () => void runAction(a) }
90
91
  });
91
92
  }
92
93
  </script>
@@ -104,8 +105,8 @@ function confirmTerminate() {
104
105
  <DropdownMenuItem @select="viewDetails">{{ t("integration.runs.menu.viewDetails") }}</DropdownMenuItem>
105
106
  <DropdownMenuItem @select="viewReplay">{{ t("integration.runs.menu.replay") }}</DropdownMenuItem>
106
107
  <DropdownMenuSeparator v-if="hasAny" />
107
- <DropdownMenuItem v-if="isActive" @select="runAction('cancel')">{{ actionLabel("cancel") }}</DropdownMenuItem>
108
- <DropdownMenuItem v-if="isActive" class="text-rose-600" @select="confirmTerminate">{{ actionLabel("terminate") }}</DropdownMenuItem>
108
+ <DropdownMenuItem v-if="isActive" @select="confirmStop('cancel')">{{ actionLabel("cancel") }}</DropdownMenuItem>
109
+ <DropdownMenuItem v-if="isActive" class="text-rose-600" @select="confirmStop('terminate')">{{ actionLabel("terminate") }}</DropdownMenuItem>
109
110
  <DropdownMenuSeparator v-if="isActive && (canResume || isTerminal)" />
110
111
  <DropdownMenuItem v-if="canResume" @select="runAction('resume')">{{ actionLabel("resume") }}</DropdownMenuItem>
111
112
  <DropdownMenuItem v-if="isTerminal" @select="runAction('retry')">{{ actionLabel("retry") }}</DropdownMenuItem>
@@ -1,14 +1,21 @@
1
1
  import type { WorkflowRun } from '../../types/integrations.js';
2
+ import type { OutputNode } from '../../utils/runOutputs.js';
2
3
  type __VLS_Props = {
3
4
  run: WorkflowRun;
4
5
  /** Which surface renders: the overview or the steps+history timeline. */
5
6
  view?: 'details' | 'timeline';
7
+ /**
8
+ * The workflow's nodes, which the result is split against and named by. The
9
+ * published blob's, as the Replay view next to this one draws them.
10
+ */
11
+ nodes?: readonly OutputNode[];
6
12
  };
7
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
14
  changed: () => any;
9
15
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
16
  onChanged?: (() => any) | undefined;
11
17
  }>, {
18
+ nodes: readonly OutputNode[];
12
19
  view: "details" | "timeline";
13
20
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
21
  export default _default;
@@ -7,9 +7,11 @@ import { IntegrationsApiError, errorReason } from "../../utils/apiError";
7
7
  import { createPollBudget, SAFETY_POLL_MS } from "../../utils/pollBudget";
8
8
  import { formatDuration } from "../../utils/formatDuration";
9
9
  import { unwrapRun } from "../../utils/runEnvelope";
10
+ import { splitRunOutputs } from "../../utils/runOutputs";
10
11
  const props = defineProps({
11
12
  run: { type: Object, required: true },
12
- view: { type: String, required: false, default: "details" }
13
+ view: { type: String, required: false, default: "details" },
14
+ nodes: { type: Array, required: false, default: () => [] }
13
15
  });
14
16
  const emit = defineEmits(["changed"]);
15
17
  const t = useIntegrationsT();
@@ -83,6 +85,7 @@ const temporalWorkflowId = computed(() => pickStr(runData.value, ["temporal_work
83
85
  const temporalRunId = computed(() => pickStr(runData.value, ["temporal_run_id", "temporalRunId"]) ?? props.run.temporalRunId ?? null);
84
86
  const hasTemporal = computed(() => !!temporalWorkflowId.value && !!temporalRunId.value);
85
87
  const runResult = computed(() => pick(runData.value, ["result"]));
88
+ const resultOutputs = computed(() => splitRunOutputs(runResult.value, props.nodes));
86
89
  const MAX_RESULT_LINKS = 50;
87
90
  const resultLinks = computed(() => {
88
91
  const out = [];
@@ -685,7 +688,16 @@ async function confirmAction() {
685
688
  {{ link.label }}
686
689
  </a>
687
690
  </div>
688
- <pre class="rounded-md bg-muted/40 p-3 text-xs overflow-x-auto max-h-[28rem]">{{ json(runResult) }}</pre>
691
+ <!-- One block per step, each clamped and scrolling on its own (AC-3). -->
692
+ <div v-if="resultOutputs?.length" class="space-y-3">
693
+ <div v-for="out in resultOutputs" :key="out.nodeId" class="min-w-0">
694
+ <h5 class="mb-1 text-xs font-medium text-foreground">
695
+ {{ out.name }}
696
+ </h5>
697
+ <pre class="rounded-md bg-muted/40 p-3 text-xs overflow-auto max-h-[28rem]">{{ json(out.value) }}</pre>
698
+ </div>
699
+ </div>
700
+ <pre v-else class="rounded-md bg-muted/40 p-3 text-xs overflow-x-auto max-h-[28rem]">{{ json(runResult) }}</pre>
689
701
  </section>
690
702
 
691
703
  <!-- Stack Trace (non-terminal runs only, lazy) -->
@@ -1,14 +1,21 @@
1
1
  import type { WorkflowRun } from '../../types/integrations.js';
2
+ import type { OutputNode } from '../../utils/runOutputs.js';
2
3
  type __VLS_Props = {
3
4
  run: WorkflowRun;
4
5
  /** Which surface renders: the overview or the steps+history timeline. */
5
6
  view?: 'details' | 'timeline';
7
+ /**
8
+ * The workflow's nodes, which the result is split against and named by. The
9
+ * published blob's, as the Replay view next to this one draws them.
10
+ */
11
+ nodes?: readonly OutputNode[];
6
12
  };
7
13
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
14
  changed: () => any;
9
15
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
10
16
  onChanged?: (() => any) | undefined;
11
17
  }>, {
18
+ nodes: readonly OutputNode[];
12
19
  view: "details" | "timeline";
13
20
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
14
21
  export default _default;
@@ -1,16 +1,18 @@
1
1
  <script setup>
2
- import { computed, onMounted, onUnmounted, ref } from "vue";
2
+ import { computed, ref } from "vue";
3
3
  import {
4
4
  Badge,
5
- DropdownMenuItem,
6
- DropdownMenuSeparator,
7
- DropdownMenuSub,
8
- DropdownMenuSubContent,
9
- DropdownMenuSubTrigger
5
+ Select,
6
+ SelectContent,
7
+ SelectItem,
8
+ SelectTrigger,
9
+ SelectValue
10
10
  } from "@revenexx/studio";
11
11
  import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../utils/runFeedQuery";
12
12
  import { RUN_STATUS_COLUMN_WIDTH, RUN_STATUS_WORDS, normalisedRunStatus, runStatusClass, runStatusLabel } from "../../utils/statusStyle";
13
13
  import { formatDuration } from "../../utils/formatDuration";
14
+ import { relativeTime } from "../../utils/relativeTime";
15
+ import { useNow } from "../../composables/useNow";
14
16
  import { TRIGGER_TYPE_WORDS, triggerTypeLabel } from "../../data/integrationNodeCatalog";
15
17
  const props = defineProps({
16
18
  runs: { type: Array, required: true },
@@ -31,31 +33,10 @@ const page = defineModel("page", { type: Number, ...{ default: 1 } });
31
33
  const t = useIntegrationsT();
32
34
  const router = useRouter();
33
35
  const emptyText = computed(() => props.emptyText ?? (anyNarrowing.value || searchText.value ? t("integration.runs.table.empty.narrowed") : t("integration.runs.table.empty")));
34
- const now = ref(Date.now());
35
- let tick = null;
36
- onMounted(() => {
37
- tick = setInterval(() => {
38
- now.value = Date.now();
39
- }, 3e3);
40
- });
41
- onUnmounted(() => {
42
- if (tick) {
43
- clearInterval(tick);
44
- tick = null;
45
- }
46
- });
36
+ const now = useNow();
47
37
  function openRun(r) {
48
38
  void router.push({ path: `/integrations/workflows/${r.workflowId}`, query: { run: r.id } });
49
39
  }
50
- function formatRelative(iso) {
51
- if (!iso) return "\u2014";
52
- const ms = Date.now() - new Date(iso).getTime();
53
- if (Number.isNaN(ms)) return "\u2014";
54
- if (ms < 6e4) return t("integration.common.time.secondsAgo", Math.max(1, Math.floor(ms / 1e3)));
55
- if (ms < 36e5) return t("integration.common.time.minutesAgo", Math.floor(ms / 6e4));
56
- if (ms < 864e5) return t("integration.common.time.hoursAgo", Math.floor(ms / 36e5));
57
- return t("integration.common.time.daysAgo", Math.floor(ms / 864e5));
58
- }
59
40
  function liveDuration(run) {
60
41
  if (!run.startedAt) return null;
61
42
  const start = Date.parse(run.startedAt);
@@ -85,8 +66,9 @@ function setStatus(word) {
85
66
  function setTrigger(kind) {
86
67
  emit("narrow", { triggerType: kind ?? "" });
87
68
  }
88
- const statusApplied = computed(() => chosenStatus.value[0] ? runStatusLabel(chosenStatus.value[0]) : "");
89
- const triggerApplied = computed(() => props.triggerType ? triggerTypeLabel(props.triggerType) : "");
69
+ const ALL = "all";
70
+ const statusChoice = computed(() => chosenStatus.value[0] ?? ALL);
71
+ const triggerChoice = computed(() => props.triggerType || ALL);
90
72
  const searchText = ref("");
91
73
  function onTableState(state) {
92
74
  searchText.value = state.search;
@@ -117,10 +99,10 @@ const sortedColumn = computed(() => {
117
99
  return entry ? entry[0] : null;
118
100
  });
119
101
  const serverModels = computed(() => props.serverMode ? {
120
- "sort": tableSort.value,
102
+ sort: tableSort.value,
121
103
  "onUpdate:sort": onTableSort,
122
104
  // 1-based here, as the platform pages; the shared table counts from zero.
123
- "page": page.value - 1,
105
+ page: page.value - 1,
124
106
  "onUpdate:page": (p) => {
125
107
  page.value = p + 1;
126
108
  }
@@ -171,46 +153,66 @@ const columns = computed(() => [
171
153
  (`filterTabs`) cannot be used here, because it compares the rows in hand and
172
154
  those are one page.
173
155
 
174
- Built to the same shape as that control on purpose — a dimension per
175
- sub-menu, "All" first, one value at a time — so this list is narrowed by the
176
- same gesture as the five that narrow what they hold, and one test helper
177
- still addresses all six (`e2e/support/studio-table.ts`). What is NOT copied
178
- is the count beside each option: the built-in counts the rows in hand, and
179
- counting a page here would put a number next to "failed" that describes this
180
- page rather than the history the option narrows.
156
+ It is no longer the same gesture as the other lists. Theirs tick values and
157
+ apply them together; this one picks one value per dimension and applies it on
158
+ the spot, because each pick is a request to the platform and staging them
159
+ would only delay the same round trip. So the shared narrowing helper does not
160
+ reach this page and `runs.spec.ts` carries its own — said in both places
161
+ rather than left to be discovered (`e2e/support/studio-table.ts`).
162
+
163
+ What is still NOT copied is the count beside each option: the built-in counts
164
+ the rows in hand, and counting a page here would put a number next to
165
+ "failed" that describes this page rather than the history the option narrows.
181
166
  -->
182
167
  <template v-if="serverMode" #filter-menu>
183
- <DropdownMenuSub>
184
- <DropdownMenuSubTrigger class="gap-2">
185
- <span class="flex-1">{{ t("integration.runs.table.column.status") }}</span>
186
- <span v-if="statusApplied" class="text-xs text-primary">{{ statusApplied }}</span>
187
- </DropdownMenuSubTrigger>
188
- <DropdownMenuSubContent class="w-48">
189
- <DropdownMenuItem @select="setStatus(null)">
190
- {{ t("integration.runs.table.filter.all") }}
191
- </DropdownMenuItem>
192
- <DropdownMenuSeparator />
193
- <DropdownMenuItem v-for="word in RUN_STATUS_WORDS" :key="word" @select="setStatus(word)">
194
- {{ runStatusLabel(word) }}
195
- </DropdownMenuItem>
196
- </DropdownMenuSubContent>
197
- </DropdownMenuSub>
168
+ <!--
169
+ Two selects rather than the sub-menus this slot used to hold. Since
170
+ `studio-shared` 0.3.2 the filter trigger opens a popover instead of a
171
+ dropdown menu, and a `DropdownMenuSub` outside a menu root has nothing to
172
+ attach to: it renders nothing at all, so the narrowing was there in the
173
+ markup and gone from the screen. A select is what the new surface is built
174
+ for — it sizes `[data-slot=select-trigger]` to its full width — and it
175
+ keeps what the menu promised, the widest setting included.
176
+ -->
177
+ <div class="space-y-1">
178
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.status") }}</span>
179
+ <Select
180
+ :model-value="statusChoice"
181
+ @update:model-value="(v) => setStatus(v === ALL ? null : String(v))"
182
+ >
183
+ <SelectTrigger :aria-label="t('integration.runs.table.column.status')" class="h-8 text-sm">
184
+ <SelectValue />
185
+ </SelectTrigger>
186
+ <SelectContent>
187
+ <SelectItem :value="ALL">
188
+ {{ t("integration.runs.table.filter.all") }}
189
+ </SelectItem>
190
+ <SelectItem v-for="word in RUN_STATUS_WORDS" :key="word" :value="word">
191
+ {{ runStatusLabel(word) }}
192
+ </SelectItem>
193
+ </SelectContent>
194
+ </Select>
195
+ </div>
198
196
 
199
- <DropdownMenuSub>
200
- <DropdownMenuSubTrigger class="gap-2">
201
- <span class="flex-1">{{ t("integration.runs.table.column.trigger") }}</span>
202
- <span v-if="triggerApplied" class="text-xs text-primary">{{ triggerApplied }}</span>
203
- </DropdownMenuSubTrigger>
204
- <DropdownMenuSubContent class="w-48">
205
- <DropdownMenuItem @select="setTrigger(null)">
206
- {{ t("integration.runs.table.filter.all") }}
207
- </DropdownMenuItem>
208
- <DropdownMenuSeparator />
209
- <DropdownMenuItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" @select="setTrigger(kind)">
210
- {{ triggerTypeLabel(kind) }}
211
- </DropdownMenuItem>
212
- </DropdownMenuSubContent>
213
- </DropdownMenuSub>
197
+ <div class="space-y-1">
198
+ <span class="text-xs font-medium text-muted-foreground">{{ t("integration.runs.table.column.trigger") }}</span>
199
+ <Select
200
+ :model-value="triggerChoice"
201
+ @update:model-value="(v) => setTrigger(v === ALL ? null : String(v))"
202
+ >
203
+ <SelectTrigger :aria-label="t('integration.runs.table.column.trigger')" class="h-8 text-sm">
204
+ <SelectValue />
205
+ </SelectTrigger>
206
+ <SelectContent>
207
+ <SelectItem :value="ALL">
208
+ {{ t("integration.runs.table.filter.all") }}
209
+ </SelectItem>
210
+ <SelectItem v-for="kind in TRIGGER_TYPE_WORDS" :key="kind" :value="kind">
211
+ {{ triggerTypeLabel(kind) }}
212
+ </SelectItem>
213
+ </SelectContent>
214
+ </Select>
215
+ </div>
214
216
  </template>
215
217
 
216
218
  <template #cell-status="{ row: run }">
@@ -243,7 +245,7 @@ const columns = computed(() => [
243
245
  </template>
244
246
 
245
247
  <template #cell-started="{ row: run }">
246
- <span class="text-sm text-muted-foreground tabular-nums">{{ formatRelative(run.startedAt) }}</span>
248
+ <span class="text-sm text-muted-foreground tabular-nums">{{ relativeTime(run.startedAt, now) }}</span>
247
249
  </template>
248
250
 
249
251
  <template #cell-duration="{ row: run }">
@@ -17,13 +17,24 @@ const sftp = typeof useConsoleSftp === "function" ? useConsoleSftp() : {
17
17
  const { create } = useIntegrationsCredentials();
18
18
  const busyId = ref(null);
19
19
  const error = ref(null);
20
- watch(open, (o) => {
21
- if (o) {
22
- error.value = null;
23
- void sftp.load();
20
+ const loadPending = ref(false);
21
+ let openCycle = 0;
22
+ watch(open, async (o) => {
23
+ if (!o) return;
24
+ const cycle = ++openCycle;
25
+ error.value = null;
26
+ loadPending.value = true;
27
+ try {
28
+ await sftp.load();
29
+ } finally {
30
+ if (cycle === openCycle) loadPending.value = false;
24
31
  }
25
32
  });
26
33
  const activeAccounts = computed(() => sftp.accounts.value.filter((a) => a.status === "active" && a.login_username));
34
+ const waiting = computed(() => (loadPending.value || sftp.loading.value) && !activeAccounts.value.length);
35
+ const loadFailed = computed(
36
+ () => !waiting.value && !activeAccounts.value.length && sftp.activeTenantSlug.value && sftp.error?.value ? t("integration.credentials.sftpImport.state.loadFailed", { message: sftp.error.value }) : null
37
+ );
27
38
  async function importAccount(account) {
28
39
  const tenant = sftp.activeTenantSlug.value;
29
40
  if (!tenant || !account.login_username) return;
@@ -69,13 +80,16 @@ async function importAccount(account) {
69
80
  limit written down there (PO-251). -->
70
81
  <LoadingAnnouncement
71
82
  :label="t('integration.credentials.sftpImport.title')"
72
- :loading="sftp.loading.value && !activeAccounts.length"
83
+ :loading="waiting"
73
84
  :text="t('integration.credentials.sftpImport.state.loading')"
74
85
  />
75
86
 
76
- <div v-if="sftp.loading.value && !activeAccounts.length" aria-hidden="true" class="space-y-2">
87
+ <div v-if="waiting" aria-hidden="true" class="space-y-2">
77
88
  <div v-for="n in 3" :key="`sk-${n}`" class="h-9 rounded-md bg-black/[0.07] animate-pulse" />
78
89
  </div>
90
+ <p v-else-if="loadFailed" class="py-4 text-center text-sm text-rose-600">
91
+ {{ loadFailed }}
92
+ </p>
79
93
  <p v-else-if="!activeAccounts.length" class="py-4 text-center text-sm text-muted-foreground">
80
94
  {{ t("integration.credentials.sftpImport.empty") }}
81
95
  </p>
@@ -212,6 +212,7 @@ function onViewMouseDown(e) {
212
212
  class="h-full w-full overflow-hidden whitespace-pre-wrap break-words px-3 py-2"
213
213
  @mousedown="onViewMouseDown"
214
214
  >
215
+ <!-- eslint-disable-next-line vue/no-v-html -- renderMarkdownLite escapes the note before it adds a tag -->
215
216
  <div v-if="data.sticky.content" v-html="rendered" />
216
217
  <!-- The hint is an instruction, not content, so it goes with the rest of the
217
218
  affordances: on a read-only canvas it would name the one gesture
@@ -4,7 +4,6 @@ import { AltArrowLeft as ArrowLeft, RefreshCircle as Loader2, Magnifier as Searc
4
4
  import { triggerGlyph } from "../../data/triggerGlyphs";
5
5
  import * as Lucide from "lucide-vue-next";
6
6
  import {
7
- Badge,
8
7
  Button,
9
8
  Dialog,
10
9
  DialogContent,
@@ -23,6 +22,7 @@ import { TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
23
22
  import LoadingAnnouncement from "./LoadingAnnouncement.vue";
24
23
  import TemplateCard from "./TemplateCard.vue";
25
24
  import TemplateInstallStep from "./TemplateInstallStep.vue";
25
+ import WorkflowImportFileStep from "./WorkflowImportFileStep.vue";
26
26
  import WorkflowIconPicker from "./WorkflowIconPicker.vue";
27
27
  import WorkflowTagsInput from "./WorkflowTagsInput.vue";
28
28
  import { resolveLocalized } from "../../utils/localize";
@@ -35,7 +35,8 @@ const { templates, loading: templatesLoading, error: templatesError, load: loadT
35
35
  const mode = ref("blank");
36
36
  const modeTabs = computed(() => [
37
37
  { key: "blank", label: t("integration.workflows.create.tab.blank") },
38
- { key: "template", label: t("integration.workflows.create.tab.template") }
38
+ { key: "template", label: t("integration.workflows.create.tab.template") },
39
+ { key: "file", label: t("integration.workflows.create.tab.file") }
39
40
  ]);
40
41
  function iconFor(name2) {
41
42
  if (!name2) return Lucide.Box;
@@ -200,14 +201,14 @@ async function installed(id) {
200
201
  </SelectValue>
201
202
  </SelectTrigger>
202
203
  <SelectContent>
203
- <SelectItem v-for="t in TRIGGER_TEMPLATES" :key="t.id" :value="t.type">
204
+ <SelectItem v-for="tpl in TRIGGER_TEMPLATES" :key="tpl.id" :value="tpl.type">
204
205
  <span class="flex items-center gap-2.5 py-0.5">
205
206
  <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
206
- <component :is="triggerGlyph(t.type)" :size="14" />
207
+ <component :is="triggerGlyph(tpl.type)" :size="14" />
207
208
  </span>
208
209
  <span class="min-w-0">
209
- <span class="block text-sm font-medium">{{ resolveLocalized(t.name) }}</span>
210
- <span class="block text-sm text-muted-foreground">{{ resolveLocalized(t.description) }}</span>
210
+ <span class="block text-sm font-medium">{{ resolveLocalized(tpl.name) }}</span>
211
+ <span class="block text-sm text-muted-foreground">{{ resolveLocalized(tpl.description) }}</span>
211
212
  </span>
212
213
  </span>
213
214
  </SelectItem>
@@ -260,6 +261,9 @@ async function installed(id) {
260
261
  </div>
261
262
  </form>
262
263
 
264
+ <!-- ── From a file ────────────────────────────────────────────────────── -->
265
+ <WorkflowImportFileStep v-else-if="mode === 'file'" @created="installed" @cancel="open = false" />
266
+
263
267
  <!-- ── From template ─────────────────────────────────────────────────── -->
264
268
  <div v-else class="-m-1 max-h-[65vh] space-y-3 overflow-y-auto p-1">
265
269
  <!-- The install step, once a template is picked: the same one the
@@ -42,42 +42,8 @@ type __VLS_Props = {
42
42
  * id that is on the canvas is in the blob by construction.
43
43
  */
44
44
  declare function revealNode(id: string): void;
45
- /**
46
- * Point at a node without opening it — for a reader who has to look at the
47
- * card's *surroundings* rather than at its settings.
48
- *
49
- * The deliberate difference from {@link revealNode} is the missing selection,
50
- * and with it the missing settings lightbox. That is the whole reason there are
51
- * two functions: the validation banner sends someone to a field at fault, and
52
- * the dialog is where that field is. The error-path advice sends them to a port
53
- * with nothing on it, and the question they have to answer — where should this
54
- * failure go? — is answered by the graph around the card, which a dialog over
55
- * the canvas covers up.
56
- *
57
- * Marked with the same pulse a newly created card gets, because the job is the
58
- * same one: say which of thirty cards is the one meant.
59
- */
60
- declare function showNodeOnCanvas(id: string): void;
61
- /**
62
- * Hang a Stop-and-Error step off a node's error output and wire it up
63
- * (specs/workflow-advice.md AC-3).
64
- *
65
- * The advice strip can tell an author their error output leads nowhere; on its
66
- * own that is a reprimand without a remedy, because the repair — drop a node,
67
- * pick the right one, connect it, write an expression quoting the failing step's
68
- * message — is four steps of tedium they have to already know. Every template we
69
- * ship does exactly this, twenty-five times over, so the shape is settled and
70
- * worth one click.
71
- *
72
- * Leaves the result unsaved and unopened, like every other insertion here: the
73
- * new step is highlighted and the author decides whether to keep it. Deleting it
74
- * is the undo, because this editor has no undo stack to hook into.
75
- */
76
- declare function attachErrorStop(sourceNodeId: string, port: string): void;
77
45
  declare const _default: import("vue").DefineComponent<__VLS_Props, {
78
46
  revealNode: typeof revealNode;
79
- showNodeOnCanvas: typeof showNodeOnCanvas;
80
- attachErrorStop: typeof attachErrorStop;
81
47
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
82
48
  run: () => any;
83
49
  toggleActive: () => any;