@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
@@ -0,0 +1,6 @@
1
+ declare function start(id: number): Promise<void>;
2
+ declare const _default: import("vue").DefineComponent<{}, {
3
+ start: typeof start;
4
+ open: import("vue").Ref<boolean, boolean>;
5
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
+ export default _default;
@@ -0,0 +1,71 @@
1
+ <script setup>
2
+ import { ref } from "vue";
3
+ import { toast } from "vue-sonner";
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogCancel,
7
+ AlertDialogContent,
8
+ AlertDialogDescription,
9
+ AlertDialogFooter,
10
+ AlertDialogHeader,
11
+ AlertDialogTitle,
12
+ Button
13
+ } from "@revenexx/studio";
14
+ import { useWorkflowFile } from "../../composables/useWorkflowFile";
15
+ import { hasVariableValues } from "../../utils/workflowFile";
16
+ import { errorReason } from "../../utils/apiError";
17
+ const t = useIntegrationsT();
18
+ const { fileFor, download } = useWorkflowFile();
19
+ const open = ref(false);
20
+ const pending = ref(null);
21
+ async function start(id) {
22
+ let file;
23
+ try {
24
+ file = (await fileFor(id)).file;
25
+ } catch (e) {
26
+ toast.error(errorReason(e, t("integration.workflowFile.export.error")));
27
+ return;
28
+ }
29
+ if (hasVariableValues(file.workflow.blob)) {
30
+ pending.value = file;
31
+ open.value = true;
32
+ return;
33
+ }
34
+ finish(file);
35
+ }
36
+ function finish(file) {
37
+ download(file);
38
+ toast.success(t("integration.workflowFile.export.toast", { name: file.workflow.name }));
39
+ }
40
+ function confirm() {
41
+ const file = pending.value;
42
+ open.value = false;
43
+ pending.value = null;
44
+ if (file) finish(file);
45
+ }
46
+ defineExpose({ start, open });
47
+ </script>
48
+
49
+ <template>
50
+ <AlertDialog :open="open" @update:open="(v) => {
51
+ if (!v) {
52
+ open = false;
53
+ pending = null;
54
+ }
55
+ }">
56
+ <AlertDialogContent>
57
+ <AlertDialogHeader>
58
+ <AlertDialogTitle>{{ t("integration.workflowFile.export.notice.title") }}</AlertDialogTitle>
59
+ <AlertDialogDescription>
60
+ {{ t("integration.workflowFile.export.notice.description") }}
61
+ </AlertDialogDescription>
62
+ </AlertDialogHeader>
63
+ <AlertDialogFooter>
64
+ <AlertDialogCancel as-child>
65
+ <Button variant="outline">{{ t("integration.common.action.cancel") }}</Button>
66
+ </AlertDialogCancel>
67
+ <Button @click="confirm">{{ t("integration.workflowFile.export.notice.confirm") }}</Button>
68
+ </AlertDialogFooter>
69
+ </AlertDialogContent>
70
+ </AlertDialog>
71
+ </template>
@@ -0,0 +1,6 @@
1
+ declare function start(id: number): Promise<void>;
2
+ declare const _default: import("vue").DefineComponent<{}, {
3
+ start: typeof start;
4
+ open: import("vue").Ref<boolean, boolean>;
5
+ }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
+ export default _default;
@@ -0,0 +1,8 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
2
+ created: (id: number) => any;
3
+ cancel: () => any;
4
+ }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
5
+ onCreated?: ((id: number) => any) | undefined;
6
+ onCancel?: (() => any) | undefined;
7
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
8
+ export default _default;
@@ -0,0 +1,95 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import { RefreshCircle as Loader2 } from "#solar-icons";
4
+ import { Button, Input, Label } from "@revenexx/studio";
5
+ import { useWorkflowFile } from "../../composables/useWorkflowFile";
6
+ import { parseWorkflowFile } from "../../utils/workflowFile";
7
+ import { errorReason } from "../../utils/apiError";
8
+ const emit = defineEmits(["created", "cancel"]);
9
+ const t = useIntegrationsT();
10
+ const { importAsNew } = useWorkflowFile();
11
+ const file = ref(null);
12
+ const name = ref("");
13
+ const error = ref(null);
14
+ const busy = ref(false);
15
+ const summary = computed(() => {
16
+ const b = file.value?.workflow.blob;
17
+ if (!b) return "";
18
+ return t("integration.workflowFile.count.both", {
19
+ nodes: t("integration.workflowFile.count.nodes", b.nodes.length),
20
+ triggers: t("integration.workflowFile.count.triggers", b.triggers?.length ?? 0)
21
+ });
22
+ });
23
+ async function onPick(event) {
24
+ const input = event.target;
25
+ const picked = input.files?.[0];
26
+ file.value = null;
27
+ error.value = null;
28
+ if (!picked) return;
29
+ const parsed = parseWorkflowFile(await picked.text());
30
+ if (!parsed.ok) {
31
+ error.value = t(`integration.workflowFile.refused.${parsed.reason}`);
32
+ input.value = "";
33
+ return;
34
+ }
35
+ file.value = parsed.file;
36
+ name.value = parsed.file.workflow.name;
37
+ }
38
+ async function submit() {
39
+ if (!file.value || busy.value) return;
40
+ if (!name.value.trim()) {
41
+ error.value = t("integration.workflows.create.error.nameRequired");
42
+ return;
43
+ }
44
+ busy.value = true;
45
+ error.value = null;
46
+ try {
47
+ const created = await importAsNew(file.value, name.value);
48
+ emit("created", created.id);
49
+ } catch (e) {
50
+ error.value = errorReason(e, t("integration.workflowFile.import.error"));
51
+ } finally {
52
+ busy.value = false;
53
+ }
54
+ }
55
+ </script>
56
+
57
+ <template>
58
+ <form class="-m-1 max-h-[65vh] space-y-3 overflow-y-auto p-1" @submit.prevent="submit">
59
+ <div class="grid grid-cols-[130px_1fr] items-center gap-x-4">
60
+ <Label for="wf-import-file">{{ t("integration.workflowFile.field.file") }}</Label>
61
+ <input
62
+ id="wf-import-file"
63
+ type="file"
64
+ accept=".json,application/json"
65
+ :disabled="busy"
66
+ class="text-sm file:mr-3 file:h-8 file:rounded-md file:border file:border-black/[0.08] file:bg-white file:px-3 file:text-sm"
67
+ @change="onPick"
68
+ >
69
+ </div>
70
+
71
+ <template v-if="file">
72
+ <div class="grid grid-cols-[130px_1fr] items-center gap-x-4">
73
+ <Label for="wf-import-name">{{ t("integration.workflows.create.field.name.label") }}</Label>
74
+ <Input id="wf-import-name" v-model="name" :disabled="busy" class="bg-white" />
75
+ </div>
76
+ <p class="text-sm text-muted-foreground">
77
+ {{ summary }} · {{ t("integration.workflowFile.import.arrives") }}
78
+ </p>
79
+ </template>
80
+
81
+ <div v-if="error" role="alert" class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-sm text-rose-700">
82
+ {{ error }}
83
+ </div>
84
+
85
+ <div class="flex gap-2 pt-1">
86
+ <Button type="submit" :disabled="!file || busy" class="h-8 text-sm">
87
+ <Loader2 v-if="busy" :size="14" class="animate-spin" />
88
+ {{ busy ? t("integration.workflows.create.action.submitting") : t("integration.workflows.create.action.submit") }}
89
+ </Button>
90
+ <Button type="button" variant="outline" class="h-8 text-sm" @click="emit('cancel')">
91
+ {{ t("integration.common.action.cancel") }}
92
+ </Button>
93
+ </div>
94
+ </form>
95
+ </template>
@@ -0,0 +1,8 @@
1
+ declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
2
+ created: (id: number) => any;
3
+ cancel: () => any;
4
+ }, string, import("vue").PublicProps, Readonly<{}> & Readonly<{
5
+ onCreated?: ((id: number) => any) | undefined;
6
+ onCancel?: (() => any) | undefined;
7
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
8
+ export default _default;
@@ -93,10 +93,11 @@ function portSemantic(p) {
93
93
  }
94
94
  function portTone(p) {
95
95
  switch (portSemantic(p)) {
96
+ // Switch cases / iterator `item` (`branch`): a taken branch is a normal
97
+ // continuation, so it reads green like any other output rather than a
98
+ // special colour.
96
99
  case "success":
97
100
  case "branch-true":
98
- // Switch cases / iterator `item`: a taken branch is a normal continuation,
99
- // so it reads green like any other output rather than a special colour.
100
101
  case "branch":
101
102
  return { handle: "!bg-emerald-500", label: "text-emerald-600" };
102
103
  // `false` is the neutral "didn't match" route, not a warning — muted slate
@@ -263,12 +264,12 @@ function remove() {
263
264
  Everything else lives in the config lightbox. -->
264
265
  <div
265
266
  class="relative w-[220px] rounded-lg border bg-white transition-all duration-150"
266
- @mouseenter="onHoverEnter"
267
- @mouseleave="onHoverLeave"
268
267
  :class="[
269
268
  selected ? 'border-emerald-500/50 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : invalidFields.length ? 'border-amber-500/70 shadow-[0_0_0_2px_rgba(245,158,11,0.22)] hover:shadow-md' : inSelectedLoop ? 'border-slate-400/80 shadow-[0_0_0_2px_rgba(100,116,139,0.20)]' : 'border-black/[0.08] shadow-sm hover:shadow-md hover:border-black/[0.16]',
270
269
  showPortLabels ? 'pb-3.5' : ''
271
270
  ]"
271
+ @mouseenter="onHoverEnter"
272
+ @mouseleave="onHoverLeave"
272
273
  >
273
274
  <!-- The card's marks, in one row at the corner the input handles leave free.
274
275
  A row rather than two absolutely-placed badges: the amber one's width
@@ -261,13 +261,6 @@ watch(() => vfNodes.value.map((n) => n.id).join("|"), () => {
261
261
  });
262
262
  const vfEdges = computed(() => {
263
263
  const blob = props.workflow.blob;
264
- const nodePos = (id) => {
265
- const wn = blob.nodes.find((n) => n.id === id);
266
- if (wn) return { x: wn.position?.x ?? 0, y: wn.position?.y ?? 0 };
267
- const ti = blob.triggers.findIndex((t2) => t2.id === id);
268
- if (ti >= 0) return blob.triggerPositions?.[id] ?? { x: -40, y: 60 + ti * 140 };
269
- return { x: 0, y: 0 };
270
- };
271
264
  const pickSourceSide = (_sourceId, _targetId) => "bottom";
272
265
  const edgeKind = (sourceNodeId, output) => {
273
266
  const wn = blob.nodes.find((n) => n.id === sourceNodeId);
@@ -64,12 +64,12 @@ const OUT_SIDES = ["bottom"];
64
64
 
65
65
  <div
66
66
  class="w-[200px] rounded-lg border-2 bg-gradient-to-br from-emerald-50 to-white transition-all duration-150"
67
- @mouseenter="onHoverEnter"
68
- @mouseleave="onHoverLeave"
69
67
  :class="[
70
68
  selected ? 'border-emerald-500 shadow-[0_0_0_2px_rgba(16,185,129,0.18)]' : 'border-emerald-400/40 shadow-sm hover:shadow-md hover:border-emerald-500/60',
71
69
  data.active === false ? 'opacity-60 grayscale-[0.4]' : ''
72
70
  ]"
71
+ @mouseenter="onHoverEnter"
72
+ @mouseleave="onHoverLeave"
73
73
  >
74
74
  <!-- Same type scale as WorkflowNodeView so trigger and node cards read
75
75
  identically (icon chip size-8/15, subtitle plain text-sm, mt-0.5). -->
@@ -18,7 +18,6 @@ import {
18
18
  SelectTrigger,
19
19
  SelectValue
20
20
  } from "@revenexx/studio";
21
- import { toast } from "vue-sonner";
22
21
  import { deepClone } from "../../utils/deepClone";
23
22
  import { copyToClipboard } from "../../utils/clipboard";
24
23
  import { languageKeyRead, languageKeyToWrite, resolveDisplayName } from "../../utils/localize";
@@ -28,7 +27,7 @@ const props = defineProps({
28
27
  });
29
28
  const emit = defineEmits(["update:open", "update:variables"]);
30
29
  const t = useIntegrationsT();
31
- const KEY_PATTERN = /^[a-z_][a-z0-9_]*$/i;
30
+ const KEY_PATTERN = /^[a-z_]\w*$/i;
32
31
  const TYPES = ["string", "number", "boolean", "object", "array"];
33
32
  const working = ref({});
34
33
  const jsonDrafts = reactive({});
@@ -213,7 +212,7 @@ async function copyUsage(key) {
213
212
  <SelectValue />
214
213
  </SelectTrigger>
215
214
  <SelectContent>
216
- <SelectItem v-for="t in TYPES" :key="t" :value="t">{{ t }}</SelectItem>
215
+ <SelectItem v-for="type in TYPES" :key="type" :value="type">{{ type }}</SelectItem>
217
216
  </SelectContent>
218
217
  </Select>
219
218
  </div>
@@ -1,11 +1,11 @@
1
1
  <script setup>
2
- import { MenuDots as MoreHorizontal } from "#solar-icons";
2
+ import { MenuDots as MoreHorizontal, Layers } from "#solar-icons";
3
3
  import { toast } from "vue-sonner";
4
4
  import { apiValidationIssues, errorReason } from "../../utils/apiError";
5
5
  import WorkflowDeleteDialog from "./WorkflowDeleteDialog.vue";
6
6
  import WorkflowDuplicateDialog from "./WorkflowDuplicateDialog.vue";
7
+ import WorkflowExportDialog from "./WorkflowExportDialog.vue";
7
8
  import WorkflowIconView from "./WorkflowIconView.vue";
8
- import { Layers } from "#solar-icons";
9
9
  import {
10
10
  Badge,
11
11
  Button,
@@ -35,6 +35,7 @@ async function onDuplicated(copy) {
35
35
  toast.success(t("integration.workflows.table.toast.duplicated", { name: copy.name }));
36
36
  await router.push(`/integrations/workflows/${copy.id}`);
37
37
  }
38
+ const exporter = ref(null);
38
39
  const GROUP_BY_TAGS_KEY = "integrations.workflows.groupByTags";
39
40
  const groupByTags = ref(false);
40
41
  try {
@@ -281,10 +282,10 @@ const columns = computed(() => [
281
282
  <span v-if="wf.description" class="text-sm text-muted-foreground truncate">{{ wf.description }}</span>
282
283
  <div v-if="wf.blob.tags?.length" class="mt-0.5 flex flex-wrap gap-1">
283
284
  <span
284
- v-for="t in wf.blob.tags"
285
- :key="t"
285
+ v-for="tag in wf.blob.tags"
286
+ :key="tag"
286
287
  class="rounded border border-black/[0.06] bg-muted/50 px-1 text-[10px] leading-4 text-muted-foreground"
287
- >{{ t }}</span>
288
+ >{{ tag }}</span>
288
289
  </div>
289
290
  </div>
290
291
  </div>
@@ -328,11 +329,11 @@ const columns = computed(() => [
328
329
  <template #cell-triggers="{ row: wf }">
329
330
  <div class="flex min-w-0 flex-col items-start gap-1">
330
331
  <span
331
- v-for="t in wf.blob.triggers"
332
- :key="t.id"
332
+ v-for="trigger in wf.blob.triggers"
333
+ :key="trigger.id"
333
334
  class="inline-flex items-center whitespace-nowrap rounded-md border border-black/[0.06] bg-muted/50 px-1.5 py-0.5 text-xs font-medium uppercase text-muted-foreground"
334
335
  >
335
- {{ t.type }}
336
+ {{ trigger.type }}
336
337
  </span>
337
338
  </div>
338
339
  </template>
@@ -385,6 +386,9 @@ const columns = computed(() => [
385
386
  <DropdownMenuItem @select="askDuplicate(wf)">
386
387
  {{ t("integration.workflows.action.duplicate") }}
387
388
  </DropdownMenuItem>
389
+ <DropdownMenuItem @select="exporter?.start(wf.id)">
390
+ {{ t("integration.workflowFile.action.export") }}
391
+ </DropdownMenuItem>
388
392
  <DropdownMenuItem @select="onToggleActive(wf)">
389
393
  {{ wf.active ? t("integration.workflows.table.menu.action.pause") : t("integration.workflows.table.menu.action.activate") }}
390
394
  </DropdownMenuItem>
@@ -404,4 +408,6 @@ const columns = computed(() => [
404
408
  <WorkflowDeleteDialog v-model:open="deleteOpen" :workflow="deleteTarget" />
405
409
 
406
410
  <WorkflowDuplicateDialog v-model:open="duplicateOpen" :workflow="duplicateTarget" @duplicated="onDuplicated" />
411
+
412
+ <WorkflowExportDialog ref="exporter" />
407
413
  </template>
@@ -30,7 +30,10 @@ export function useIntegrationsNodes() {
30
30
  // Registered and built are two different things: a version whose code
31
31
  // was never built can be drawn but not executed, so the palette must
32
32
  // be able to leave it out (PO-338). Missing field = assume built.
33
- __hasBundle: n.has_bundle !== false
33
+ __hasBundle: n.has_bundle !== false,
34
+ // The package's own order, which the palette falls back to inside a
35
+ // folder (PO-537). Null and absent both mean "not recorded".
36
+ __packagePosition: typeof n.package_position === "number" ? n.package_position : void 0
34
37
  }));
35
38
  applyNodeCatalog(manifests);
36
39
  loaded.value = true;
@@ -17,7 +17,9 @@ export declare function useIntegrationsWorkflows(): {
17
17
  activeCount: import("vue").ComputedRef<any>;
18
18
  load: (force?: boolean) => Promise<Workflow[]>;
19
19
  list: () => Promise<Workflow[]>;
20
- get: (id: number) => Promise<Workflow | null>;
20
+ get: (id: number, { strict }?: {
21
+ strict?: boolean;
22
+ }) => Promise<Workflow | null>;
21
23
  create: (input: {
22
24
  name: string;
23
25
  description?: string | null;
@@ -1,7 +1,7 @@
1
1
  import { computed } from "vue";
2
2
  import { useIntegrationsApi } from "./integrationsApi.js";
3
3
  import { useIntegrationsT } from "./useIntegrationsT.js";
4
- import { deepClone } from "../utils/deepClone.js";
4
+ import { assignTriggerHandles, pausedTriggers } from "../utils/workflowFile.js";
5
5
  import { errorReason } from "../utils/apiError.js";
6
6
  import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
7
7
  import { useLoadGate } from "./useLoadGate.js";
@@ -135,12 +135,13 @@ export function useIntegrationsWorkflows() {
135
135
  if (!loaded.value) await load();
136
136
  return workflows.value;
137
137
  }
138
- async function get(id) {
138
+ async function get(id, { strict = false } = {}) {
139
139
  try {
140
140
  const wf = ingest(unwrapRecord(await api.get(`/workflows/${id}`)));
141
141
  upsertCache(wf);
142
142
  return wf;
143
- } catch {
143
+ } catch (e) {
144
+ if (strict) throw e;
144
145
  return workflows.value.find((w) => w.id === id) ?? null;
145
146
  }
146
147
  }
@@ -158,25 +159,7 @@ export function useIntegrationsWorkflows() {
158
159
  async function duplicate(id, name) {
159
160
  const source = await get(id);
160
161
  if (!source) throw new Error(t("integration.workflows.error.notFound"));
161
- const blob = deepClone(source.blob);
162
- const handleMap = /* @__PURE__ */ new Map();
163
- for (const trigger of blob.triggers ?? []) {
164
- const fresh = crypto.randomUUID();
165
- handleMap.set(trigger.id, fresh);
166
- trigger.id = fresh;
167
- trigger.active = false;
168
- }
169
- for (const edge of blob.edges ?? []) {
170
- const mapped = handleMap.get(edge.from.nodeId);
171
- if (mapped) edge.from.nodeId = mapped;
172
- }
173
- if (blob.triggerPositions) {
174
- const rekeyed = {};
175
- for (const [handle, pos] of Object.entries(blob.triggerPositions)) {
176
- rekeyed[handleMap.get(handle) ?? handle] = pos;
177
- }
178
- blob.triggerPositions = rekeyed;
179
- }
162
+ const blob = pausedTriggers(assignTriggerHandles(source.blob, new Set((source.blob.triggers ?? []).map((tr) => tr.id))));
180
163
  const base = {
181
164
  description: source.description,
182
165
  blob,
@@ -0,0 +1,17 @@
1
+ import type { Ref } from 'vue';
2
+ /**
3
+ * The current time as a ref that moves every `intervalMs`, for the surfaces that
4
+ * print something measured against it — a running run's elapsed time, and how long
5
+ * ago a moment was (`relativeTime`).
6
+ *
7
+ * `Date.now()` read in a template is not reactive: it is re-read only when the
8
+ * component re-renders for some other reason. A realtime event re-renders the entry
9
+ * it changes and none of the others, so "12s ago" beside an untouched entry would
10
+ * stand still while the one beside it moved on. Passing this ref is what keeps them
11
+ * on one clock (PO-393).
12
+ *
13
+ * Three seconds is the rhythm the runs list already re-timed a running run at. The
14
+ * interval starts on mount and stops on unmount, so a surface that is not shown
15
+ * costs nothing.
16
+ */
17
+ export declare function useNow(intervalMs?: number): Ref<number>;
@@ -0,0 +1,16 @@
1
+ import { onMounted, onUnmounted, ref } from "vue";
2
+ export function useNow(intervalMs = 3e3) {
3
+ const now = ref(Date.now());
4
+ let tick = null;
5
+ onMounted(() => {
6
+ now.value = Date.now();
7
+ tick = setInterval(() => {
8
+ now.value = Date.now();
9
+ }, intervalMs);
10
+ });
11
+ onUnmounted(() => {
12
+ if (tick) clearInterval(tick);
13
+ tick = null;
14
+ });
15
+ return now;
16
+ }
@@ -0,0 +1,21 @@
1
+ import type { Workflow } from '../types/integrations.js';
2
+ import type { OverwritePlan, WorkflowFile } from '../utils/workflowFile.js';
3
+ /**
4
+ * Export and Import, wired to the tenant (specs/workflow-file.md).
5
+ *
6
+ * The rules are `utils/workflowFile`; this is what they need from the stores. Each
7
+ * of them is read fresh at the moment it matters rather than trusted from whatever
8
+ * a page happened to load: which settings are credentials comes from the node
9
+ * catalogue, which credentials are this tenant's from the credential list, and which
10
+ * trigger handles are taken from the workflows list — and a stale answer to any of
11
+ * the three is a credential in a file or a webhook address that silently changed.
12
+ */
13
+ export declare function useWorkflowFile(): {
14
+ fileFor: (id: number) => Promise<{
15
+ workflow: Workflow;
16
+ file: WorkflowFile;
17
+ }>;
18
+ download: (file: WorkflowFile) => void;
19
+ importAsNew: (file: WorkflowFile, name: string) => Promise<Workflow>;
20
+ planOverwrite: (target: Workflow, file: WorkflowFile) => Promise<OverwritePlan>;
21
+ };
@@ -0,0 +1,63 @@
1
+ import { INTEGRATION_NODE_CATALOG, findManifest } from "../data/integrationNodeCatalog.js";
2
+ import { useIntegrationsCredentials } from "./useIntegrationsCredentials.js";
3
+ import { useIntegrationsNodes } from "./useIntegrationsNodes.js";
4
+ import { useIntegrationsT } from "./useIntegrationsT.js";
5
+ import { useIntegrationsWorkflows } from "./useIntegrationsWorkflows.js";
6
+ import { blobForNewWorkflow, overwritePlan, toWorkflowFile, workflowFileName } from "../utils/workflowFile.js";
7
+ export function useWorkflowFile() {
8
+ const { workflows, error: workflowsError, get, load: loadWorkflows, create } = useIntegrationsWorkflows();
9
+ const { credentials, error: credentialsError, load: loadCredentials } = useIntegrationsCredentials();
10
+ const { loaded: nodesLoaded, load: loadNodes } = useIntegrationsNodes();
11
+ const t = useIntegrationsT();
12
+ async function nodeCatalogue() {
13
+ await loadNodes();
14
+ if (!nodesLoaded.value) throw new Error(t("integration.workflowFile.error.catalogue"));
15
+ }
16
+ const fieldsFor = (node) => {
17
+ const exact = findManifest(node.nodeSlug, node.nodeVersion);
18
+ if (exact && exact.version === node.nodeVersion) return exact.config ?? [];
19
+ const all = INTEGRATION_NODE_CATALOG.filter((m) => m.slug === node.nodeSlug);
20
+ return all.length ? all.flatMap((m) => m.config ?? []) : null;
21
+ };
22
+ async function tenantHandles(except) {
23
+ await loadWorkflows(true);
24
+ if (workflowsError.value) throw new Error(workflowsError.value);
25
+ return new Set(workflows.value.filter((wf) => wf.id !== except).flatMap((wf) => (wf.blob.triggers ?? []).map((tr) => tr.id)));
26
+ }
27
+ async function tenantCredentialIds() {
28
+ await loadCredentials(true);
29
+ if (credentialsError.value) throw new Error(credentialsError.value);
30
+ return new Set(credentials.value.map((c) => c.id));
31
+ }
32
+ async function fileFor(id) {
33
+ const [workflow, ids] = await Promise.all([get(id, { strict: true }), tenantCredentialIds(), nodeCatalogue()]);
34
+ if (!workflow) throw new Error(t("integration.workflowFile.export.error"));
35
+ return { workflow, file: toWorkflowFile(workflow, fieldsFor, ids) };
36
+ }
37
+ function download(file) {
38
+ const url = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: "application/json" }));
39
+ const a = document.createElement("a");
40
+ a.href = url;
41
+ a.download = workflowFileName(file.workflow.name);
42
+ document.body.append(a);
43
+ a.click();
44
+ a.remove();
45
+ setTimeout(() => URL.revokeObjectURL(url), 0);
46
+ }
47
+ async function importAsNew(file, name) {
48
+ const [taken, ids] = await Promise.all([tenantHandles(), tenantCredentialIds(), nodeCatalogue()]);
49
+ const blob = { ...blobForNewWorkflow(file, fieldsFor, taken, ids), name: name.trim() };
50
+ return await create({
51
+ name: name.trim(),
52
+ description: file.workflow.description,
53
+ blob,
54
+ active: false,
55
+ executionMode: file.workflow.executionMode
56
+ });
57
+ }
58
+ async function planOverwrite(target, file) {
59
+ const [taken, ids] = await Promise.all([tenantHandles(target.id), tenantCredentialIds(), nodeCatalogue()]);
60
+ return overwritePlan(target.blob, file, fieldsFor, taken, ids);
61
+ }
62
+ return { fileFor, download, importAsNew, planOverwrite };
63
+ }
@@ -8,8 +8,17 @@ export interface InlineNodeJSON {
8
8
  text?: string;
9
9
  attrs?: Record<string, unknown>;
10
10
  content?: InlineNodeJSON[];
11
+ marks?: {
12
+ type: string;
13
+ }[];
11
14
  }
12
15
  export declare const EXPR_TOKEN_NODE = "exprToken";
16
+ /**
17
+ * A pill the operator has opened to edit: its raw text, marked so it is not
18
+ * turned back into a pill while the caret is in it. Without the mark, the
19
+ * closing `}}` it already carries would re-pill it on the first keystroke.
20
+ */
21
+ export declare const EXPR_EDITING_MARK = "exprEditing";
13
22
  /** Raw text → inline content: text nodes with one atom pill per token. */
14
23
  export declare function textToInlineContent(text: string): InlineNodeJSON[];
15
24
  /**
@@ -29,6 +38,7 @@ export declare function textToDoc(text: string, { multiline }?: LineMode): Inlin
29
38
  * Multiple paragraphs are the lines of a multiline field; in a single-line
30
39
  * field they can only have arrived by paste, and collapse to single spaces. */
31
40
  export declare function docToText(doc: InlineNodeJSON | null | undefined, { multiline }?: LineMode): string;
32
- /** True when any TEXT node still carries a complete raw token (needs re-pilling). */
41
+ /** True when any TEXT node still carries a complete raw token (needs re-pilling).
42
+ * Text the operator is editing (`EXPR_EDITING_MARK`) is left alone. */
33
43
  export declare function hasRawTokenText(doc: InlineNodeJSON | null | undefined): boolean;
34
44
  export {};
@@ -1,5 +1,6 @@
1
1
  import { listExpressionTokens } from "./nodeRunData.js";
2
2
  export const EXPR_TOKEN_NODE = "exprToken";
3
+ export const EXPR_EDITING_MARK = "exprEditing";
3
4
  export function textToInlineContent(text) {
4
5
  const out = [];
5
6
  let cursor = 0;
@@ -39,7 +40,7 @@ export function hasRawTokenText(doc) {
39
40
  const stack = [doc];
40
41
  while (stack.length) {
41
42
  const node = stack.pop();
42
- if (node.type === "text" && listExpressionTokens(node.text ?? "").length > 0) return true;
43
+ if (node.type === "text" && !node.marks?.some((m) => m.type === EXPR_EDITING_MARK) && listExpressionTokens(node.text ?? "").length > 0) return true;
43
44
  for (const child of node.content ?? []) stack.push(child);
44
45
  }
45
46
  return false;
@@ -1,2 +1,16 @@
1
+ /**
2
+ * Node manifest icon → Lucide component.
3
+ *
4
+ * Authors declare icons as `mdi:*` names (the node SDK's convention), but the
5
+ * studio renders Lucide. The naive PascalCase lookup almost never hits a
6
+ * Lucide export, so every node degraded to the generic Box. This curated map
7
+ * covers the registry's actual vocabulary; unmapped names still try the
8
+ * PascalCase lookup before falling back to Box.
9
+ *
10
+ * Exported for its test, which checks every entry against Lucide's exports: a
11
+ * test that found the entries by reading this file's text stopped seeing every
12
+ * unquoted key the day the linter unquoted them.
13
+ */
14
+ export declare const MDI_TO_LUCIDE: Record<string, string>;
1
15
  /** Resolve a manifest icon name to a Lucide component (Box as last resort). */
2
16
  export declare function nodeIcon(name?: string | null): unknown;