@revenexx/studio-integrations 1.1.1 → 1.3.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 (119) hide show
  1. package/CHANGELOG.md +638 -0
  2. package/README.md +3 -1
  3. package/dist/module.json +1 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +6 -18
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +13 -23
  6. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +7 -6
  7. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +15 -4
  8. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
  9. package/dist/runtime/components/Integrations/ExpressionInput.vue +45 -19
  10. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/GroupFrameView.vue +5 -4
  12. package/dist/runtime/components/Integrations/LoadingAnnouncement.d.vue.ts +38 -0
  13. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue +16 -0
  14. package/dist/runtime/components/Integrations/LoadingAnnouncement.vue.d.ts +38 -0
  15. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -2
  16. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +5 -1
  17. package/dist/runtime/components/Integrations/NodeConfigField.vue +88 -1
  18. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +5 -1
  19. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +7 -3
  20. package/dist/runtime/components/Integrations/NodeInspector.vue +125 -54
  21. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +7 -3
  22. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.d.vue.ts +21 -0
  23. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue +133 -0
  24. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue.d.ts +21 -0
  25. package/dist/runtime/components/Integrations/RunActionsMenu.vue +2 -1
  26. package/dist/runtime/components/Integrations/RunDetailPanel.vue +5 -17
  27. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +30 -8
  28. package/dist/runtime/components/Integrations/RunResponseModal.vue +92 -24
  29. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +30 -8
  30. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +61 -3
  31. package/dist/runtime/components/Integrations/RunsTable.vue +129 -18
  32. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +61 -3
  33. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +10 -1
  34. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.d.vue.ts +16 -0
  35. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +111 -0
  36. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue.d.ts +16 -0
  37. package/dist/runtime/components/Integrations/StickyNoteView.vue +1 -1
  38. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +18 -3
  39. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.d.vue.ts +18 -0
  40. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue +70 -0
  41. package/dist/runtime/components/Integrations/WorkflowDeleteDialog.vue.d.ts +18 -0
  42. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  43. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +4 -4
  44. package/dist/runtime/components/Integrations/WorkflowEditor.vue +78 -30
  45. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +17 -6
  46. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +17 -21
  47. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +8 -11
  48. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.d.vue.ts +13 -0
  49. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +216 -0
  50. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue.d.ts +13 -0
  51. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +6 -7
  52. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +7 -7
  53. package/dist/runtime/components/Integrations/WorkflowsTable.vue +10 -48
  54. package/dist/runtime/components/Integrations/canvasSides.d.ts +48 -0
  55. package/dist/runtime/components/Integrations/canvasSides.js +22 -0
  56. package/dist/runtime/composables/useCredentialTypes.js +2 -6
  57. package/dist/runtime/composables/useIntegrationsAudit.js +2 -2
  58. package/dist/runtime/composables/useIntegrationsCredentials.js +4 -8
  59. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +4 -0
  60. package/dist/runtime/composables/useIntegrationsDeadLetters.js +16 -1
  61. package/dist/runtime/composables/useIntegrationsNodes.js +2 -2
  62. package/dist/runtime/composables/useIntegrationsRevisions.js +2 -6
  63. package/dist/runtime/composables/useIntegrationsRuns.d.ts +4 -15
  64. package/dist/runtime/composables/useIntegrationsRuns.js +49 -34
  65. package/dist/runtime/composables/useIntegrationsSchedules.js +2 -2
  66. package/dist/runtime/composables/useIntegrationsSecrets.js +2 -6
  67. package/dist/runtime/composables/useIntegrationsTemplates.js +7 -5
  68. package/dist/runtime/composables/useIntegrationsTriggers.js +2 -2
  69. package/dist/runtime/composables/useIntegrationsWorkflows.js +10 -7
  70. package/dist/runtime/composables/useNodeConfigResolve.js +2 -2
  71. package/dist/runtime/composables/useOAuthConnect.d.ts +42 -0
  72. package/dist/runtime/composables/useOAuthConnect.js +33 -0
  73. package/dist/runtime/data/expressionEditor.d.ts +17 -5
  74. package/dist/runtime/data/expressionEditor.js +11 -5
  75. package/dist/runtime/data/handleId.d.ts +61 -1
  76. package/dist/runtime/data/handleId.js +8 -1
  77. package/dist/runtime/data/integrationNodeCatalog.d.ts +25 -0
  78. package/dist/runtime/data/integrationNodeCatalog.js +8 -0
  79. package/dist/runtime/data/nodeVersionMove.d.ts +72 -0
  80. package/dist/runtime/data/nodeVersionMove.js +65 -0
  81. package/dist/runtime/data/payloadForm.js +3 -2
  82. package/dist/runtime/i18n/messages/de.json +79 -12
  83. package/dist/runtime/i18n/messages/en.json +79 -12
  84. package/dist/runtime/pages/integrations/audit.vue +7 -6
  85. package/dist/runtime/pages/integrations/credentials.vue +9 -21
  86. package/dist/runtime/pages/integrations/index.vue +55 -8
  87. package/dist/runtime/pages/integrations/runs.vue +116 -7
  88. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  89. package/dist/runtime/pages/integrations/templates/[slug].vue +2 -1
  90. package/dist/runtime/pages/integrations/templates/index.vue +18 -15
  91. package/dist/runtime/pages/integrations/workflows/[id].vue +169 -24
  92. package/dist/runtime/pages/integrations/workflows/index.vue +23 -4
  93. package/dist/runtime/types/integrations.d.ts +49 -1
  94. package/dist/runtime/utils/apiError.d.ts +21 -1
  95. package/dist/runtime/utils/apiError.js +1 -1
  96. package/dist/runtime/utils/buildSettle.d.ts +68 -0
  97. package/dist/runtime/utils/buildSettle.js +29 -0
  98. package/dist/runtime/utils/clipboard.d.ts +33 -0
  99. package/dist/runtime/utils/clipboard.js +11 -0
  100. package/dist/runtime/utils/deepClone.d.ts +41 -0
  101. package/dist/runtime/utils/deepClone.js +3 -0
  102. package/dist/runtime/utils/formatDuration.d.ts +44 -0
  103. package/dist/runtime/utils/formatDuration.js +13 -0
  104. package/dist/runtime/utils/nodeDisplayName.d.ts +22 -0
  105. package/dist/runtime/utils/nodeDisplayName.js +6 -0
  106. package/dist/runtime/utils/notify.d.ts +1 -1
  107. package/dist/runtime/utils/runEnvelope.d.ts +31 -0
  108. package/dist/runtime/utils/runEnvelope.js +12 -0
  109. package/dist/runtime/utils/runFeedQuery.d.ts +135 -0
  110. package/dist/runtime/utils/runFeedQuery.js +66 -0
  111. package/dist/runtime/utils/runOutputs.d.ts +33 -0
  112. package/dist/runtime/utils/runOutputs.js +10 -0
  113. package/dist/runtime/utils/settingCondition.d.ts +48 -0
  114. package/dist/runtime/utils/settingCondition.js +84 -0
  115. package/dist/runtime/utils/stateNamespaceName.d.ts +23 -0
  116. package/dist/runtime/utils/stateNamespaceName.js +8 -0
  117. package/dist/runtime/utils/validationFindings.d.ts +26 -3
  118. package/dist/runtime/utils/validationFindings.js +18 -3
  119. package/package.json +1 -1
