@revenexx/studio-integrations 1.2.0 → 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 (37) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +10 -2
  4. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +5 -1
  5. package/dist/runtime/components/Integrations/NodeConfigField.vue +65 -1
  6. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +5 -1
  7. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +5 -1
  8. package/dist/runtime/components/Integrations/NodeInspector.vue +103 -25
  9. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +5 -1
  10. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.d.vue.ts +21 -0
  11. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue +133 -0
  12. package/dist/runtime/components/Integrations/NodeVersionMoveDialog.vue.d.ts +21 -0
  13. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.d.vue.ts +16 -0
  14. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +111 -0
  15. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue.d.ts +16 -0
  16. package/dist/runtime/components/Integrations/WorkflowEditor.vue +39 -2
  17. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.d.vue.ts +13 -0
  18. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +216 -0
  19. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue.d.ts +13 -0
  20. package/dist/runtime/composables/useIntegrationsTemplates.js +5 -0
  21. package/dist/runtime/composables/useIntegrationsWorkflows.js +6 -0
  22. package/dist/runtime/data/integrationNodeCatalog.d.ts +25 -0
  23. package/dist/runtime/data/integrationNodeCatalog.js +8 -0
  24. package/dist/runtime/data/nodeVersionMove.d.ts +72 -0
  25. package/dist/runtime/data/nodeVersionMove.js +65 -0
  26. package/dist/runtime/i18n/messages/de.json +53 -4
  27. package/dist/runtime/i18n/messages/en.json +53 -4
  28. package/dist/runtime/pages/integrations/workflows/[id].vue +30 -20
  29. package/dist/runtime/pages/integrations/workflows/index.vue +23 -4
  30. package/dist/runtime/types/integrations.d.ts +44 -1
  31. package/dist/runtime/utils/buildSettle.d.ts +68 -0
  32. package/dist/runtime/utils/buildSettle.js +29 -0
  33. package/dist/runtime/utils/settingCondition.d.ts +48 -0
  34. package/dist/runtime/utils/settingCondition.js +84 -0
  35. package/dist/runtime/utils/stateNamespaceName.d.ts +23 -0
  36. package/dist/runtime/utils/stateNamespaceName.js +8 -0
  37. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,160 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 1.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 593c0d5: A step can be changed onto another version of its node
