@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
@@ -22,13 +22,7 @@ import {
22
22
  DropdownMenuLabel,
23
23
  DropdownMenuSeparator,
24
24
  DropdownMenuTrigger,
25
- Select,
26
- SelectContent,
27
- SelectItem,
28
- SelectTrigger,
29
- SelectValue,
30
- Switch,
31
- Textarea
25
+ Switch
32
26
  } from "@revenexx/studio";
33
27
  import { deepClone } from "../../../utils/deepClone";
34
28
  import WorkflowEditor from "../../../components/Integrations/WorkflowEditor.vue";
@@ -37,6 +31,9 @@ import WorkflowRunReplayPanel from "../../../components/Integrations/WorkflowRun
37
31
  import WorkflowSettingsDialog from "../../../components/Integrations/WorkflowSettingsDialog.vue";
38
32
  import WorkflowDeleteDialog from "../../../components/Integrations/WorkflowDeleteDialog.vue";
39
33
  import WorkflowDuplicateDialog from "../../../components/Integrations/WorkflowDuplicateDialog.vue";
34
+ import WorkflowExportDialog from "../../../components/Integrations/WorkflowExportDialog.vue";
35
+ import { useWorkflowFile } from "../../../composables/useWorkflowFile";
36
+ import { parseWorkflowFile } from "../../../utils/workflowFile";
40
37
  import RunsTable from "../../../components/Integrations/RunsTable.vue";
41
38
  import RunDetailPanel from "../../../components/Integrations/RunDetailPanel.vue";
42
39
  import { toast } from "vue-sonner";
