@revenexx/studio-integrations 2.0.0 → 2.2.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 (73) hide show
  1. package/CHANGELOG.md +141 -0
  2. package/README.md +1 -0
  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 +28 -9
  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 +7 -26
  21. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -0
  22. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +10 -6
  23. package/dist/runtime/components/Integrations/WorkflowEditor.vue +224 -225
  24. package/dist/runtime/components/Integrations/WorkflowExportDialog.d.vue.ts +6 -0
  25. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue +71 -0
  26. package/dist/runtime/components/Integrations/WorkflowExportDialog.vue.d.ts +6 -0
  27. package/dist/runtime/components/Integrations/WorkflowImportFileStep.d.vue.ts +8 -0
  28. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue +95 -0
  29. package/dist/runtime/components/Integrations/WorkflowImportFileStep.vue.d.ts +8 -0
  30. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +5 -4
  31. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +0 -7
  32. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +2 -2
  33. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +2 -3
  34. package/dist/runtime/components/Integrations/WorkflowsTable.vue +14 -8
  35. package/dist/runtime/composables/useIntegrationsNodes.js +4 -1
  36. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +3 -1
  37. package/dist/runtime/composables/useIntegrationsWorkflows.js +5 -22
  38. package/dist/runtime/composables/useNow.d.ts +17 -0
  39. package/dist/runtime/composables/useNow.js +16 -0
  40. package/dist/runtime/composables/useWorkflowFile.d.ts +21 -0
  41. package/dist/runtime/composables/useWorkflowFile.js +63 -0
  42. package/dist/runtime/data/expressionEditor.d.ts +11 -1
  43. package/dist/runtime/data/expressionEditor.js +2 -1
  44. package/dist/runtime/data/nodeIcons.d.ts +14 -0
  45. package/dist/runtime/data/nodeIcons.js +57 -14
  46. package/dist/runtime/data/nodeRunData.js +1 -1
  47. package/dist/runtime/data/payloadForm.js +1 -1
  48. package/dist/runtime/i18n/messages/de.json +42 -0
  49. package/dist/runtime/i18n/messages/en.json +42 -0
  50. package/dist/runtime/pages/integrations/audit.vue +3 -3
  51. package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +2 -2
  52. package/dist/runtime/pages/integrations/index.vue +5 -10
  53. package/dist/runtime/pages/integrations/schedules.vue +13 -1
  54. package/dist/runtime/pages/integrations/templates/index.vue +40 -40
  55. package/dist/runtime/pages/integrations/workflows/[id].vue +324 -244
  56. package/dist/runtime/types/integrations.d.ts +13 -0
  57. package/dist/runtime/utils/credentialState.d.ts +0 -7
  58. package/dist/runtime/utils/credentialState.js +0 -12
  59. package/dist/runtime/utils/localize.d.ts +12 -0
  60. package/dist/runtime/utils/localize.js +1 -1
  61. package/dist/runtime/utils/paletteOrder.d.ts +29 -0
  62. package/dist/runtime/utils/paletteOrder.js +46 -0
  63. package/dist/runtime/utils/relativeTime.d.ts +29 -0
  64. package/dist/runtime/utils/relativeTime.js +23 -0
  65. package/dist/runtime/utils/runFeedQuery.d.ts +12 -6
  66. package/dist/runtime/utils/runFeedQuery.js +3 -18
  67. package/dist/runtime/utils/settingValue.d.ts +33 -0
  68. package/dist/runtime/utils/settingValue.js +8 -0
  69. package/dist/runtime/utils/statusStyle.d.ts +8 -8
  70. package/dist/runtime/utils/statusStyle.js +4 -1
  71. package/dist/runtime/utils/workflowFile.d.ts +119 -0
  72. package/dist/runtime/utils/workflowFile.js +192 -0
  73. package/package.json +8 -3
@@ -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";
@@ -232,6 +229,41 @@ async function onDuplicated(copy) {
232
229
  toast.success(t("integration.editor.toast.duplicated", { name: copy.name }));
233
230
  await leaveTo(`/integrations/workflows/${copy.id}`);
234
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
+ }
235
267
  const deleteOpen = ref(false);
236
268
  const deleteTarget = ref(null);