8
+
9
+ The editor could say precisely that a step sat on a node version the platform
10
+ cannot run, and name the version that would work — and there it stopped. Getting
11
+ there meant deleting the step and inserting a fresh one, which throws away its
12
+ settings, its name, its note and its connections.
13
+
14
+ The version is now a control wherever there is somewhere to go, and two states
15
+ reach it. Where the pinned version cannot run, the red notice keeps every word it
16
+ had and gains the button it was missing. Where the step runs fine and a newer
17
+ version exists, one grey line in its dialog says so — **and nothing else does**.
18
+ No mark on the canvas card: nothing is wrong there, and a fourth mark for a
19
+ non-problem is what teaches an operator to stop reading marks. A node with a
20
+ single usable version says nothing at all.
21
+
22
+ Older versions are offered too, not only newer ones. A version that turns out to
23
+ behave badly otherwise has no way back.
24
+
25
+ **What the change costs is said before it happens.** The two versions are read
26
+ against the settings the step actually holds, and split three ways: what travels,
27
+ what the chosen version does not have and will be taken out, and what arrives
28
+ needing an eye — a setting it requires and the step has no answer for, or one
29
+ whose type or choices moved. A setting nobody filled in appears nowhere; there is
30
+ nothing to lose. A change that costs nothing says so in a sentence rather than
31
+ showing three empty lists, and where the version being left is no longer in the
32
+ catalogue the answer says the two could not be compared at all.
33
+
34
+ Values the chosen version does not declare are removed rather than left behind.
35
+ The service keeps such a key on save without complaint, so it would otherwise sit
36
+ in the workflow as invisible dead data.
37
+
38
+ Nothing about the step's identity moves: same id, same connections, same name,
39
+ same note, same place on the canvas. It lands as an unsaved change like editing
40
+ any setting, so discarding the editor's changes undoes it.
41
+
42
+ Promised as `specs/node-configuration.md` AC-24 to AC-26. `specs/node-palette.md`
43
+ AC-7 gains the clause that the version it names is somewhere the step can
44
+ actually go, and loses the gap entry that described the dead end.
45
+
46
+ - bb8a80a: Declare what a workflow may remember between runs (PO-374).
47
+
48
+ A new **State** entry in the editor's floating control pane (Shift+T) opens a
49
+ dialog for the blob's `state` block: each namespace with its role — mapping,
50
+ cursor, dedupe or digest — and whether it belongs to this workflow alone or to
51
+ every workflow in the tenant. The engine mints a run's grant from exactly this
52
+ list, so until now a workflow could not reach the state store from the studio at
53
+ all.
54
+
55
+ The role is chosen when the namespace is added and stated as text afterwards:
56
+ entries written with one meaning cannot be read back with another. What sharing
57
+ costs — tenant-wide reads _and_ writes, and a namespace that outlives this
58
+ workflow — is on screen next to the control that chooses it.
59
+
60
+ Also fixes a silent loss in both directions of the workflow mapper, which
61
+ enumerates blob keys one by one: `state` was neither read from the API nor sent
62
+ back on save, so a declaration made anywhere else would have disappeared on the
63
+ next save from the studio.
64
+
65
+ A node setting of the new type `state-ref` picks one of the workflow's declared
66
+ namespaces **of that setting's kind**, and declares a new one from inside the
67
+ picker — the same shape a `credentials-ref` has, so the author never leaves the
68
+ node to type a name that nothing would have compared.
69
+
70
+ A namespace declared inside the picker waits in the node's draft until the
71
+ inspector keeps it — Save, or prev/next — so Esc discards the declaration
72
+ together with the setting that named it, and the workflow does not report
73
+ unsaved changes for something nobody chose to keep.
74
+
75
+ A node's test run now names the workflow being edited, which lets it read that
76
+ workflow's namespaces. Writes stay refused at author time.
77
+
78
+ Promised behaviour: `specs/state-namespaces.md`.
79
+
80
+ - ddff18a: A setting that cannot apply is no longer put in front of the author
81
+
82
+ A node can now say in its manifest when one of its settings applies — another
83
+ setting's key, an operator, and the value to compare against — and the
84
+ configuration form draws it only while that holds. Until now the only thing
85
+ warning an author off a meaningless field was a sentence in small print under
86
+ it, which they read last, having already filled it in. Date Time's **Source**
87
+ is the plainest case: under "Now" it still asked which field to read the date
88
+ out of.
89
+
90
+ What changed on screen:
91
+
92
+ - A setting whose condition does not hold is not drawn, and is not counted
93
+ among the optional ones either — a fold that offers four settings and shows
94
+ three teaches the reader to distrust the number.
95
+ - What was typed into a setting that stops applying is dropped, the same
96
+ cascade the studio already runs for a setting whose options another one
97
+ resolves. A value nowhere on screen is a value nobody can correct.
98
+ - Settings a node resolves while the author edits are treated the same way.
99
+
100
+ The words a condition is written in are the fourteen the condition and filter
101
+ nodes already offer — `equals`, `isEmpty`, `contains`, `>` and the rest — so an
102
+ author who has met them in one place does not learn a second set for the other.
103
+ `specs/node-configuration.md` AC-27 and AC-28 carry the promise.
104
+
105
+ Nothing changes for a node that says nothing: every setting is drawn exactly as
106
+ before. A condition written on a setting _inside a group_ is not honoured here —
107
+ a group is drawn as one value editor rather than as its own settings — and that
108
+ is written into the spec's Gaps rather than left to be discovered.
109
+
110
+ ### Patch Changes
111
+
112
+ - 4dd5b12: The answer that keeps unsaved work is legible, and is the one the question emphasises
113
+
114
+ Leaving a workflow with unsaved changes asks whether to discard them, and the
115
+ question offers two answers. Only one of them looked like an answer: "Keep editing"
116
+ was drawn in a colour its own background shares, so it read as an empty button beside
117
+ "Discard and leave" and its label appeared only once the pointer rested on it. Which
118
+ is the wrong half of the dialog to hide — that button is the one the keyboard lands
119
+ on, so the answer the question exists to make easy was the answer nobody could see.
120
+
121
+ It is now drawn as the filled answer, the way every other dialog in the module draws
122
+ the answer on the right, so the question also says which answer it falls back to. The
123
+ cause was two button variants on one element: the dialog's cancel element applies the
124
+ outline variant itself, and the button inside it was left on the default one, so the
125
+ merge took the background from the first and the text colour from the second. Nothing
126
+ else about the question moves — the wording, the keyboard default and what each answer
127
+ does are unchanged.
128
+
129
+ Promised as `specs/workflow-save.md` AC-13 and AC-14: the label is checked as a
130
+ contrast ratio against the button it sits on, unhovered, and the emphasis as a
131
+ difference between the two answers' surfaces rather than against a class name or a
132
+ colour value.
133
+
134
+ - ab2eb96: A build state stops saying "still building" wherever it is shown
135
+
136
+ Saving a workflow queues the platform to build an executable bundle from it, and
137
+ until that build lands the workflow cannot run. Two surfaces say so: the chip beside
138
+ Save in the editor, and the build column in the workflows list. Only the editor's
139
+ ever became true on its own — the list said whatever its last read said, so a
140
+ workflow that finished building while the list stood open kept reading "Building…"
141
+ until something reloaded the page. Save in the editor, come back to the list, and the
142
+ row never moved.
143
+
144
+ The list now watches a build out the way the editor does: one read of the tenant per
145
+ turn, so thirty workflows building at once cost what one costs, and it stops as soon
146
+ as nothing is waiting. A platform build report still makes it quicker where there is
147
+ one; nothing depends on one arriving any more, on either surface.
148
+
149
+ The two waits now share one interval and one ceiling instead of the editor owning
150
+ both, so neither surface can drift into asking twice as often as the other.
151
+
152
+ Promised as `specs/workflow-build.md`, a new spec for the subject: what becomes of a
153
+ saved workflow before it can run. AC-1 is the editor's promise, moved out of
154
+ `specs/workflow-save.md`, where a reader asking what a build state means would not
155
+ have looked; AC-2 is the list's. `specs/realtime-updates.md` AC-7 keeps the
156
+ announcement half and now says which of the two makes a build state true.
157
+
3
158
  ## 1.2.0