@@ -26,10 +26,11 @@ const props = defineProps({
26
26
  siblingFields: { type: Array, required: false },
27
27
  depth: { type: Number, required: false },
28
28
  variables: { type: Object, required: false },
29
+ stateNamespaces: { type: Array, required: false },
29
30
  upstreamOutputs: { type: Array, required: false },
30
31
  refused: { type: String, required: false }
31
32
  });
32
- const emit = defineEmits(["set"]);
33
+ const emit = defineEmits(["set", "declare-state"]);
33
34
  const t = useIntegrationsT();
34
35
  const value = computed(() => props.config[props.field.key]);
35
36
  function write(v) {
@@ -127,6 +128,30 @@ function onCredentialSelect(v) {
127
128
  }
128
129
  write(v);
129
130
  }
131
+ const stateOptions = computed(() => {
132
+ const role = props.field.stateRole;
133
+ const declared = props.stateNamespaces ?? [];
134
+ const list = role ? declared.filter((n) => n.role === role) : declared;
135
+ const selected = value.value;
136
+ if (typeof selected === "string" && selected && !list.some((n) => n.namespace === selected)) {
137
+ return [{ namespace: selected, role: role ?? "mapping", visibility: void 0 }, ...list];
138
+ }
139
+ return list;
140
+ });
141
+ const stateTakenNames = computed(() => (props.stateNamespaces ?? []).map((n) => n.namespace));
142
+ const stateCreateOpen = ref(false);
143
+ const STATE_ACTION_NEW = "__action_new_state_namespace__";
144
+ function onStateSelect(v) {
145
+ if (v === STATE_ACTION_NEW) {
146
+ stateCreateOpen.value = true;
147
+ return;
148
+ }
149
+ write(v);
150
+ }
151
+ function onStateDeclared(namespace, role, visibility) {
152
+ emit("declare-state", namespace, role, visibility);
153
+ write(namespace);
154
+ }
130
155
  function acceptsSftp(field) {
131
156
  const t2 = field.credentialType;
132
157
  return t2 === "revenexx:sftp" || Array.isArray(t2) && t2.includes("revenexx:sftp");
@@ -195,6 +220,9 @@ function removeKvRow(idx) {
195
220
  kvRows.value = kvRows.value.filter((_, i) => i !== idx);
196
221
  commitKvRows();
197
222
  }
223
+ const isMultiline = computed(
224
+ () => Boolean(props.field.multiline) && (props.field.type === "string" || props.field.type === "expression")
225
+ );
198
226
  const isQueryField = computed(
199
227
  () => props.field.type === "array" && props.field.items?.type !== "object" && /(^|__)queries$/.test(props.field.key)
200
228
  );
@@ -383,6 +411,7 @@ function setArrayItemField(index, fieldKey, v) {
383
411
  :upstream-outputs="upstreamOutputs"
384
412
  :invalid="invalid"
385
413
  :label="resolveLocalized(field.label)"
414
+ :multiline="isMultiline"
386
415
  @update:model-value="(v) => write(v)"
387
416
  />
388
417
 
@@ -409,6 +438,25 @@ function setArrayItemField(index, fieldKey, v) {
409
438
  :upstream-outputs="upstreamOutputs"
410
439
  :invalid="invalid"
411
440
  :label="resolveLocalized(field.label)"
441
+ :multiline="isMultiline"
442
+ @update:model-value="(v) => write(v)"
443
+ />
444
+
445
+ <!-- plain string the node says takes several lines. Same accessible name as
446
+ the one-line box below, because it is the same setting. Opens at four
447
+ rows, grows with what is typed and scrolls past ten — the same rule the
448
+ expression editor follows, so the two controls for one kind of setting
449
+ do not behave differently. The two heights are lines × 1.25rem plus the
450
+ 1rem of vertical padding; `rows` alone would not set them, because the
451
+ shared control sizes itself to its content and ignores it. -->
452
+ <Textarea
453
+ v-else-if="field.type === 'string' && isMultiline"
454
+ :aria-label="resolveLocalized(field.label)"
455
+ :model-value="value ?? ''"
456
+ :placeholder="resolveLocalized(field.placeholder)"
457
+ :rows="4"
458
+ class="min-h-24 max-h-[13.5rem] resize-none rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
459
+ :class="invalid ? 'border-rose-400 focus-visible:ring-rose-400/40' : ''"
412
460
  @update:model-value="(v) => write(v)"
413
461
  />
414
462
 
@@ -596,6 +644,45 @@ function setArrayItemField(index, fieldKey, v) {
596
644
  />
597
645
  </template>
598
646
 
647
+ <!-- state-ref — pick one of the workflow's declared namespaces of this
648
+ field's role. Declaring lives INSIDE the picker, like creating a
649
+ credential does: a picker whose list is empty must not be a dead end
650
+ that sends the author to another dialog and back. -->
651
+ <template v-else-if="field.type === 'state-ref'">
652
+ <Select
653
+ :model-value="value ?? ''"
654
+ @update:model-value="(v) => onStateSelect(v)"
655
+ >
656
+ <SelectTrigger :aria-label="resolveLocalized(field.label)" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
657
+ <SelectValue :placeholder="t('integration.nodeConfig.state.placeholder')" />
658
+ </SelectTrigger>
659
+ <SelectContent>
660
+ <SelectItem v-for="o in stateOptions" :key="o.namespace" :value="o.namespace">
661
+ <span class="flex items-center gap-1.5">
662
+ <span class="min-w-0 truncate font-mono">{{ o.namespace }}</span>
663
+ <span
664
+ v-if="o.visibility === 'shared'"
665
+ class="shrink-0 rounded-full border px-1.5 py-px text-[10px] font-medium text-muted-foreground"
666
+ >{{ t("integration.workflows.state.visibility.shared.label") }}</span>
667
+ </span>
668
+ </SelectItem>
669
+ <div v-if="!stateOptions.length" class="px-2 py-1.5 text-sm text-muted-foreground">
670
+ {{ t("integration.nodeConfig.state.empty") }}
671
+ </div>
672
+ <SelectSeparator />
673
+ <SelectItem :value="STATE_ACTION_NEW" class="font-medium text-violet-600 data-[highlighted]:text-violet-700">
674
+ {{ t("integration.nodeConfig.state.action.new") }}
675
+ </SelectItem>
676
+ </SelectContent>
677
+ </Select>
678
+ <IntegrationsStateNamespaceCreateDialog
679
+ v-model:open="stateCreateOpen"
680
+ :role="field.stateRole ?? 'mapping'"
681
+ :taken="stateTakenNames"
682
+ @declared="onStateDeclared"
683
+ />
684
+ </template>
685
+
599
686
  <!-- array of objects (e.g. headers / mappings) -->
600
687
  <div v-else-if="field.type === 'array' && field.items?.type === 'object'" class="space-y-2">
601
688
  <div
@@ -1,4 +1,4 @@
1
- import type { ConfigField, WorkflowVariable } from '../../types/integrations.js';
1
+ import type { ConfigField, WorkflowStateNamespace, WorkflowStateRole, WorkflowStateVisibility, WorkflowVariable } from '../../types/integrations.js';
2
2
  type __VLS_Props = {
3
3
  field: ConfigField;
4
4
  config: Record<string, unknown>;
@@ -11,6 +11,8 @@ type __VLS_Props = {
11
11
  siblingFields?: ConfigField[];
12
12
  depth?: number;
13
13
  variables?: Record<string, WorkflowVariable>;
14
+ /** The workflow's declared state namespaces — the options a `state-ref` offers. */
15
+ stateNamespaces?: WorkflowStateNamespace[];
14
16
  upstreamOutputs?: Array<{
15
17
  nodeId: string;
16
18
  nodeName?: string;
@@ -22,7 +24,9 @@ type __VLS_Props = {
22
24
  };
23
25
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
24
26
  set: (key: string, value: unknown) => any;
27
+ "declare-state": (namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any;
25
28
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
26
29
  onSet?: ((key: string, value: unknown) => any) | undefined;
30
+ "onDeclare-state"?: ((namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any) | undefined;
27
31
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
28
32
  export default _default;
@@ -1,9 +1,11 @@
1
- import type { NodeManifestOutput, WorkflowEdge, WorkflowNode, WorkflowTrigger, WorkflowVariable } from '../../types/integrations.js';
1
+ import type { NodeManifestOutput, WorkflowEdge, WorkflowNode, WorkflowStateNamespace, WorkflowStateRole, WorkflowStateVisibility, WorkflowTrigger, WorkflowVariable } from '../../types/integrations.js';
2
2
  type __VLS_Props = {
3
3
  /** Either a workflow node OR a trigger. Distinguished by presence of `nodeSlug`. */
4
4
  selection: WorkflowNode | WorkflowTrigger | null;
5
5
  secretKeys: string[];
6
6
  variables?: Record<string, WorkflowVariable>;
7
+ /** The workflow's declared state namespaces — options for `state-ref` fields. */
8
+ stateNamespaces?: WorkflowStateNamespace[];
7
9
  upstreamOutputs?: Array<{
8
10
  nodeId: string;
9
11
  nodeName?: string;
@@ -29,13 +31,15 @@ type __VLS_Props = {
29
31
  };
30
32
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
31
33
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
- close: () => any;
33
34
  remove: () => any;
35
+ close: () => any;
36
+ "declare-state": (namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any;
34
37
  navigate: (id: string) => any;
35
38
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
36
39
  onUpdate?: ((next: WorkflowTrigger | WorkflowNode) => any) | undefined;
37
- onClose?: (() => any) | undefined;
38
40
  onRemove?: (() => any) | undefined;
41
+ onClose?: (() => any) | undefined;
42
+ "onDeclare-state"?: ((namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any) | undefined;
39
43
  onNavigate?: ((id: string) => any) | undefined;
40
44
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
41
45
  export default _default;
@@ -28,7 +28,8 @@ import {
28
28
  ToggleGroupItem
29
29
  } from "@revenexx/studio";
30
30
  import EventTypeCombobox from "./EventTypeCombobox.vue";
31
- import { findManifest, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion } from "../../data/integrationNodeCatalog";
31
+ import { findManifest, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion, executableVersionsOf, newerExecutableVersions } from "../../data/integrationNodeCatalog";
32
+ import NodeVersionMoveDialog from "./NodeVersionMoveDialog.vue";
32
33
  import {
33
34
  collectUpstreamNodes,
34
35
  inputPortsForManifest,
@@ -39,6 +40,9 @@ import {
39
40
  } from "../../data/nodeInputData";
40
41
  import { portDotClass } from "../../data/portTone";
41
42
  import { computeLoopBodies, manifestIterates } from "../../data/loopBody";
43
+ import { applyingFields, staleKeys } from "../../utils/settingCondition";
44
+ import { deepClone } from "../../utils/deepClone";
45
+ import { copyToClipboard } from "../../utils/clipboard";
42
46
  import { nodeDisplayLabel } from "../../utils/validationFindings";
43
47
  import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
44
48
  import {
@@ -49,11 +53,13 @@ import {
49
53
  } from "../../data/nodeRunData";
50
54
  import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
51
55
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
56
+ import { errorReason } from "../../utils/apiError";
52
57
  const t = useIntegrationsT();
53
58
  const props = defineProps({
54
59
  selection: { type: [Object, null], required: true },
55
60
  secretKeys: { type: Array, required: true },
56
61
  variables: { type: Object, required: false },
62
+ stateNamespaces: { type: Array, required: false },
57
63
  upstreamOutputs: { type: Array, required: false },
58
64
  autofocusName: { type: Boolean, required: false },
59
65
  nodes: { type: Array, required: false },
@@ -63,20 +69,35 @@ const props = defineProps({
63
69
  workflowId: { type: [Number, String], required: false },
64
70
  refusedFields: { type: Object, required: false }
65
71
  });
66
- const emit = defineEmits(["update", "close", "remove", "navigate"]);
67
- function clone(v) {
68
- return JSON.parse(JSON.stringify(v));
69
- }
70
- const draft = ref(clone(props.selection));
72
+ const emit = defineEmits(["update", "close", "remove", "navigate", "declare-state"]);
73
+ const draft = ref(deepClone(props.selection));
71
74
  watch(() => props.selection?.id, () => {
72
- if (props.selection && props.selection.id !== draft.value.id) draft.value = clone(props.selection);
75
+ if (props.selection && props.selection.id !== draft.value.id) {
76
+ draft.value = deepClone(props.selection);
77
+ pendingState.value = [];
78
+ }
73
79
  });
74
80
  const isNode = computed(() => "nodeSlug" in draft.value);
75
81
  const draftNode = computed(() => isNode.value ? draft.value : null);
76
82
  const draftTrigger = computed(() => isNode.value ? null : draft.value);
77
- const dirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(props.selection));
83
+ const pendingState = ref([]);
84
+ const offeredStateNamespaces = computed(() => [
85
+ ...props.stateNamespaces ?? [],
86
+ ...pendingState.value
87
+ ]);
88
+ function stageStateNamespace(namespace, role, visibility) {
89
+ if (offeredStateNamespaces.value.some((n) => n.namespace === namespace)) return;
90
+ pendingState.value = [...pendingState.value, { namespace, role, visibility }];
91
+ }
92
+ const draftChanged = computed(() => JSON.stringify(draft.value) !== JSON.stringify(props.selection));
93
+ const dirty = computed(() => draftChanged.value || pendingState.value.length > 0);
94
+ function commit() {
95
+ for (const ns of pendingState.value) emit("declare-state", ns.namespace, ns.role, ns.visibility);
96
+ pendingState.value = [];
97
+ if (draftChanged.value) emit("update", deepClone(draft.value));
98
+ }
78
99
  function save() {
79
- if (dirty.value) emit("update", clone(draft.value));
100
+ commit();
80
101
  emit("close");
81
102
  }
82
103
  const nameInput = ref(null);
@@ -99,6 +120,21 @@ const readyVersion = computed(() => {
99
120
  const wn = draftNode.value;
100
121
  return wn ? newestExecutableVersion(wn.nodeSlug) : null;
101
122
  });
123
+ const versionMoveOpen = ref(false);
124
+ const newerVersions = computed(() => {
125
+ const wn = draftNode.value;
126
+ return wn ? newerExecutableVersions(wn.nodeSlug, wn.nodeVersion) : [];
127
+ });
128
+ const canMoveVersion = computed(() => {
129
+ const wn = draftNode.value;
130
+ if (!wn) return false;
131
+ const runnable = executableVersionsOf(wn.nodeSlug);
132
+ if (!runnable.length) return false;
133
+ return runnable.length > 1 || runnable[0]?.version !== wn.nodeVersion;
134
+ });
135
+ function applyVersionMove(payload) {
136
+ patchNode({ nodeVersion: payload.version, config: payload.config });
137
+ }
102
138
  const TRIGGER_LABELS = computed(() => ({
103
139
  manual: t("integration.nodeInspector.trigger.type.manual"),
104
140
  schedule: t("integration.nodeInspector.trigger.type.schedule"),
@@ -150,8 +186,11 @@ watch(showSettingsTab, (hasThem) => {
150
186
  if (!hasThem) tab.value = "parameters";
151
187
  });
152
188
  const SETTINGS_FIELD_KEY = /(^|_)(timeout|retry|retries|backoff)|(timeout|retry|retries|backoff)(ms|sec|seconds|attempts)?$/i;
153
- const parameterFields = computed(() => (manifest.value?.config ?? []).filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
154
- const settingsFields = computed(() => (manifest.value?.config ?? []).filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
189
+ const applyingConfig = computed(
190
+ () => applyingFields(manifest.value?.config ?? [], draftNode.value?.config ?? {})
191
+ );
192
+ const parameterFields = computed(() => applyingConfig.value.filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
193
+ const settingsFields = computed(() => applyingConfig.value.filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
155
194
  const requiredFields = computed(() => parameterFields.value.filter((f) => f.required));
156
195
  const optionalFields = computed(() => parameterFields.value.filter((f) => !f.required));
157
196
  function optionalShouldOpen() {
@@ -162,27 +201,14 @@ const optionalOpen = ref(optionalShouldOpen());
162
201
  watch([() => props.selection?.id, manifest], () => {
163
202
  optionalOpen.value = optionalShouldOpen();
164
203
  });
165
- function dependentKeys(changedKey) {
166
- const fields = manifest.value?.config ?? [];
167
- const out = /* @__PURE__ */ new Set();
168
- const queue = [changedKey];
169
- while (queue.length > 0) {
170
- const dep = queue.shift();
171
- for (const f of fields) {
172
- if ((f.dependsOn ?? []).includes(dep) && !out.has(f.key)) {
173
- out.add(f.key);
174
- queue.push(f.key);
175
- }
176
- }
177
- }
178
- out.delete(changedKey);
179
- return out;
204
+ function dependentKeys(changedKey, after) {
205
+ return staleKeys(manifest.value?.config ?? [], changedKey, after);
180
206
  }
181
207
  function writeConfig(key, value) {
182
208
  const wn = draftNode.value;
183
209
  if (!wn) return;
184
210
  const next = { ...wn.config, [key]: value };
185
- const cleared = dependentKeys(key);
211
+ const cleared = dependentKeys(key, next);
186
212
  for (const k of cleared) delete next[k];
187
213
  const clearedDynamicSchema = [...cleared].some(
188
214
  (k) => (manifest.value?.config ?? []).some((f) => f.key === k && f.type === "dynamic-schema")
@@ -203,12 +229,10 @@ function patchTrigger(patch) {
203
229
  }
204
230
  async function copyConfig() {
205
231
  if (!draftNode.value) return;
206
- try {
207
- await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
208
- toast.success(t("integration.nodeInspector.toast.configCopied"));
209
- } catch {
210
- toast.error(t("integration.nodeInspector.toast.configCopyFailed"));
211
- }
232
+ await copyToClipboard(JSON.stringify(draftNode.value.config ?? {}, null, 2), {
233
+ copied: t("integration.nodeInspector.toast.configCopied"),
234
+ failed: t("integration.nodeInspector.toast.configCopyFailed")
235
+ });
212
236
  }
213
237
  async function pasteConfig() {
214
238
  if (!draftNode.value) return;
@@ -440,28 +464,24 @@ async function rotateEndpointSecret() {
440
464
  toast.error(t("integration.nodeInspector.toast.rotateNoSecret"));
441
465
  }
442
466
  } catch (e) {
443
- toast.error(e.message || t("integration.nodeInspector.toast.rotateFailed"));
467
+ toast.error(errorReason(e, t("integration.nodeInspector.toast.rotateFailed")));
444
468
  } finally {
445
469
  rotateBusy.value = false;
446
470
  }
447
471
  }
448
472
  async function copySecret() {
449
473
  if (!rotatedSecret.value) return;
450
- try {
451
- await navigator.clipboard.writeText(rotatedSecret.value);
452
- toast.success(t("integration.nodeInspector.toast.secretCopied"));
453
- } catch {
454
- toast.error(t("integration.nodeInspector.toast.secretCopyFailed"));
455
- }
474
+ await copyToClipboard(rotatedSecret.value, {
475
+ copied: t("integration.nodeInspector.toast.secretCopied"),
476
+ failed: t("integration.nodeInspector.toast.secretCopyFailed")
477
+ });
456
478
  }
457
479
  async function copyWebhookUrl() {
458
480
  if (!webhookUrl.value) return;
459
- try {
460
- await navigator.clipboard.writeText(webhookUrl.value);
461
- toast.success(t("integration.nodeInspector.toast.webhookUrlCopied"));
462
- } catch {
463
- toast.error(t("integration.nodeInspector.toast.webhookUrlCopyFailed"));
464
- }
481
+ await copyToClipboard(webhookUrl.value, {
482
+ copied: t("integration.nodeInspector.toast.webhookUrlCopied"),
483
+ failed: t("integration.nodeInspector.toast.webhookUrlCopyFailed")
484
+ });
465
485
  }
466
486
  const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
467
487
  } };
@@ -526,7 +546,7 @@ async function loadRunData() {
526
546
  runData.value = map;
527
547
  runInfo.value = { id: source.id, startedAt: source.startedAt };
528
548
  } catch (e) {
529
- runError.value = e?.message ?? t("integration.nodeInspector.input.runData.loadFailed");
549
+ runError.value = errorReason(e, t("integration.nodeInspector.input.runData.loadFailed"));
530
550
  } finally {
531
551
  runLoading.value = false;
532
552
  }
@@ -640,11 +660,10 @@ function insertExpression(expression) {
640
660
  el.focus();
641
661
  return;
642
662
  }
643
- try {
644
- void navigator.clipboard?.writeText(expression);
645
- } catch {
646
- }
647
- toast(t("integration.nodeInspector.toast.expressionCopied"));
663
+ void copyToClipboard(expression, {
664
+ copied: t("integration.nodeInspector.toast.expressionCopied"),
665
+ failed: t("integration.nodeInspector.toast.expressionCopyFailed")
666
+ });
648
667
  }
649
668
  const chipClass = "max-w-full cursor-grab truncate rounded-md border border-black/[0.06] bg-slate-50 px-1.5 py-0.5 font-mono text-[11px] text-slate-700 transition hover:border-black/[0.12] hover:bg-slate-100 active:cursor-grabbing";
650
669
  const rowClass = "flex w-full min-w-0 cursor-grab items-center gap-1.5 rounded-md px-1.5 py-[3px] text-left font-mono text-[11px] text-slate-700 transition hover:bg-slate-100 active:cursor-grabbing";
@@ -767,7 +786,7 @@ const hasNext = computed(() => seqIndex.value >= 0 && seqIndex.value < sequence.
767
786
  function navigate(offset) {
768
787
  const target = sequence.value[seqIndex.value + offset];
769
788
  if (!target) return;
770
- if (dirty.value) emit("update", clone(draft.value));
789
+ commit();
771
790
  emit("navigate", target);
772
791
  }
773
792
  const outputView = ref("schema");
@@ -873,7 +892,16 @@ async function runTestStep() {
873
892
  try {
874
893
  const res = await api.post(
875
894
  `/nodes/${encodeURIComponent(wn.nodeSlug)}/${encodeURIComponent(wn.nodeVersion)}/execute:test`,
876
- { config: wn.config ?? {}, inputs }
895
+ {
896
+ config: wn.config ?? {},
897
+ inputs,
898
+ // Names the workflow being edited, which is what lets the test run READ
899
+ // the state namespaces this workflow declares (PO-374). Writes stay
900
+ // refused: a correlation created by a test click would be
901
+ // indistinguishable from one a real run made. Omitted for an unsaved
902
+ // workflow, which has nothing declared yet anyway.
903
+ ...props.workflowId ? { workflow_id: Number(props.workflowId) } : {}
904
+ }
877
905
  );
878
906
  testLogs.value = res.logs ?? [];
879
907
  testOutputs.value = {
@@ -966,6 +994,26 @@ async function runTestStep() {
966
994
  </PopoverContent>
967
995
  </Popover>
968
996
  </div>
997
+ <!-- The version, once it is something the author can change (PO-339).
998
+ Deliberately a line of its own and deliberately grey: where a newer
999
+ version merely exists, nothing is wrong, and a coloured mark for a
1000
+ non-problem is what teaches people to stop reading marks. It is
1001
+ absent altogether for a node with one runnable version — there is no
1002
+ choice to offer — and for a node whose pinned version cannot run,
1003
+ where the notice below the tabs carries the same route in the louder
1004
+ tone that state has earned. -->
1005
+ <div v-if="isNode && canMoveVersion && versionExecutable" class="flex items-center gap-1 text-xs text-muted-foreground">
1006
+ <span v-if="newerVersions.length" class="tabular-nums">
1007
+ {{ t("integration.nodeInspector.version.available", { version: newerVersions[0]?.version ?? "" }) }}
1008
+ </span>
1009
+ <button
1010
+ type="button"
1011
+ class="rounded-sm underline decoration-dotted underline-offset-2 hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
1012
+ @click="versionMoveOpen = true"
1013
+ >
1014
+ {{ t("integration.nodeInspector.version.change") }}
1015
+ </button>
1016
+ </div>
969
1017
  </div>
970
1018
  <span class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-600">
971
1019
  {{ groupLabel }}
@@ -1305,6 +1353,18 @@ async function runTestStep() {
1305
1353
  class="mx-4 mb-1 shrink-0 rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-sm text-rose-700"
1306
1354
  >
1307
1355
  {{ readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1356
+ <!-- The sentence named a version and led nowhere (PO-339). It leads
1357
+ here now; where the node has no runnable version at all there is
1358
+ still nothing to offer, and the notice stays the statement it
1359
+ was. -->
1360
+ <button
1361
+ v-if="readyVersion && canMoveVersion"
1362
+ type="button"
1363
+ class="ml-1 rounded-sm font-medium underline underline-offset-2 hover:text-rose-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-rose-400"
1364
+ @click="versionMoveOpen = true"
1365
+ >
1366
+ {{ t("integration.nodeInspector.version.moveTo", { version: readyVersion }) }}
1367
+ </button>
1308
1368
  </p>
1309
1369
 
1310
1370
  <div
@@ -1757,6 +1817,8 @@ async function runTestStep() {
1757
1817
  :sibling-fields="manifest.config ?? []"
1758
1818
  :depth="0"
1759
1819
  :variables="variables"
1820
+ :state-namespaces="offeredStateNamespaces"
1821
+ @declare-state="stageStateNamespace"
1760
1822
  :upstream-outputs="upstreamOutputs"
1761
1823
  :refused="refusedFields?.[field.key]"
1762
1824
  @set="(k, v) => writeConfig(k, v)"
@@ -1797,6 +1859,8 @@ async function runTestStep() {
1797
1859
  :sibling-fields="manifest.config ?? []"
1798
1860
  :depth="0"
1799
1861
  :variables="variables"
1862
+ :state-namespaces="offeredStateNamespaces"
1863
+ @declare-state="stageStateNamespace"
1800
1864
  :upstream-outputs="upstreamOutputs"
1801
1865
  :refused="refusedFields?.[field.key]"
1802
1866
  @set="(k, v) => writeConfig(k, v)"
@@ -1839,6 +1903,8 @@ async function runTestStep() {
1839
1903
  :sibling-fields="manifest?.config ?? []"
1840
1904
  :depth="0"
1841
1905
  :variables="variables"
1906
+ :state-namespaces="offeredStateNamespaces"
1907
+ @declare-state="stageStateNamespace"
1842
1908
  :upstream-outputs="upstreamOutputs"
1843
1909
  :refused="refusedFields?.[field.key]"
1844
1910
  @set="(k, v) => writeConfig(k, v)"
@@ -2128,6 +2194,11 @@ async function runTestStep() {
2128
2194
  </Button>
2129
2195
  </div>
2130
2196
  </div>
2197
+
2198
+ <!-- Asked outside the panes so it is not clipped by either, and mounted
2199
+ unconditionally: the two places that open it sit in different parts of
2200
+ the dialog and neither owns it. -->
2201
+ <NodeVersionMoveDialog v-model:open="versionMoveOpen" :node="draftNode" @move="applyVersionMove" />
2131
2202
  </div>
2132
2203
  </template>
2133
2204
 
@@ -1,9 +1,11 @@
1
- import type { NodeManifestOutput, WorkflowEdge, WorkflowNode, WorkflowTrigger, WorkflowVariable } from '../../types/integrations.js';
1
+ import type { NodeManifestOutput, WorkflowEdge, WorkflowNode, WorkflowStateNamespace, WorkflowStateRole, WorkflowStateVisibility, WorkflowTrigger, WorkflowVariable } from '../../types/integrations.js';
2
2
  type __VLS_Props = {
3
3
  /** Either a workflow node OR a trigger. Distinguished by presence of `nodeSlug`. */
4
4
  selection: WorkflowNode | WorkflowTrigger | null;
5
5
  secretKeys: string[];
6
6
  variables?: Record<string, WorkflowVariable>;
7
+ /** The workflow's declared state namespaces — options for `state-ref` fields. */
8
+ stateNamespaces?: WorkflowStateNamespace[];
7
9
  upstreamOutputs?: Array<{
8
10
  nodeId: string;
9
11
  nodeName?: string;
@@ -29,13 +31,15 @@ type __VLS_Props = {
29
31
  };
30
32
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
31
33
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
- close: () => any;
33
34
  remove: () => any;
35
+ close: () => any;
36
+ "declare-state": (namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any;
34
37
  navigate: (id: string) => any;
35
38
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
36
39
  onUpdate?: ((next: WorkflowTrigger | WorkflowNode) => any) | undefined;
37
- onClose?: (() => any) | undefined;
38
40
  onRemove?: (() => any) | undefined;
41
+ onClose?: (() => any) | undefined;
42
+ "onDeclare-state"?: ((namespace: string, role: WorkflowStateRole, visibility: WorkflowStateVisibility) => any) | undefined;
39
43
  onNavigate?: ((id: string) => any) | undefined;
40
44
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
41
45
  export default _default;
@@ -0,0 +1,21 @@
1
+ import type { WorkflowNode } from '../../types/integrations.js';
2
+ type __VLS_Props = {
3
+ node: WorkflowNode | null;
4
+ };
5
+ type __VLS_PublicProps = __VLS_Props & {
6
+ 'open'?: boolean;
7
+ };
8
+ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:open": (value: boolean) => any;
10
+ move: (payload: {
11
+ version: string;
12
+ config: Record<string, unknown>;
13
+ }) => any;
14
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
15
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
16
+ onMove?: ((payload: {
17
+ version: string;
18
+ config: Record<string, unknown>;
19
+ }) => any) | undefined;
20
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
21
+ export default _default;