237
269
  function askDelete() {
@@ -756,14 +788,6 @@ async function confirmDeactivate() {
756
788
  saving.value = false;
757
789
  }
758
790
  }
759
- function formatDateTime(iso) {
760
- if (!iso) return "\u2014";
761
- try {
762
- return new Date(iso).toLocaleString();
763
- } catch {
764
- return iso;
765
- }
766
- }
767
791
  </script>
768
792
 
769
793
  <template>
@@ -773,40 +797,40 @@ function formatDateTime(iso) {
773
797
  <div class="flex h-full flex-col">
774
798
  <template v-if="workflow">
775
799
  <PageHeader :title="shownName">
776
- <template #title>
777
- <span class="flex min-w-0 items-center gap-1.5">
778
- <NuxtLink to="/integrations/workflows" class="shrink-0 font-normal text-muted-foreground/80 transition-colors hover:text-primary">{{ t("integration.editor.breadcrumb.workflows") }}</NuxtLink>
779
- <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
780
- <!-- Run open: the breadcrumb extends — name and Runs become crumbs
781
- back into the workflow surface. -->
782
- <template v-if="openRunId">
783
- <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>
784
803
  <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
785
- <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>
786
- <Chevron :size="15" weight="Outline" class="shrink-0 text-muted-foreground/50" />
787
- <!-- 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
788
812
  number only fills in until the run is handed off. -->
789
- <span class="shrink-0 font-mono">{{ openRun?.temporalRunId ?? t("integration.editor.breadcrumb.runNumber", { id: openRunId }) }}</span>
790
- </template>
791
- <template v-else>
792
- <span class="flex size-7 shrink-0 items-center justify-center rounded-md bg-slate-100 text-slate-500">
793
- <WorkflowIconView :icon="shownIcon" :size="16" />
794
- </span>
795
- <span class="truncate" :title="shownName">{{ shownName }}</span>
796
- <!-- 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
797
821
  oversight. A name changes nothing about what happens when Run is
798
822
  pressed, so the header may show what the operator just entered. The
799
823
  dispatch mode decides what a run does — and a run goes to the
800
824
  workflow the platform holds, not to the draft. A badge following the
801
825
  draft would read SYNC over a button that fires an async run. -->
802
- <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) })">
803
- {{ modeLabel(workflow.execution_mode) }}
804
- </Badge>
805
- </template>
806
- </span>
807
- </template>
808
- <template #actions>
809
- <!-- 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
810
834
  chrome rather than as an action on this workflow, so the natural
811
835
  place was ahead of the breadcrumb — but the shell's `#title` slot
812
836
  *is* the `<h1>`'s content, and a control inside the heading makes