4
159
 
5
160
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "1.2.0",
4
+ "version": "1.3.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -3,6 +3,7 @@ import { computed } from "vue";
3
3
  import { Button } from "@revenexx/studio";
4
4
  import { IntegrationsApiError } from "../../utils/apiError";
5
5
  import { resolveLocalized } from "../../utils/localize";
6
+ import { applyingFields, staleKeys } from "../../utils/settingCondition";
6
7
  const props = defineProps({
7
8
  slug: { type: String, required: true },
8
9
  version: { type: String, required: true },
@@ -24,6 +25,13 @@ const q = useNodeConfigResolve({
24
25
  dependsOn: () => props.field.dependsOn
25
26
  });
26
27
  const resolvedFields = computed(() => q.data.value?.fields ?? []);
28
+ const applyingResolvedFields = computed(() => applyingFields(resolvedFields.value, props.config));
29
+ function writeResolved(key, value) {
30
+ emit("set", key, value);
31
+ const after = { ...props.config, [key]: value };
32
+ for (const stale of staleKeys(resolvedFields.value, key, after))
33
+ emit("set", stale, void 0);
34
+ }
27
35
  </script>
28
36
 
29
37
  <template>
@@ -67,7 +75,7 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
67
75
  <legend class="px-1 text-mono text-muted-foreground">
68
76
  {{ resolveLocalized(field.label) }}
69
77
  </legend>
70
- <template v-for="f in resolvedFields" :key="f.key">
78
+ <template v-for="f in applyingResolvedFields" :key="f.key">
71
79
  <!-- collision guard: a resolved key that shadows a static config field is skipped -->
72
80
  <p v-if="staticKeys.includes(f.key)" class="text-sm text-amber-600">
73
81
  {{ t("integration.nodeConfig.dynamicSchema.fieldCollision", { key: f.key }) }}
@@ -89,7 +97,7 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
89
97
  :static-keys="staticKeys"
90
98
  :sibling-fields="resolvedFields"
91
99
  :depth="(depth ?? 0) + 1"
92
- @set="(k, v) => emit('set', k, v)"
100
+ @set="writeResolved"
93
101
  />
94
102
  </template>
95
103
 
@@ -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;
@@ -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");
@@ -619,6 +644,45 @@ function setArrayItemField(index, fieldKey, v) {
619
644
  />
620
645
  </template>
621
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
+
622
686
  <!-- array of objects (e.g. headers / mappings) -->
623
687
  <div v-else-if="field.type === 'array' && field.items?.type === 'object'" class="space-y-2">
624
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;
@@ -31,11 +33,13 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
31
33
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
34
  remove: () => any;
33
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
40
  onRemove?: (() => any) | undefined;
38
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,7 @@ 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";
42
44
  import { deepClone } from "../../utils/deepClone";
43
45
  import { copyToClipboard } from "../../utils/clipboard";
44
46
  import { nodeDisplayLabel } from "../../utils/validationFindings";
@@ -57,6 +59,7 @@ const props = defineProps({
57
59
  selection: { type: [Object, null], required: true },
58
60
  secretKeys: { type: Array, required: true },
59
61
  variables: { type: Object, required: false },
62
+ stateNamespaces: { type: Array, required: false },
60
63
  upstreamOutputs: { type: Array, required: false },
61
64
  autofocusName: { type: Boolean, required: false },
62
65
  nodes: { type: Array, required: false },
@@ -66,17 +69,35 @@ const props = defineProps({
66
69
  workflowId: { type: [Number, String], required: false },
67
70
  refusedFields: { type: Object, required: false }
68
71
  });
69
- const emit = defineEmits(["update", "close", "remove", "navigate"]);
72
+ const emit = defineEmits(["update", "close", "remove", "navigate", "declare-state"]);
70
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 = deepClone(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", deepClone(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")
@@ -760,7 +786,7 @@ const hasNext = computed(() => seqIndex.value >= 0 && seqIndex.value < sequence.
760
786
  function navigate(offset) {
761
787
  const target = sequence.value[seqIndex.value + offset];
762
788
  if (!target) return;
763
- if (dirty.value) emit("update", deepClone(draft.value));
789
+ commit();
764
790
  emit("navigate", target);
765
791
  }
766
792
  const outputView = ref("schema");
@@ -866,7 +892,16 @@ async function runTestStep() {
866
892
  try {
867
893
  const res = await api.post(
868
894
  `/nodes/${encodeURIComponent(wn.nodeSlug)}/${encodeURIComponent(wn.nodeVersion)}/execute:test`,
869
- { 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
+ }
870
905
  );
871
906
  testLogs.value = res.logs ?? [];
872
907
  testOutputs.value = {
@@ -959,6 +994,26 @@ async function runTestStep() {
959
994
  </PopoverContent>
960
995
  </Popover>
961
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>
962
1017
  </div>
963
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">
964
1019
  {{ groupLabel }}
@@ -1298,6 +1353,18 @@ async function runTestStep() {
1298
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"
1299
1354
  >
1300
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>
1301
1368
  </p>
1302
1369
 
1303
1370
  <div
@@ -1750,6 +1817,8 @@ async function runTestStep() {
1750
1817
  :sibling-fields="manifest.config ?? []"
1751
1818
  :depth="0"
1752
1819
  :variables="variables"
1820
+ :state-namespaces="offeredStateNamespaces"
1821
+ @declare-state="stageStateNamespace"
1753
1822
  :upstream-outputs="upstreamOutputs"
1754
1823
  :refused="refusedFields?.[field.key]"
1755
1824
  @set="(k, v) => writeConfig(k, v)"
@@ -1790,6 +1859,8 @@ async function runTestStep() {
1790
1859
  :sibling-fields="manifest.config ?? []"
1791
1860
  :depth="0"
1792
1861
  :variables="variables"
1862
+ :state-namespaces="offeredStateNamespaces"
1863
+ @declare-state="stageStateNamespace"
1793
1864
  :upstream-outputs="upstreamOutputs"
1794
1865
  :refused="refusedFields?.[field.key]"
1795
1866
  @set="(k, v) => writeConfig(k, v)"
@@ -1832,6 +1903,8 @@ async function runTestStep() {
1832
1903
  :sibling-fields="manifest?.config ?? []"
1833
1904
  :depth="0"
1834
1905
  :variables="variables"
1906
+ :state-namespaces="offeredStateNamespaces"
1907
+ @declare-state="stageStateNamespace"
1835
1908
  :upstream-outputs="upstreamOutputs"
1836
1909
  :refused="refusedFields?.[field.key]"
1837
1910
  @set="(k, v) => writeConfig(k, v)"
@@ -2121,6 +2194,11 @@ async function runTestStep() {
2121
2194
  </Button>
2122
2195
  </div>
2123
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" />
2124
2202
  </div>
2125
2203
  </template>
2126
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;
@@ -31,11 +33,13 @@ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {
31
33
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
34
  remove: () => any;
33
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
40
  onRemove?: (() => any) | undefined;
38
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;