@@ -46,8 +43,6 @@ import { BUILD_SETTLE_POLL_MS, BUILD_SETTLE_TIMEOUT_MS, createBuildSettle, isBui
46
43
  import { splitRunOutputs } from "../../../utils/runOutputs";
47
44
  import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
48
45
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
49
- import { unwiredErrorPorts } from "../../../utils/errorPathAdvice";
50
- import { newestExecutableVersion } from "../../../data/integrationNodeCatalog";
51
46
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
52
47
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
53
48
  const t = useIntegrationsT();
@@ -110,27 +105,6 @@ async function revealNode(nodeId) {
110
105
  await nextTick();
111
106
  editorRef.value?.revealNode(nodeId);
112
107
  }
113
- const errorPathAdvice = computed(() => {
114
- const blob = shownBlob.value;
115
- if (!blob) return [];
116
- const byId = new Map(blob.nodes.map((n) => [n.id, n]));
117
- return unwiredErrorPorts(blob).flatMap((a) => {
118
- const node = byId.get(a.nodeId);
119
- return node ? [{ ...a, label: nodeDisplayLabel(node) }] : [];
120
- });
121
- });
122
- const adviceExpanded = ref(false);
123
- const canAttachErrorStop = computed(() => newestExecutableVersion("revenexx:stop-and-error") !== null);
124
- async function showNodeOnCanvas(nodeId) {
125
- tab.value = "editor";
126
- await nextTick();
127
- editorRef.value?.showNodeOnCanvas(nodeId);
128
- }
129
- async function attachErrorStop(nodeId, port) {
130
- tab.value = "editor";
131
- await nextTick();
132
- editorRef.value?.attachErrorStop(nodeId, port);
133
- }
134
108
  const { load: loadNodeCatalog } = useIntegrationsNodes();
135
109
  onMounted(() => {
136
110
  void loadNodeCatalog();
@@ -200,7 +174,6 @@ watch(id, async () => {
200
174
  if (!w) await leaveTo("/integrations/workflows");
201
175
  draft.value = null;
202
176
  warningsExpanded.value = false;
203
- adviceExpanded.value = false;
204
177
  if (tab.value === "runs") void loadRuns(1);
205
178
  settleBuildStatus();
206
179
  }, { immediate: true });
@@ -256,6 +229,41 @@ async function onDuplicated(copy) {
256
229
  toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
257
230
  await leaveTo(`/integrations/workflows/${copy.id}`);
258
231
  }
232
+ const exporter = ref(null);
233
+ const { planOverwrite } = useWorkflowFile();
234
+ const importInput = ref(null);
235
+ const importPlan = ref(null);
236
+ const importOpen = ref(false);
237
+ async function onImportPicked(event) {
238
+ const input = event.target;
239
+ const picked = input.files?.[0];
240
+ input.value = "";
241
+ if (!picked || !workflow.value) return;
242
+ const parsed = parseWorkflowFile(await picked.text());
243
+ if (!parsed.ok) {
244
+ toast.error(t(`integration.workflowFile.refused.${parsed.reason}`));
245
+ return;
246
+ }
247
+ let plan;
248
+ try {
249
+ plan = await planOverwrite(workflow.value, parsed.file);
250
+ } catch {
251
+ toast.error(t("integration.workflowFile.import.planError"));
252
+ return;
253
+ }
254
+ importPlan.value = { ...plan, executionMode: parsed.file.workflow.executionMode, active: workflow.value.active, dirty: isDirty.value };
255
+ importOpen.value = true;
256
+ }
257
+ function confirmImport() {
258
+ const plan = importPlan.value;
259
+ importOpen.value = false;
260
+ draft.value = null;
261
+ const base = draftFrom();
262
+ if (!plan || !base) return;
263
+ draft.value = { ...base, executionMode: plan.executionMode, blob: plan.blob };
264
+ editorResetKey.value++;
265
+ toast.success(t("integration.workflowFile.overwrite.toast"));
266
+ }
259
267
  const deleteOpen = ref(false);
260
268
  const deleteTarget = ref(null);
261
269
  function askDelete() {
@@ -780,14 +788,6 @@ async function confirmDeactivate() {
780
788
  saving.value = false;
781
789
  }
782
790
  }
783
- function formatDateTime(iso) {
784
- if (!iso) return "\u2014";
785
- try {
786
- return new Date(iso).toLocaleString();
787
- } catch {
788
- return iso;
789
- }
790
- }
791
791
  </script>
792
792
 
793
793
  <template>
@@ -797,40 +797,40 @@ function formatDateTime(iso) {
797
797
  <div class="flex h-full flex-col">
798
798
  <template v-if="workflow">
799
799
  <PageHeader :title="shownName">
800
- <template #title>
801
- <span class="flex min-w-0 items-center gap-1.5">
802
- <NuxtLink to="/integrations/workflows" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
803
- <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
804
- <!-- Run open: the breadcrumb extends — name and Runs become crumbs
805
- back into the workflow surface. -->
806
- <template v-if="openRunId">
807
- <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" :title="shownName" @click="closeRun('editor')">{{ shownName }}</button>
800
+ <template #title>
801
+ <span class="flex min-w-0 items-center gap-1.5">
802
+ <NuxtLink to="/integrations/workflows" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
808
803
  <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
809
- <button type="button" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
810
- <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
811
- <!-- The Temporal run id IS the run's identity; the internal
804
+ <!-- Run open: the breadcrumb extends — name and Runs become crumbs
805
+ back into the workflow surface. -->
806
+ <template v-if="openRunId">
807
+ <button type="button" class="truncate font-normal text-muted-foreground/80 transition-colors hover:text-primary" :title="shownName" @click="closeRun('editor')">{{ shownName }}</button>
808
+ <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
809
+ <button type="button" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary" @click="closeRun('runs')">{{ t("integration.editor.breadcrumb.runs") }}</button>
810
+ <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
811
+ <!-- The Temporal run id IS the run's identity; the internal
812
812
  number only fills in until the run is handed off. -->
813
- <span class="shrink-0 font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
814
- </template>
815
- <template v-else>
816
- <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
817
- <WorkflowIconView :icon="shownIcon" :size="16" />
818
- </span>
819
- <span class="truncate" :title="shownName">{{ shownName }}</span>
820
- <!-- The SAVED mode, beside a drafted name, and the difference is not an
813
+ <span class="shrink-0 font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
814
+ </template>
815
+ <template v-else>
816
+ <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
817
+ <WorkflowIconView :icon="shownIcon" :size="16" />
818
+ </span>
819
+ <span class="truncate" :title="shownName">{{ shownName }}</span>
820
+ <!-- The SAVED mode, beside a drafted name, and the difference is not an
821
821
  oversight. A name changes nothing about what happens when Run is
822
822
  pressed, so the header may show what the operator just entered. The
823
823
  dispatch mode decides what a run does — and a run goes to the
824
824
  workflow the platform holds, not to the draft. A badge following the
825
825
  draft would read SYNC over a button that fires an async run. -->
826
- <Badge variant="outline" class="ml-1.5 shrink-0 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
827
- {{ modeLabel(workflow.execution_mode) }}
828
- </Badge>
829
- </template>
830
- </span>
831
- </template>
832
- <template #actions>
833
- <!-- Folding the rail away, and getting it back (PO-273). It reads as
826
+ <Badge variant="outline" class="ml-1.5 shrink-0 border text-xs font-medium uppercase text-muted-foreground" :title="t('integration.editor.header.executionMode.title', { mode: modeLabel(workflow.execution_mode) })">
827
+ {{ modeLabel(workflow.execution_mode) }}
828
+ </Badge>
829
+ </template>
830
+ </span>
831
+ </template>
832
+ <template #actions>
833
+ <!-- Folding the rail away, and getting it back (PO-273). It reads as
834
834
  chrome rather than as an action on this workflow, so the natural
835
835
  place was ahead of the breadcrumb — but the shell's `#title` slot
836
836
  *is* the `<h1>`'s content, and a control inside the heading makes
@@ -850,43 +850,43 @@ function formatDateTime(iso) {
850
850
  Icon-only, so it carries the same name as before to a screen reader
851
851
  and to a test. Rendered only where the canvas fills the page, the
852
852
  one surface it acts on. -->
853
- <Button
854
- v-if="canvasFillsPage"
855
- variant="ghost"
856
- size="sm"
857
- class="h-7 shrink-0 gap-1.5 text-sm font-normal text-muted-foreground hover:text-primary"
858
- :class="railFolded ? 'px-2' : 'w-7 px-0'"
859
- :aria-label="railFolded ? void 0 : t('integration.nav.action.hide')"
860
- :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
861
- @click="toggleRail"
862
- >
863
- <RailIcon :size="15" />
864
- <template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
865
- </Button>
866
-
867
- <!-- Run open: run-scoped actions replace the workflow Save. -->
868
- <template v-if="openRunId">
869
- <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" :title="t('integration.editor.action.retry.title')" @click="retryOpenRun">
870
- <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
871
- <RetryIcon v-else :size="12" />
872
- {{ t("integration.editor.action.retry") }}
873
- </Button>
874
- <Button class="h-7 text-sm" @click="closeRun('editor')">
875
- <ArrowLeft :size="12" />
876
- {{ t("integration.editor.action.backToWorkflow") }}
877
- </Button>
878
- </template>
879
- <template v-else>
880
- <span
881
- v-if="buildWorking"
882
- class="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
883
- :title="buildNotice?.text"
853
+ <Button
854
+ v-if="canvasFillsPage"
855
+ variant="ghost"
856
+ size="sm"
857
+ class="h-7 shrink-0 gap-1.5 text-sm font-normal text-muted-foreground hover:text-primary"
858
+ :class="railFolded ? 'px-2' : 'w-7 px-0'"
859
+ :aria-label="railFolded ? void 0 : t('integration.nav.action.hide')"
860
+ :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
861
+ @click="toggleRail"
884
862
  >
885
- <Loader2 :size="12" class="animate-spin" />
886
- {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
887
- </span>
888
- <span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
889
- <!-- What is done to the WORKFLOW, as opposed to the draft in front
863
+ <RailIcon :size="15" />
864
+ <template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
865
+ </Button>
866
+
867
+ <!-- Run open: run-scoped actions replace the workflow Save. -->
868
+ <template v-if="openRunId">
869
+ <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" :title="t('integration.editor.action.retry.title')" @click="retryOpenRun">
870
+ <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
871
+ <RetryIcon v-else :size="12" />
872
+ {{ t("integration.editor.action.retry") }}
873
+ </Button>
874
+ <Button class="h-7 text-sm" @click="closeRun('editor')">
875
+ <ArrowLeft :size="12" />
876
+ {{ t("integration.editor.action.backToWorkflow") }}
877
+ </Button>
878
+ </template>
879
+ <template v-else>
880
+ <span
881
+ v-if="buildWorking"
882
+ class="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
883
+ :title="buildNotice?.text"
884
+ >
885
+ <Loader2 :size="12" class="animate-spin" />
886
+ {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
887
+ </span>
888
+ <span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
889
+ <!-- What is done to the WORKFLOW, as opposed to the draft in front
890
890
  of it: the buttons beside this menu discard and save an
891
891
  afternoon's work, and these two make and unmake the thing that
892
892
  work belongs to. Two reasons they are a menu rather than two more
@@ -895,33 +895,39 @@ function formatDateTime(iso) {
895
895
  Duplicate that stayed a button reads as an oversight rather than
896
896
  as a grouping — the menu only holds together while it holds both.
897
897
  Icon-only, so it carries a name of its own. -->
898
- <DropdownMenu>
899
- <DropdownMenuTrigger as-child>
900
- <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
901
- <MoreHorizontal :size="12" />
902
- </Button>
903
- </DropdownMenuTrigger>
904
- <DropdownMenuContent align="end">
905
- <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
906
- <DropdownMenuSeparator />
907
- <DropdownMenuItem @select="duplicateOpen = true">
908
- {{ t("integration.editor.action.duplicate.label") }}
909
- </DropdownMenuItem>
910
- <DropdownMenuSeparator />
911
- <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
912
- {{ t("integration.common.action.delete") }}
913
- </DropdownMenuItem>
914
- </DropdownMenuContent>
915
- </DropdownMenu>
916
- <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
917
- {{ t("integration.editor.action.discard.label") }}
918
- </Button>
919
- <Button class="h-7 text-sm" :disabled="!isDirty || saving" @click="save">
920
- <Loader2 v-if="saving" :size="12" class="animate-spin" />
921
- <Save v-else :size="12" />
922
- {{ saving ? t("integration.editor.action.save.saving") : t("integration.editor.action.save.label") }}
923
- </Button>
924
- <!-- Activation is a standing state, not a transport control. The play
898
+ <DropdownMenu>
899
+ <DropdownMenuTrigger as-child>
900
+ <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
901
+ <MoreHorizontal :size="12" />
902
+ </Button>
903
+ </DropdownMenuTrigger>
904
+ <DropdownMenuContent align="end">
905
+ <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
906
+ <DropdownMenuSeparator />
907
+ <DropdownMenuItem @select="duplicateOpen = true">
908
+ {{ t("integration.editor.action.duplicate.label") }}
909
+ </DropdownMenuItem>
910
+ <DropdownMenuItem @select="exporter?.start(workflow.id)">
911
+ {{ t("integration.workflowFile.action.export") }}
912
+ </DropdownMenuItem>
913
+ <DropdownMenuItem @select="importInput?.click()">
914
+ {{ t("integration.workflowFile.action.import") }}
915
+ </DropdownMenuItem>
916
+ <DropdownMenuSeparator />
917
+ <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
918
+ {{ t("integration.common.action.delete") }}
919
+ </DropdownMenuItem>
920
+ </DropdownMenuContent>
921
+ </DropdownMenu>
922
+ <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
923
+ {{ t("integration.editor.action.discard.label") }}
924
+ </Button>
925
+ <Button class="h-7 text-sm" :disabled="!isDirty || saving" @click="save">
926
+ <Loader2 v-if="saving" :size="12" class="animate-spin" />
927
+ <Save v-else :size="12" />
928
+ {{ saving ? t("integration.editor.action.save.saving") : t("integration.editor.action.save.label") }}
929
+ </Button>
930
+ <!-- Activation is a standing state, not a transport control. The play
925
931
  symbol belongs to Run, 600 px away in the canvas's control pane,
926
932
  where pressing it makes one thing happen now; arming a workflow's
927
933
  triggers looks like nothing happening at all. So this states the
@@ -933,236 +939,180 @@ function formatDateTime(iso) {
933
939
  is not disabled, and `aria-describedby` puts the same sentence on
934
940
  the switch for anyone who never hovers. Sighted users also have
935
941
  the amber banner directly below, which says it at length. -->
936
- <div
937
- class="flex h-7 items-center gap-2 rounded-md border border-black/[0.08] px-2"
938
- :title="workflow.active ? t('integration.editor.action.pause.title') : isInvalid ? t('integration.editor.action.activate.title.invalid', validationWarnings?.length ?? 0) : t('integration.editor.action.activate.title')"
939
- >
940
- <Switch
941
- :aria-label="t('integration.editor.action.state.label')"
942
- :aria-describedby="!workflow.active && isInvalid ? 'wf-activation-blocked' : void 0"
943
- class="data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-slate-300"
944
- :model-value="workflow.active"
945
- :disabled="!workflow.active && isInvalid"
946
- @update:model-value="onToggleActive"
947
- />
948
- <span class="text-sm" :class="workflow.active ? 'font-medium text-emerald-700' : 'text-muted-foreground'">
949
- {{ workflow.active ? t("integration.workflows.table.status.lifecycle.active") : t("integration.workflows.table.status.lifecycle.paused") }}
950
- </span>
951
- <span v-if="!workflow.active && isInvalid" id="wf-activation-blocked" class="sr-only">
952
- {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
953
- </span>
954
- </div>
942
+ <div
943
+ class="flex h-7 items-center gap-2 rounded-md border border-black/[0.08] px-2"
944
+ :title="workflow.active ? t('integration.editor.action.pause.title') : isInvalid ? t('integration.editor.action.activate.title.invalid', validationWarnings?.length ?? 0) : t('integration.editor.action.activate.title')"
945
+ >
946
+ <Switch
947
+ :aria-label="t('integration.editor.action.state.label')"
948
+ :aria-describedby="!workflow.active && isInvalid ? 'wf-activation-blocked' : void 0"
949
+ class="data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-slate-300"
950
+ :model-value="workflow.active"
951
+ :disabled="!workflow.active && isInvalid"
952
+ @update:model-value="onToggleActive"
953
+ />
954
+ <span class="text-sm" :class="workflow.active ? 'font-medium text-emerald-700' : 'text-muted-foreground'">
955
+ {{ workflow.active ? t("integration.workflows.table.status.lifecycle.active") : t("integration.workflows.table.status.lifecycle.paused") }}
956
+ </span>
957
+ <span v-if="!workflow.active && isInvalid" id="wf-activation-blocked" class="sr-only">
958
+ {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
959
+ </span>
960
+ </div>
961
+ </template>
955
962
  </template>
956
- </template>
957
963
 
958
- </PageHeader>
964
+ </PageHeader>
959
965
 
960
- <TabBar v-if="!openRunId" v-model="tab" :tabs="tabs" class="shrink-0 px-4" />
961
- <TabBar v-else v-model="runView" :tabs="runViews" class="shrink-0 px-4" />
966
+ <TabBar v-if="!openRunId" v-model="tab" :tabs="tabs" class="shrink-0 px-4" />
967
+ <TabBar v-else v-model="runView" :tabs="runViews" class="shrink-0 px-4" />
962
968
 
963
- <div v-if="saveError" class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700">
964
- {{ saveError }}
965
- </div>
969
+ <div v-if="saveError" class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700">
970
+ {{ saveError }}
971
+ </div>
966
972
 
967
- <div v-if="reauthCredentials.length" class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800">
968
- {{ t("integration.editor.reauth.banner", reauthCredentials.length) }}
969
- ({{ reauthCredentials.map((c) => c.name).join(", ") }}) —
970
- <NuxtLink to="/integrations/credentials" class="font-medium underline">
971
- {{ t("integration.editor.reauth.action.fix") }}
972
- </NuxtLink>.
973
- </div>
973
+ <div v-if="reauthCredentials.length" class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800">
974
+ {{ t("integration.editor.reauth.banner", reauthCredentials.length) }}
975
+ ({{ reauthCredentials.map((c) => c.name).join(", ") }}) —
976
+ <NuxtLink to="/integrations/credentials" class="font-medium underline">
977
+ {{ t("integration.editor.reauth.action.fix") }}
978
+ </NuxtLink>.
979
+ </div>
974
980
 
975
- <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
981
+ <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
976
982
  be activated. The banner names what's incomplete; the header's state
977
983
  switch stays blocked until the next save recomputes an empty warnings set.
978
984
  Amber (not red) — the work is saved fine, it just isn't runnable yet.
979
985
  One line per NODE, listing its fields (PO-209): the messages behind
980
986
  them ride along as the line's title, and the line leads to the node. -->
981
- <div
982
- v-if="!openRunId && !workflow.active && isInvalid"
983
- class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800"
984
- >
985
- <p class="font-medium">
986
- {{ t("integration.editor.validation.banner.title", { summary: warningSummary }) }}
987
- </p>
988
- <ul
989
- class="mt-1 space-y-0.5"
990
- :class="warningsExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
987
+ <div
988
+ v-if="!openRunId && !workflow.active && isInvalid"
989
+ class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800"
991
990
  >
992
- <li
993
- v-for="(g, i) in warningsExpanded ? warningGroups : warningGroups.slice(0, WARN_COLLAPSED)"
994
- :key="`${g.nodeId ?? 'unattributed'}-${i}`"
995
- class="flex gap-1.5"
996
- >
997
- <span class="select-none text-amber-500">•</span>
998
- <!-- Attributed to a node: the whole line jumps there and opens its
999
- settings. Otherwise there is nowhere to go — state the finding. -->
1000
- <button
1001
- v-if="g.nodeId"
1002
- type="button"
1003
- class="text-left underline decoration-amber-700/30 underline-offset-2 hover:decoration-amber-800"
1004
- :title="g.messages.join('\n')"
1005
- :aria-label="t('integration.editor.validation.node.ariaFix', g.fields.length, { label: g.label })"
1006
- @click="revealNode(g.nodeId)"
1007
- >
1008
- <span class="font-medium">{{ g.label }}</span> — {{ g.fields.join(", ") }}
1009
- </button>
1010
- <span v-else>{{ g.messages.join(" ") }}</span>
1011
- </li>
1012
- <li v-if="!warningsExpanded && warningGroups.length > WARN_COLLAPSED" class="pl-3">
1013
- <button type="button" class="text-amber-700/90 underline underline-offset-2 hover:text-amber-900" @click="warningsExpanded = true">
1014
- {{ t("integration.editor.validation.banner.more", { n: warningGroups.length - WARN_COLLAPSED }) }}
1015
- </button>
1016
- </li>
1017
- </ul>
1018
- </div>
1019
-
1020
- <!-- Error-path advice (PO-443, specs/workflow-advice.md).
1021
- Slate, NOT amber: amber already means "fix this before it runs" twice
1022
- over in this editor (the reauth strip, the validation banner) and on
1023
- the node cards, so a third would leave the colour meaning nothing.
1024
- This blocks nothing — the activation control stays operable while it
1025
- shows, which is the point of it not being a finding.
1026
-
1027
- Editor tab only, unlike the validation banner above: every line here
1028
- acts on the canvas — reveal a card, attach a step to it — so over the
1029
- run list or the history preview it offers moves to a canvas that is not
1030
- on screen, and its node names read as part of whatever is. That is not
1031
- hypothetical: it put a current node's name over a preview of an
1032
- archived revision (workflow-history.md AC-3). -->
1033
- <div
1034
- v-if="tab === 'editor' && !openRunId && errorPathAdvice.length"
1035
- class="border-b border-slate-500/20 bg-slate-500/5 px-4 py-2 text-sm text-slate-700"
1036
- >
1037
- <p class="font-medium">
1038
- {{ t("integration.editor.errorPath.advice", errorPathAdvice.length) }}
1039
- </p>
1040
- <ul
1041
- class="mt-1 space-y-0.5"
1042
- :class="adviceExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
1043
- >
1044
- <li
1045
- v-for="a in adviceExpanded ? errorPathAdvice : errorPathAdvice.slice(0, WARN_COLLAPSED)"
1046
- :key="`${a.nodeId}-${a.port}`"
1047
- class="flex items-baseline gap-1.5"
991
+ <p class="font-medium">
992
+ {{ t("integration.editor.validation.banner.title", { summary: warningSummary }) }}
993
+ </p>
994
+ <ul
995
+ class="mt-1 space-y-0.5"
996
+ :class="warningsExpanded ? 'max-h-36 overflow-y-auto pr-1' : ''"
1048
997
  >
1049
- <span class="select-none text-slate-400">•</span>
1050
- <button
1051
- type="button"
1052
- class="text-left font-medium underline decoration-transparent underline-offset-2 hover:decoration-slate-700"
1053
- :aria-label="t('integration.editor.errorPath.ariaReveal', { label: a.label, port: a.port })"
1054
- @click="showNodeOnCanvas(a.nodeId)"
1055
- >
1056
- {{ a.label }}
1057
- </button>
1058
- <button
1059
- v-if="canAttachErrorStop"
1060
- type="button"
1061
- class="text-slate-600 underline underline-offset-2 hover:text-slate-900"
1062
- :aria-label="t('integration.editor.errorPath.ariaFix', { label: a.label, port: a.port })"
1063
- @click="attachErrorStop(a.nodeId, a.port)"
998
+ <li
999
+ v-for="(g, i) in warningsExpanded ? warningGroups : warningGroups.slice(0, WARN_COLLAPSED)"
1000
+ :key="`${g.nodeId ?? 'unattributed'}-${i}`"
1001
+ class="flex gap-1.5"
1064
1002
  >
1065
- {{ t("integration.editor.errorPath.action.stop") }}
1066
- </button>
1067
- </li>
1068
- <li v-if="!adviceExpanded && errorPathAdvice.length > WARN_COLLAPSED" class="pl-3">
1069
- <button type="button" class="text-slate-600 underline underline-offset-2 hover:text-slate-900" @click="adviceExpanded = true">
1070
- {{ t("integration.editor.errorPath.more", { n: errorPathAdvice.length - WARN_COLLAPSED }) }}
1071
- </button>
1072
- </li>
1073
- </ul>
1074
- </div>
1003
+ <span class="select-none text-amber-500">•</span>
1004
+ <!-- Attributed to a node: the whole line jumps there and opens its
1005
+ settings. Otherwise there is nowhere to go — state the finding. -->
1006
+ <button
1007
+ v-if="g.nodeId"
1008
+ type="button"
1009
+ class="text-left underline decoration-amber-700/30 underline-offset-2 hover:decoration-amber-800"
1010
+ :title="g.messages.join('\n')"
1011
+ :aria-label="t('integration.editor.validation.node.ariaFix', g.fields.length, { label: g.label })"
1012
+ @click="revealNode(g.nodeId)"
1013
+ >
1014
+ <span class="font-medium">{{ g.label }}</span> — {{ g.fields.join(", ") }}
1015
+ </button>
1016
+ <span v-else>{{ g.messages.join(" ") }}</span>
1017
+ </li>
1018
+ <li v-if="!warningsExpanded && warningGroups.length > WARN_COLLAPSED" class="pl-3">
1019
+ <button type="button" class="text-amber-700/90 underline underline-offset-2 hover:text-amber-900" @click="warningsExpanded = true">
1020
+ {{ t("integration.editor.validation.banner.more", { n: warningGroups.length - WARN_COLLAPSED }) }}
1021
+ </button>
1022
+ </li>
1023
+ </ul>
1024
+ </div>
1075
1025
 
1076
- <!-- Only the FAILED build state banners (its message matters); the
1026
+ <!-- Only the FAILED build state banners (its message matters); the
1077
1027
  working states live as the header chip — and inside a run the
1078
1028
  workflow's build state is noise, so nothing shows at all. -->
1079
- <div
1080
- v-if="!openRunId && buildNotice && buildNotice.tone === 'error'"
1081
- class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700"
1082
- >
1083
- {{ buildNotice.text }}
1084
- </div>
1029
+ <div
1030
+ v-if="!openRunId && buildNotice && buildNotice.tone === 'error'"
1031
+ class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700"
1032
+ >
1033
+ {{ buildNotice.text }}
1034
+ </div>
1085
1035
 
1086
- <!-- Nested run detail — overview/timeline panel or Replay canvas. -->
1087
- <template v-if="openRunId">
1088
- <div v-if="runView !== 'replay'" class="flex-1 overflow-y-auto p-4">
1089
- <div class="w-full">
1090
- <RunDetailPanel v-if="openRun" :run="openRun" :view="runView" @changed="loadOpenRun" />
1091
- <div v-else-if="openRunError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
1092
- {{ openRunError }}
1093
- </div>
1094
- <div v-else class="flex items-center gap-2 p-12 text-sm text-muted-foreground">
1095
- <Loader2 :size="16" class="animate-spin" /> {{ t("integration.editor.run.loading") }}
1036
+ <!-- Nested run detail — overview/timeline panel or Replay canvas. -->
1037
+ <template v-if="openRunId">
1038
+ <div v-if="runView !== 'replay'" class="flex-1 overflow-y-auto p-4">
1039
+ <div class="w-full">
1040
+ <RunDetailPanel v-if="openRun" :run="openRun" :view="runView" :nodes="workflow?.blob?.nodes ?? []" @changed="loadOpenRun" />
1041
+ <div v-else-if="openRunError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
1042
+ {{ openRunError }}
1043
+ </div>
1044
+ <div v-else class="flex items-center gap-2 p-12 text-sm text-muted-foreground">
1045
+ <Loader2 :size="16" class="animate-spin" /> {{ t("integration.editor.run.loading") }}
1046
+ </div>
1096
1047
  </div>
1097
1048
  </div>
1098
- </div>
1099
- <div v-else class="min-h-0 flex-1">
1100
- <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1101
- </div>
1102
- </template>
1049
+ <div v-else class="min-h-0 flex-1">
1050
+ <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1051
+ </div>
1052
+ </template>
1103
1053
 
1104
- <!-- Editor tab — fills the remaining height inside the panel via
1054
+ <!-- Editor tab — fills the remaining height inside the panel via
1105
1055
  the flex wrapper in the studio layout.
1106
1056
 
1107
1057
  overlay-open: every lightbox this page owns renders as a sibling of
1108
1058
  the tabs, so the editor stays mounted underneath one and would keep
1109
1059
  its keyboard shortcuts live (Delete deleting the selected node behind
1110
1060
  the dialog — PO-208). Add new page-level dialogs to this list. -->
1111
- <div v-else-if="tab === 'editor'" class="flex-1 min-h-0">
1112
- <WorkflowEditor
1113
- :key="editorResetKey"
1114
- ref="editorRef"
1115
- :workflow="workflow"
1116
- :draft="draft?.blob ?? null"
1117
- :run-busy="running || syncLoading || waitingForBuild"
1118
- :run-waiting="waitingForBuild"
1119
- :overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
1120
- @change="onChange"
1121
- @run="runFromEditor"
1122
- @toggle-active="onToggleActive"
1123
- @open-settings="settingsOpen = true"
1124
- />
1125
- </div>
1061
+ <div v-else-if="tab === 'editor'" class="flex-1 min-h-0">
1062
+ <WorkflowEditor
1063
+ :key="editorResetKey"
1064
+ ref="editorRef"
1065
+ :workflow="workflow"
1066
+ :draft="draft?.blob ?? null"
1067
+ :run-busy="running || syncLoading || waitingForBuild"
1068
+ :run-waiting="waitingForBuild"
1069
+ :overlay-open="settingsOpen || duplicateOpen || importOpen || !!exporter?.open || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
1070
+ @change="onChange"
1071
+ @run="runFromEditor"
1072
+ @toggle-active="onToggleActive"
1073
+ @open-settings="settingsOpen = true"
1074
+ />
1075
+ </div>
1126
1076
 
1127
- <!-- Runs — this workflow's full execution history. Manual runs start
1077
+ <!-- Runs — this workflow's full execution history. Manual runs start
1128
1078
  from the editor's Run button, through the run dialog. -->
1129
- <div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
1130
- <div v-if="runsError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
1131
- {{ runsError }}
1132
- </div>
1079
+ <div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
1080
+ <div v-if="runsError" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
1081
+ {{ runsError }}
1082
+ </div>
1133
1083
 
1134
- <RunsTable
1135
- :runs="wfRuns"
1136
- :loading="runsLoading"
1137
- :pagination="false"
1138
- :show-workflow="false"
1139
- :empty-text="t('integration.editor.runs.empty')"
1140
- @changed="() => loadRuns()"
1141
- />
1084
+ <RunsTable
1085
+ :runs="wfRuns"
1086
+ :loading="runsLoading"
1087
+ :pagination="false"
1088
+ :show-workflow="false"
1089
+ :empty-text="t('integration.editor.runs.empty')"
1090
+ @changed="() => loadRuns()"
1091
+ />
1142
1092
 
1143
- <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
1144
- <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
1145
- <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
1146
- <div class="flex gap-2">
1147
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
1148
- {{ t("integration.editor.runs.pagination.newer") }}
1149
- </Button>
1150
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
1151
- {{ t("integration.editor.runs.pagination.older") }}
1152
- </Button>
1093
+ <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
1094
+ <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
1095
+ <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
1096
+ <div class="flex gap-2">
1097
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
1098
+ {{ t("integration.editor.runs.pagination.newer") }}
1099
+ </Button>
1100
+ <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
1101
+ {{ t("integration.editor.runs.pagination.older") }}
1102
+ </Button>
1103
+ </div>
1153
1104
  </div>
1154
1105
  </div>
1155
- </div>
1156
1106
 
1157
- <!-- Dead letters — triggers that never produced a run -->
1158
- <div v-else-if="tab === 'dead-letters'" class="p-6 overflow-y-auto">
1159
- <IntegrationsDeadLettersPanel :workflow-id="workflow.id" />
1160
- </div>
1107
+ <!-- Dead letters — triggers that never produced a run -->
1108
+ <div v-else-if="tab === 'dead-letters'" class="p-6 overflow-y-auto">
1109
+ <IntegrationsDeadLettersPanel :workflow-id="workflow.id" />
1110
+ </div>
1161
1111
 
1162
- <!-- History — n8n-style: read-only canvas preview + revision list. -->
1163
- <div v-else-if="tab === 'history'" class="flex-1 min-h-0">
1164
- <WorkflowHistoryPanel :workflow="workflow" :draft-pending="isDirty" @restored="onRevisionRestored" />
1165
- </div>
1112
+ <!-- History — n8n-style: read-only canvas preview + revision list. -->
1113
+ <div v-else-if="tab === 'history'" class="flex-1 min-h-0">
1114
+ <WorkflowHistoryPanel :workflow="workflow" :draft-pending="isDirty" @restored="onRevisionRestored" />
1115
+ </div>
1166
1116
  </template>
1167
1117
 
1168
1118
  <div v-else class="p-12 text-center text-sm text-muted-foreground">
@@ -1277,6 +1227,56 @@ function formatDateTime(iso) {
1277
1227
 
1278
1228
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
1279
1229
 
1230
+ <WorkflowExportDialog ref="exporter" />
1231
+
1232
+ <!-- The menu item opens the browser's own file picker; this input is only its
1233
+ carrier, and it carries a name so it is not an unnamed control. -->
1234
+ <input
1235
+ ref="importInput"
1236
+ type="file"
1237
+ accept=".json,application/json"
1238
+ class="hidden"
1239
+ :aria-label="t('integration.workflowFile.field.file')"
1240
+ @change="onImportPicked"
1241
+ >
1242
+
1243
+ <!-- Importing over this workflow. Says how much goes, that the credentials have
1244
+ to be chosen again, and — where it applies — that the running version keeps
1245
+ running and that unsaved work is thrown away. -->
1246
+ <AlertDialog :open="importOpen" @update:open="(v) => {
1247
+ if (!v) importOpen = false;
1248
+ }">
1249
+ <AlertDialogContent>
1250
+ <AlertDialogHeader>
1251
+ <AlertDialogTitle>{{ t("integration.workflowFile.overwrite.title") }}</AlertDialogTitle>
1252
+ <AlertDialogDescription v-if="importPlan && workflow">
1253
+ {{ t("integration.workflowFile.overwrite.description", {
1254
+ name: workflow.name,
1255
+ current: t("integration.workflowFile.count.both", {
1256
+ nodes: t("integration.workflowFile.count.nodes", importPlan.replaced.nodes),
1257
+ triggers: t("integration.workflowFile.count.triggers", importPlan.replaced.triggers)
1258
+ }),
1259
+ incoming: t("integration.workflowFile.count.both", {
1260
+ nodes: t("integration.workflowFile.count.nodes", importPlan.incoming.nodes),
1261
+ triggers: t("integration.workflowFile.count.triggers", importPlan.incoming.triggers)
1262
+ })
1263
+ }) }}
1264
+ </AlertDialogDescription>
1265
+ </AlertDialogHeader>
1266
+ <ul class="space-y-1 text-sm text-muted-foreground">
1267
+ <li>{{ t("integration.workflowFile.overwrite.save") }}</li>
1268
+ <li v-if="importPlan?.active" class="text-amber-700">{{ t("integration.workflowFile.overwrite.active") }}</li>
1269
+ <li v-if="importPlan?.dirty" class="text-amber-700">{{ t("integration.workflowFile.overwrite.draft") }}</li>
1270
+ </ul>
1271
+ <AlertDialogFooter>
1272
+ <AlertDialogCancel as-child>
1273
+ <Button variant="outline">{{ t("integration.common.action.cancel") }}</Button>
1274
+ </AlertDialogCancel>
1275
+ <Button @click="confirmImport">{{ t("integration.workflowFile.overwrite.confirm") }}</Button>
1276
+ </AlertDialogFooter>
1277
+ </AlertDialogContent>
1278
+ </AlertDialog>
1279
+
1280
1280
  <!-- Leaving the workflow with unsaved work.
1281
1281
  Staying is the default in the only sense that decides anything: it holds the
1282
1282
  focus when the dialog opens, so Enter or Space keeps the work. That is what