@@ -826,43 +850,43 @@ function formatDateTime(iso) {
826
850
  Icon-only, so it carries the same name as before to a screen reader
827
851
  and to a test. Rendered only where the canvas fills the page, the
828
852
  one surface it acts on. -->
829
- <Button
830
- v-if="canvasFillsPage"
831
- variant="ghost"
832
- size="sm"
833
- class="h-7 shrink-0 gap-1.5 text-sm font-normal text-muted-foreground hover:text-primary"
834
- :class="railFolded ? 'px-2' : 'w-7 px-0'"
835
- :aria-label="railFolded ? void 0 : t('integration.nav.action.hide')"
836
- :title="railFolded ? t('integration.nav.action.show.title') : t('integration.nav.action.hide.title')"
837
- @click="toggleRail"
838
- >
839
- <RailIcon :size="15" />
840
- <template v-if="railFolded">{{ t("integration.nav.action.show") }}</template>
841
- </Button>
842
-
843
- <!-- Run open: run-scoped actions replace the workflow Save. -->
844
- <template v-if="openRunId">
845
- <Button v-if="canRetryOpenRun" variant="outline" class="h-7 text-sm" :disabled="retryBusy" :title="t('integration.editor.action.retry.title')" @click="retryOpenRun">
846
- <Loader2 v-if="retryBusy" :size="12" class="animate-spin" />
847
- <RetryIcon v-else :size="12" />
848
- {{ t("integration.editor.action.retry") }}
849
- </Button>
850
- <Button class="h-7 text-sm" @click="closeRun('editor')">
851
- <ArrowLeft :size="12" />
852
- {{ t("integration.editor.action.backToWorkflow") }}
853
- </Button>
854
- </template>
855
- <template v-else>
856
- <span
857
- v-if="buildWorking"
858
- 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"
859
- :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"
860
862
  >
861
- <Loader2 :size="12" class="animate-spin" />
862
- {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
863
- </span>
864
- <span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
865
- <!-- 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
866
890
  of it: the buttons beside this menu discard and save an
867
891
  afternoon's work, and these two make and unmake the thing that
868
892
  work belongs to. Two reasons they are a menu rather than two more
@@ -871,33 +895,39 @@ function formatDateTime(iso) {
871
895
  Duplicate that stayed a button reads as an oversight rather than
872
896
  as a grouping — the menu only holds together while it holds both.
873
897
  Icon-only, so it carries a name of its own. -->
874
- <DropdownMenu>
875
- <DropdownMenuTrigger as-child>
876
- <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
877
- <MoreHorizontal :size="12" />
878
- </Button>
879
- </DropdownMenuTrigger>
880
- <DropdownMenuContent align="end">
881
- <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
882
- <DropdownMenuSeparator />
883
- <DropdownMenuItem @select="duplicateOpen = true">
884
- {{ t("integration.editor.action.duplicate.label") }}
885
- </DropdownMenuItem>
886
- <DropdownMenuSeparator />
887
- <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
888
- {{ t("integration.common.action.delete") }}
889
- </DropdownMenuItem>
890
- </DropdownMenuContent>
891
- </DropdownMenu>
892
- <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
893
- {{ t("integration.editor.action.discard.label") }}
894
- </Button>
895
- <Button class="h-7 text-sm" :disabled="!isDirty || saving" @click="save">
896
- <Loader2 v-if="saving" :size="12" class="animate-spin" />
897
- <Save v-else :size="12" />
898
- {{ saving ? t("integration.editor.action.save.saving") : t("integration.editor.action.save.label") }}
899
- </Button>
900
- <!-- 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
901
931
  symbol belongs to Run, 600 px away in the canvas's control pane,
902
932
  where pressing it makes one thing happen now; arming a workflow's
903
933
  triggers looks like nothing happening at all. So this states the
@@ -909,180 +939,180 @@ function formatDateTime(iso) {
909
939
  is not disabled, and `aria-describedby` puts the same sentence on
910
940
  the switch for anyone who never hovers. Sighted users also have
911
941
  the amber banner directly below, which says it at length. -->
912
- <div
913
- class="flex h-7 items-center gap-2 rounded-md border border-black/[0.08] px-2"
914
- :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')"
915
- >
916
- <Switch
917
- :aria-label="t('integration.editor.action.state.label')"
918
- :aria-describedby="!workflow.active && isInvalid ? 'wf-activation-blocked' : void 0"
919
- class="data-[state=checked]:bg-emerald-600 data-[state=unchecked]:bg-slate-300"
920
- :model-value="workflow.active"
921
- :disabled="!workflow.active && isInvalid"
922
- @update:model-value="onToggleActive"
923
- />
924
- <span class="text-sm" :class="workflow.active ? 'font-medium text-emerald-700' : 'text-muted-foreground'">
925
- {{ workflow.active ? t("integration.workflows.table.status.lifecycle.active") : t("integration.workflows.table.status.lifecycle.paused") }}
926
- </span>
927
- <span v-if="!workflow.active && isInvalid" id="wf-activation-blocked" class="sr-only">
928
- {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
929
- </span>
930
- </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>
931
962
  </template>
932
- </template>
933
963
 
934
- </PageHeader>
964
+ </PageHeader>
935
965
 
936
- <TabBar v-if="!openRunId" v-model="tab" :tabs="tabs" class="shrink-0 px-4" />
937
- <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" />
938
968
 
939
- <div v-if="saveError" class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700">
940
- {{ saveError }}
941
- </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>
942
972
 
943
- <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">
944
- {{ t("integration.editor.reauth.banner", reauthCredentials.length) }}
945
- ({{ reauthCredentials.map((c) => c.name).join(", ") }}) —
946
- <NuxtLink to="/integrations/credentials" class="font-medium underline">
947
- {{ t("integration.editor.reauth.action.fix") }}
948
- </NuxtLink>.
949
- </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>
950
980
 
951
- <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
981
+ <!-- Manifest validation (PO-186): an inactive workflow with warnings can't
952
982
  be activated. The banner names what's incomplete; the header's state
953
983
  switch stays blocked until the next save recomputes an empty warnings set.
954
984
  Amber (not red) — the work is saved fine, it just isn't runnable yet.
955
985
  One line per NODE, listing its fields (PO-209): the messages behind
956
986
  them ride along as the line's title, and the line leads to the node. -->
957
- <div
958
- v-if="!openRunId && !workflow.active && isInvalid"
959
- class="border-b border-amber-500/20 bg-amber-500/5 px-4 py-2 text-sm text-amber-800"
960
- >
961
- <p class="font-medium">
962
- {{ t("integration.editor.validation.banner.title", { summary: warningSummary }) }}
963
- </p>
964
- <ul
965
- class="mt-1 space-y-0.5"
966
- :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"
967
990
  >
968
- <li
969
- v-for="(g, i) in warningsExpanded ? warningGroups : warningGroups.slice(0, WARN_COLLAPSED)"
970
- :key="`${g.nodeId ?? 'unattributed'}-${i}`"
971
- class="flex 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' : ''"
972
997
  >
973
- <span class="select-none text-amber-500">•</span>
974
- <!-- Attributed to a node: the whole line jumps there and opens its
975
- settings. Otherwise there is nowhere to go — state the finding. -->
976
- <button
977
- v-if="g.nodeId"
978
- type="button"
979
- class="text-left underline decoration-amber-700/30 underline-offset-2 hover:decoration-amber-800"
980
- :title="g.messages.join('\n')"
981
- :aria-label="t('integration.editor.validation.node.ariaFix', g.fields.length, { label: g.label })"
982
- @click="revealNode(g.nodeId)"
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"
983
1002
  >
984
- <span class="font-medium">{{ g.label }}</span> — {{ g.fields.join(", ") }}
985
- </button>
986
- <span v-else>{{ g.messages.join(" ") }}</span>
987
- </li>
988
- <li v-if="!warningsExpanded && warningGroups.length > WARN_COLLAPSED" class="pl-3">
989
- <button type="button" class="text-amber-700/90 underline underline-offset-2 hover:text-amber-900" @click="warningsExpanded = true">
990
- {{ t("integration.editor.validation.banner.more", { n: warningGroups.length - WARN_COLLAPSED }) }}
991
- </button>
992
- </li>
993
- </ul>
994
- </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>
995
1025
 
996
- <!-- Only the FAILED build state banners (its message matters); the
1026
+ <!-- Only the FAILED build state banners (its message matters); the
997
1027
  working states live as the header chip — and inside a run the
998
1028
  workflow's build state is noise, so nothing shows at all. -->
999
- <div
1000
- v-if="!openRunId && buildNotice && buildNotice.tone === 'error'"
1001
- class="border-b border-rose-500/20 bg-rose-500/5 px-4 py-2 text-sm text-rose-700"
1002
- >
1003
- {{ buildNotice.text }}
1004
- </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>
1005
1035
 
1006
- <!-- Nested run detail — overview/timeline panel or Replay canvas. -->
1007
- <template v-if="openRunId">
1008
- <div v-if="runView !== 'replay'" class="flex-1 overflow-y-auto p-4">
1009
- <div class="w-full">
1010
- <RunDetailPanel v-if="openRun" :run="openRun" :view="runView" @changed="loadOpenRun" />
1011
- <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">
1012
- {{ openRunError }}
1013
- </div>
1014
- <div v-else class="flex items-center gap-2 p-12 text-sm text-muted-foreground">
1015
- <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>
1016
1047
  </div>
1017
1048
  </div>
1018
- </div>
1019
- <div v-else class="min-h-0 flex-1">
1020
- <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1021
- </div>
1022
- </template>
1049
+ <div v-else class="min-h-0 flex-1">
1050
+ <WorkflowRunReplayPanel :workflow="workflow" :initial-run-id="openRunId" />
1051
+ </div>
1052
+ </template>
1023
1053
 
1024
- <!-- Editor tab — fills the remaining height inside the panel via
1054
+ <!-- Editor tab — fills the remaining height inside the panel via
1025
1055
  the flex wrapper in the studio layout.
1026
1056
 
1027
1057
  overlay-open: every lightbox this page owns renders as a sibling of
1028
1058
  the tabs, so the editor stays mounted underneath one and would keep
1029
1059
  its keyboard shortcuts live (Delete deleting the selected node behind
1030
1060
  the dialog — PO-208). Add new page-level dialogs to this list. -->
1031
- <div v-else-if="tab === 'editor'" class="flex-1 min-h-0">
1032
- <WorkflowEditor
1033
- :key="editorResetKey"
1034
- ref="editorRef"
1035
- :workflow="workflow"
1036
- :draft="draft?.blob ?? null"
1037
- :run-busy="running || syncLoading || waitingForBuild"
1038
- :run-waiting="waitingForBuild"
1039
- :overlay-open="settingsOpen || duplicateOpen || deleteOpen || syncOpen || runPayloadOpen || deactivationOpen || leaveOpen"
1040
- @change="onChange"
1041
- @run="runFromEditor"
1042
- @toggle-active="onToggleActive"
1043
- @open-settings="settingsOpen = true"
1044
- />
1045
- </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>
1046
1076
 
1047
- <!-- Runs — this workflow's full execution history. Manual runs start
1077
+ <!-- Runs — this workflow's full execution history. Manual runs start
1048
1078
  from the editor's Run button, through the run dialog. -->
1049
- <div v-else-if="tab === 'runs'" class="p-4 space-y-4 overflow-y-auto">
1050
- <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">
1051
- {{ runsError }}
1052
- </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>
1053
1083
 
1054
- <RunsTable
1055
- :runs="wfRuns"
1056
- :loading="runsLoading"
1057
- :pagination="false"
1058
- :show-workflow="false"
1059
- :empty-text="t('integration.editor.runs.empty')"
1060
- @changed="() => loadRuns()"
1061
- />
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
+ />
1062
1092
 
1063
- <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
1064
- <div v-if="runsLastPage > 1 || runsTotal > wfRuns.length" class="flex items-center justify-between text-sm text-muted-foreground">
1065
- <span class="tabular-nums">{{ t("integration.editor.runs.pagination.page", { page: runsPage, lastPage: runsLastPage }) }} · {{ t("integration.editor.runs.pagination.count", runsTotal) }}</span>
1066
- <div class="flex gap-2">
1067
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted <= 1 || runsLoading" @click="goRunsPage(runsWanted - 1)">
1068
- {{ t("integration.editor.runs.pagination.newer") }}
1069
- </Button>
1070
- <Button variant="outline" size="sm" class="h-7 text-sm" :disabled="runsWanted >= runsLastPage || runsLoading" @click="goRunsPage(runsWanted + 1)">
1071
- {{ t("integration.editor.runs.pagination.older") }}
1072
- </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>
1073
1104
  </div>
1074
1105
  </div>
1075
- </div>
1076
1106
 
1077
- <!-- Dead letters — triggers that never produced a run -->
1078
- <div v-else-if="tab === 'dead-letters'" class="p-6 overflow-y-auto">
1079
- <IntegrationsDeadLettersPanel :workflow-id="workflow.id" />
1080
- </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>
1081
1111
 
1082
- <!-- History — n8n-style: read-only canvas preview + revision list. -->
1083
- <div v-else-if="tab === 'history'" class="flex-1 min-h-0">
1084
- <WorkflowHistoryPanel :workflow="workflow" :draft-pending="isDirty" @restored="onRevisionRestored" />
1085
- </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>
1086
1116
  </template>
1087
1117
 
1088
1118
  <div v-else class="p-12 text-center text-sm text-muted-foreground">
@@ -1197,6 +1227,56 @@ function formatDateTime(iso) {
1197
1227
 
1198
1228
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
1199
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
+
1200
1280
  <!-- Leaving the workflow with unsaved work.
1201
1281
  Staying is the default in the only sense that decides anything: it holds the
1202
1282
  focus when the dialog opens, so Enter or Space keeps the work. That is what