@revenexx/studio-integrations 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/components/Integrations/CredentialCreateDialog.d.vue.ts +13 -0
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +279 -0
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue.d.ts +13 -0
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.d.vue.ts +20 -0
  6. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +342 -0
  7. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue.d.ts +20 -0
  8. package/dist/runtime/components/Integrations/CredentialLogo.d.vue.ts +14 -0
  9. package/dist/runtime/components/Integrations/CredentialLogo.vue +37 -0
  10. package/dist/runtime/components/Integrations/CredentialLogo.vue.d.ts +14 -0
  11. package/dist/runtime/components/Integrations/CredentialRevenexxSource.d.vue.ts +12 -0
  12. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +364 -0
  13. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue.d.ts +12 -0
  14. package/dist/runtime/components/Integrations/CredentialTypePicker.d.vue.ts +15 -0
  15. package/dist/runtime/components/Integrations/CredentialTypePicker.vue +71 -0
  16. package/dist/runtime/components/Integrations/CredentialTypePicker.vue.d.ts +15 -0
  17. package/dist/runtime/components/Integrations/DeadLettersPanel.d.vue.ts +5 -0
  18. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +151 -0
  19. package/dist/runtime/components/Integrations/DeadLettersPanel.vue.d.ts +5 -0
  20. package/dist/runtime/components/Integrations/EditorTip.d.vue.ts +17 -0
  21. package/dist/runtime/components/Integrations/EditorTip.vue +46 -0
  22. package/dist/runtime/components/Integrations/EditorTip.vue.d.ts +17 -0
  23. package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +9 -0
  24. package/dist/runtime/components/Integrations/EventTypeCombobox.vue +64 -0
  25. package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +9 -0
  26. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +19 -0
  27. package/dist/runtime/components/Integrations/ExpressionInput.vue +236 -0
  28. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +19 -0
  29. package/dist/runtime/components/Integrations/GroupFrameView.d.vue.ts +28 -0
  30. package/dist/runtime/components/Integrations/GroupFrameView.vue +222 -0
  31. package/dist/runtime/components/Integrations/GroupFrameView.vue.d.ts +28 -0
  32. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +14 -0
  33. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +184 -0
  34. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +14 -0
  35. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.d.vue.ts +16 -0
  36. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +102 -0
  37. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue.d.ts +16 -0
  38. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +23 -0
  39. package/dist/runtime/components/Integrations/NodeConfigField.vue +587 -0
  40. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +23 -0
  41. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.d.vue.ts +14 -0
  42. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.vue +80 -0
  43. package/dist/runtime/components/Integrations/NodeDynamicOutputsResolver.vue.d.ts +14 -0
  44. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +22 -3
  45. package/dist/runtime/components/Integrations/NodeInspector.vue +1074 -294
  46. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +22 -3
  47. package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +9 -0
  48. package/dist/runtime/components/Integrations/NodeLogo.vue +38 -0
  49. package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +9 -0
  50. package/dist/runtime/components/Integrations/NodePalette.d.vue.ts +22 -1
  51. package/dist/runtime/components/Integrations/NodePalette.vue +411 -90
  52. package/dist/runtime/components/Integrations/NodePalette.vue.d.ts +22 -1
  53. package/dist/runtime/components/Integrations/QueryBuilder.d.vue.ts +9 -0
  54. package/dist/runtime/components/Integrations/QueryBuilder.vue +239 -0
  55. package/dist/runtime/components/Integrations/QueryBuilder.vue.d.ts +9 -0
  56. package/dist/runtime/components/Integrations/{RunDetailDialog.d.vue.ts → RunActionsMenu.d.vue.ts} +3 -4
  57. package/dist/runtime/components/Integrations/RunActionsMenu.vue +86 -0
  58. package/dist/runtime/components/Integrations/{RunDetailDialog.vue.d.ts → RunActionsMenu.vue.d.ts} +3 -4
  59. package/dist/runtime/components/Integrations/RunDetailPanel.d.vue.ts +14 -0
  60. package/dist/runtime/components/Integrations/RunDetailPanel.vue +777 -0
  61. package/dist/runtime/components/Integrations/RunDetailPanel.vue.d.ts +14 -0
  62. package/dist/runtime/components/Integrations/RunPayloadInput.d.vue.ts +13 -0
  63. package/dist/runtime/components/Integrations/RunPayloadInput.vue +175 -0
  64. package/dist/runtime/components/Integrations/RunPayloadInput.vue.d.ts +13 -0
  65. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +27 -0
  66. package/dist/runtime/components/Integrations/RunResponseModal.vue +84 -0
  67. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +27 -0
  68. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +21 -0
  69. package/dist/runtime/components/Integrations/RunsTable.vue +114 -0
  70. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +21 -0
  71. package/dist/runtime/components/Integrations/SftpAccountImportDialog.d.vue.ts +11 -0
  72. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +91 -0
  73. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue.d.ts +11 -0
  74. package/dist/runtime/components/Integrations/Sidebar.vue +1 -0
  75. package/dist/runtime/components/Integrations/StickyNoteView.d.vue.ts +28 -0
  76. package/dist/runtime/components/Integrations/StickyNoteView.vue +208 -0
  77. package/dist/runtime/components/Integrations/StickyNoteView.vue.d.ts +28 -0
  78. package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +29 -0
  79. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +201 -0
  80. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +29 -0
  81. package/dist/runtime/components/Integrations/WorkflowContextMenu.d.vue.ts +32 -0
  82. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue +138 -0
  83. package/dist/runtime/components/Integrations/WorkflowContextMenu.vue.d.ts +32 -0
  84. package/dist/runtime/components/Integrations/WorkflowCreateDialog.d.vue.ts +9 -0
  85. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +322 -0
  86. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue.d.ts +9 -0
  87. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.d.vue.ts +18 -0
  88. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +63 -0
  89. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue.d.ts +18 -0
  90. package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +12 -1
  91. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +68 -6
  92. package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +12 -1
  93. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +8 -1
  94. package/dist/runtime/components/Integrations/WorkflowEditor.vue +1424 -144
  95. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +8 -1
  96. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +12 -0
  97. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +417 -0
  98. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +12 -0
  99. package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +10 -0
  100. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +89 -0
  101. package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +10 -0
  102. package/dist/runtime/components/Integrations/WorkflowIconView.d.vue.ts +10 -0
  103. package/dist/runtime/components/Integrations/WorkflowIconView.vue +35 -0
  104. package/dist/runtime/components/Integrations/WorkflowIconView.vue.d.ts +10 -0
  105. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +5 -1
  106. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +114 -84
  107. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +5 -1
  108. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.d.vue.ts +12 -0
  109. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +654 -0
  110. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue.d.ts +12 -0
  111. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +36 -0
  112. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +136 -0
  113. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +36 -0
  114. package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +14 -0
  115. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +63 -0
  116. package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +14 -0
  117. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +35 -15
  118. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.d.vue.ts +13 -0
  119. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +265 -0
  120. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue.d.ts +13 -0
  121. package/dist/runtime/components/Integrations/WorkflowsTable.d.vue.ts +4 -1
  122. package/dist/runtime/components/Integrations/WorkflowsTable.vue +122 -39
  123. package/dist/runtime/components/Integrations/WorkflowsTable.vue.d.ts +4 -1
  124. package/dist/runtime/components/Integrations/workflowLayout.d.ts +5 -0
  125. package/dist/runtime/components/Integrations/workflowLayout.js +5 -3
  126. package/dist/runtime/composables/integrationsApi.d.ts +6 -2
  127. package/dist/runtime/composables/integrationsApi.js +5 -4
  128. package/dist/runtime/composables/useCanvasShortcuts.d.ts +37 -0
  129. package/dist/runtime/composables/useCanvasShortcuts.js +62 -0
  130. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +22 -0
  131. package/dist/runtime/composables/useIntegrationsDeadLetters.js +50 -0
  132. package/dist/runtime/composables/useIntegrationsNodes.d.ts +4 -0
  133. package/dist/runtime/composables/useIntegrationsNodes.js +6 -3
  134. package/dist/runtime/composables/useIntegrationsRevisions.d.ts +40 -0
  135. package/dist/runtime/composables/useIntegrationsRevisions.js +51 -0
  136. package/dist/runtime/composables/useIntegrationsRuns.d.ts +21 -1
  137. package/dist/runtime/composables/useIntegrationsRuns.js +92 -4
  138. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -0
  139. package/dist/runtime/composables/useIntegrationsTemplates.js +32 -4
  140. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +1 -0
  141. package/dist/runtime/composables/useIntegrationsWorkflows.js +57 -1
  142. package/dist/runtime/composables/useMiniMapLayout.d.ts +34 -0
  143. package/dist/runtime/composables/useMiniMapLayout.js +119 -0
  144. package/dist/runtime/composables/useNodeConfigResolve.d.ts +43 -0
  145. package/dist/runtime/composables/useNodeConfigResolve.js +123 -0
  146. package/dist/runtime/composables/useNodeDrag.d.ts +22 -0
  147. package/dist/runtime/composables/useNodeDrag.js +10 -0
  148. package/dist/runtime/composables/usePaletteLayout.d.ts +41 -0
  149. package/dist/runtime/composables/usePaletteLayout.js +113 -0
  150. package/dist/runtime/data/expressionEditor.d.ts +22 -0
  151. package/dist/runtime/data/expressionEditor.js +40 -0
  152. package/dist/runtime/data/integrationNodeCatalog.js +8 -8
  153. package/dist/runtime/data/nodeInputData.d.ts +37 -0
  154. package/dist/runtime/data/nodeInputData.js +62 -0
  155. package/dist/runtime/data/nodeRunData.d.ts +56 -0
  156. package/dist/runtime/data/nodeRunData.js +91 -0
  157. package/dist/runtime/data/payloadForm.d.ts +44 -0
  158. package/dist/runtime/data/payloadForm.js +126 -0
  159. package/dist/runtime/data/workflowIconCatalog.d.ts +34 -0
  160. package/dist/runtime/data/workflowIconCatalog.js +116 -0
  161. package/dist/runtime/pages/integrations/audit.vue +25 -35
  162. package/dist/runtime/pages/integrations/credentials.vue +117 -122
  163. package/dist/runtime/pages/integrations/index.vue +133 -36
  164. package/dist/runtime/pages/integrations/runs.vue +125 -75
  165. package/dist/runtime/pages/integrations/schedules.vue +5 -16
  166. package/dist/runtime/pages/integrations/secrets.vue +1 -3
  167. package/dist/runtime/pages/integrations/workflows/[id].vue +599 -194
  168. package/dist/runtime/pages/integrations/workflows/index.vue +10 -8
  169. package/dist/runtime/types/integrations.d.ts +190 -4
  170. package/dist/runtime/utils/credentialState.d.ts +28 -0
  171. package/dist/runtime/utils/credentialState.js +29 -0
  172. package/dist/runtime/utils/localize.d.ts +3 -1
  173. package/dist/runtime/utils/localize.js +1 -2
  174. package/dist/runtime/utils/statusStyle.d.ts +6 -0
  175. package/dist/runtime/utils/statusStyle.js +15 -0
  176. package/package.json +12 -2
  177. package/dist/runtime/components/Integrations/RunDetailDialog.vue +0 -169
  178. package/dist/runtime/pages/integrations/workflows/new.d.vue.ts +0 -2
  179. package/dist/runtime/pages/integrations/workflows/new.vue +0 -153
  180. package/dist/runtime/pages/integrations/workflows/new.vue.d.ts +0 -2
@@ -1,6 +1,8 @@
1
1
  <script setup>
2
- import { computed, onMounted } from "vue";
3
- import { AddCircle as Plus, TrashBinMinimalistic as Trash2, CloseCircle as X } from "#solar-icons";
2
+ import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
3
+ import { TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
4
+ import { ChevronLeft, ChevronRight, Clock, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
5
+ import { toast } from "vue-sonner";
4
6
  import {
5
7
  Button,
6
8
  Input,
@@ -13,112 +15,803 @@ import {
13
15
  Switch,
14
16
  Textarea
15
17
  } from "@revenexx/studio";
16
- import { findManifest } from "../../data/integrationNodeCatalog";
18
+ import EventTypeCombobox from "./EventTypeCombobox.vue";
19
+ import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
20
+ import {
21
+ collectUpstreamNodes,
22
+ nodeOutputExpr,
23
+ portsForNode,
24
+ triggerPayloadExpr,
25
+ variableExpr
26
+ } from "../../data/nodeInputData";
27
+ import {
28
+ dataTypeOf,
29
+ portValuesFromStepOutput,
30
+ previewValue,
31
+ valueAtPath
32
+ } from "../../data/nodeRunData";
17
33
  const props = defineProps({
18
34
  selection: { type: [Object, null], required: true },
19
- secretKeys: { type: Array, required: true }
35
+ secretKeys: { type: Array, required: true },
36
+ variables: { type: Object, required: false },
37
+ upstreamOutputs: { type: Array, required: false },
38
+ autofocusName: { type: Boolean, required: false },
39
+ nodes: { type: Array, required: false },
40
+ edges: { type: Array, required: false },
41
+ triggers: { type: Array, required: false },
42
+ dynamicOutputs: { type: Object, required: false },
43
+ workflowId: { type: [Number, String], required: false }
20
44
  });
21
- const emit = defineEmits(["update", "close", "remove"]);
22
- const isNode = computed(() => !!props.selection && "nodeSlug" in props.selection);
23
- const { credentials, load: loadCredentials } = useIntegrationsCredentials();
24
- onMounted(() => {
25
- void loadCredentials();
26
- });
27
- function credsOfType(slug) {
28
- if (!slug) return credentials.value;
29
- return credentials.value.filter((c) => c.credential_type_slug === slug);
45
+ const emit = defineEmits(["update", "close", "remove", "navigate"]);
46
+ function clone(v) {
47
+ return JSON.parse(JSON.stringify(v));
30
48
  }
31
- function credNeedsReauth(id) {
32
- return credentials.value.some((c) => c.id === id && c.status === "needs_reauth");
49
+ const draft = ref(clone(props.selection));
50
+ watch(() => props.selection?.id, () => {
51
+ if (props.selection && props.selection.id !== draft.value.id) draft.value = clone(props.selection);
52
+ });
53
+ const isNode = computed(() => "nodeSlug" in draft.value);
54
+ const draftNode = computed(() => isNode.value ? draft.value : null);
55
+ const draftTrigger = computed(() => isNode.value ? null : draft.value);
56
+ const dirty = computed(() => JSON.stringify(draft.value) !== JSON.stringify(props.selection));
57
+ function save() {
58
+ if (dirty.value) emit("update", clone(draft.value));
59
+ emit("close");
33
60
  }
34
- const exprHint = "${{ \u2026 }} ok";
61
+ const nameInput = ref(null);
62
+ onMounted(() => {
63
+ if (!props.autofocusName) return;
64
+ void nextTick(() => {
65
+ nameInput.value?.focus();
66
+ nameInput.value?.select();
67
+ });
68
+ });
35
69
  const manifest = computed(() => {
36
- if (!isNode.value || !props.selection) return null;
37
- const wn = props.selection;
38
- return findManifest(wn.nodeSlug, wn.nodeVersion);
70
+ const wn = draftNode.value;
71
+ return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
39
72
  });
40
73
  function labelText(v) {
41
74
  if (!v) return "";
42
- return typeof v === "string" ? v : Object.values(v)[0] ?? "";
75
+ return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
43
76
  }
44
- function readConfig(key) {
45
- if (!isNode.value || !props.selection) return void 0;
46
- return props.selection.config?.[key];
77
+ const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
78
+ const TRIGGER_LABELS = { manual: "Manual", schedule: "Schedule", webhook: "Webhook", event: "Event" };
79
+ const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
80
+ const namePlaceholder = computed(() => {
81
+ if (draftNode.value) return labelText(manifest.value?.name) || draftNode.value.nodeSlug;
82
+ return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
83
+ });
84
+ const subtitleText = computed(() => {
85
+ const wn = draftNode.value;
86
+ if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
87
+ return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
88
+ });
89
+ const groupLabel = computed(() => {
90
+ if (!isNode.value) return "Trigger";
91
+ const groups = manifest.value?.groups;
92
+ if (groups?.length) return labelText(groups[groups.length - 1]);
93
+ return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
94
+ });
95
+ function writeName(name) {
96
+ draft.value = { ...draft.value, name };
97
+ }
98
+ const tab = ref("parameters");
99
+ const showSettingsTab = computed(() => isNode.value);
100
+ const SETTINGS_FIELD_KEY = /(^|_)(timeout|retry|retries|backoff)|(timeout|retry|retries|backoff)(ms|sec|seconds|attempts)?$/i;
101
+ const parameterFields = computed(() => (manifest.value?.config ?? []).filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
102
+ const settingsFields = computed(() => (manifest.value?.config ?? []).filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
103
+ function dependentKeys(changedKey) {
104
+ const fields = manifest.value?.config ?? [];
105
+ const out = /* @__PURE__ */ new Set();
106
+ const queue = [changedKey];
107
+ while (queue.length > 0) {
108
+ const dep = queue.shift();
109
+ for (const f of fields) {
110
+ if ((f.dependsOn ?? []).includes(dep) && !out.has(f.key)) {
111
+ out.add(f.key);
112
+ queue.push(f.key);
113
+ }
114
+ }
115
+ }
116
+ out.delete(changedKey);
117
+ return out;
47
118
  }
48
119
  function writeConfig(key, value) {
49
- if (!isNode.value || !props.selection) return;
50
- const wn = props.selection;
51
- emit("update", { ...wn, config: { ...wn.config, [key]: value } });
120
+ const wn = draftNode.value;
121
+ if (!wn) return;
122
+ const next = { ...wn.config, [key]: value };
123
+ const cleared = dependentKeys(key);
124
+ for (const k of cleared) delete next[k];
125
+ const clearedDynamicSchema = [...cleared].some(
126
+ (k) => (manifest.value?.config ?? []).some((f) => f.key === k && f.type === "dynamic-schema")
127
+ );
128
+ if (clearedDynamicSchema) {
129
+ const staticKeys = new Set((manifest.value?.config ?? []).map((f) => f.key));
130
+ for (const k of Object.keys(next)) if (!staticKeys.has(k)) delete next[k];
131
+ }
132
+ wn.config = next;
52
133
  }
53
- function writeName(name) {
54
- if (!props.selection) return;
55
- emit("update", { ...props.selection, name });
134
+ function patchNode(patch) {
135
+ if (!draftNode.value) return;
136
+ draft.value = { ...draftNode.value, ...patch };
137
+ }
138
+ function patchTrigger(patch) {
139
+ if (!draftTrigger.value) return;
140
+ draft.value = { ...draftTrigger.value, ...patch };
141
+ }
142
+ const requiredMissingCount = computed(() => {
143
+ if (!draftNode.value || !manifest.value?.config) return 0;
144
+ const cfg = draftNode.value.config ?? {};
145
+ const empty = (v) => v === void 0 || v === null || v === "" || Array.isArray(v) && v.length === 0;
146
+ return manifest.value.config.filter((f) => f.required && empty(cfg[f.key])).length;
147
+ });
148
+ async function copyConfig() {
149
+ if (!draftNode.value) return;
150
+ try {
151
+ await navigator.clipboard.writeText(JSON.stringify(draftNode.value.config ?? {}, null, 2));
152
+ toast.success("Config copied");
153
+ } catch {
154
+ toast.error("Could not copy config");
155
+ }
156
+ }
157
+ async function pasteConfig() {
158
+ if (!draftNode.value) return;
159
+ let text;
160
+ try {
161
+ text = await navigator.clipboard.readText();
162
+ } catch {
163
+ toast.error("Could not read the clipboard");
164
+ return;
165
+ }
166
+ let parsed;
167
+ try {
168
+ parsed = JSON.parse(text);
169
+ } catch {
170
+ toast.error("Clipboard is not valid JSON");
171
+ return;
172
+ }
173
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
174
+ toast.error("Clipboard is not a JSON config object");
175
+ return;
176
+ }
177
+ patchNode({ config: parsed });
178
+ toast.success("Config pasted");
56
179
  }
57
180
  function readTriggerConfig(key) {
58
- if (isNode.value || !props.selection) return void 0;
59
- return props.selection.config[key];
181
+ return draftTrigger.value?.config[key];
60
182
  }
61
183
  function writeTriggerConfig(key, value) {
62
- if (isNode.value || !props.selection) return;
63
- const wt = props.selection;
64
- emit("update", { ...wt, config: { ...wt.config, [key]: value } });
184
+ const wt = draftTrigger.value;
185
+ if (!wt) return;
186
+ patchTrigger({ config: { ...wt.config, [key]: value } });
65
187
  }
66
- function readArray(key) {
67
- const v = readConfig(key);
68
- return Array.isArray(v) ? v : [];
188
+ const { loadForWorkflow, getSteps } = useIntegrationsRuns();
189
+ const runData = ref(null);
190
+ const runInfo = ref(null);
191
+ const runLoading = ref(false);
192
+ const runError = ref(null);
193
+ async function loadRunData() {
194
+ if (!props.workflowId || runLoading.value) return;
195
+ runLoading.value = true;
196
+ runError.value = null;
197
+ try {
198
+ const { runs } = await loadForWorkflow(Number(props.workflowId), "", { perPage: 10 });
199
+ const source = runs.find((r) => r.status === "succeeded") ?? runs[0];
200
+ if (!source) {
201
+ runError.value = "No runs yet. Run the workflow once to sample real values.";
202
+ return;
203
+ }
204
+ const res = await getSteps(props.workflowId, source.id, true);
205
+ const map = {};
206
+ for (const step of res.steps ?? []) {
207
+ const values = portValuesFromStepOutput(step.output);
208
+ const existing = map[step.node_id];
209
+ if (!existing || Object.keys(values).length > 0 && Object.keys(existing).length === 0) {
210
+ map[step.node_id] = values;
211
+ }
212
+ }
213
+ runData.value = map;
214
+ runInfo.value = { id: source.id, startedAt: source.startedAt };
215
+ } catch (e) {
216
+ runError.value = e?.message ?? "Could not load run data.";
217
+ } finally {
218
+ runLoading.value = false;
219
+ }
69
220
  }
70
- function pushArrayItem(key, defaultItem) {
71
- writeConfig(key, [...readArray(key), defaultItem]);
221
+ function clearRunData() {
222
+ runData.value = null;
223
+ runInfo.value = null;
224
+ runError.value = null;
72
225
  }
73
- function removeArrayItem(key, index) {
74
- const arr = readArray(key).slice();
75
- arr.splice(index, 1);
76
- writeConfig(key, arr);
226
+ const pinnedSamples = ref({});
227
+ const pinStorageKey = computed(() => `integrations.sample-outputs.${props.workflowId ?? "wf"}`);
228
+ onMounted(() => {
229
+ try {
230
+ const raw = localStorage.getItem(pinStorageKey.value);
231
+ const parsed = raw ? JSON.parse(raw) : null;
232
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
233
+ pinnedSamples.value = parsed;
234
+ }
235
+ } catch {
236
+ }
237
+ });
238
+ function persistPins() {
239
+ try {
240
+ localStorage.setItem(pinStorageKey.value, JSON.stringify(pinnedSamples.value));
241
+ } catch {
242
+ }
243
+ }
244
+ const testOutputs = ref({});
245
+ function sampleFor(nodeId) {
246
+ return testOutputs.value[nodeId] ?? pinnedSamples.value[nodeId] ?? runData.value?.[nodeId] ?? void 0;
247
+ }
248
+ function withSampledFields(ports, sample) {
249
+ if (!sample) return ports;
250
+ return ports.map((p) => {
251
+ const value = sample[p.name];
252
+ if (!value || typeof value !== "object" || Array.isArray(value)) return p;
253
+ const known = new Set(p.fields.map((f) => f.name));
254
+ const extras = Object.keys(value).filter((k) => !known.has(k)).map((name) => ({ name, dataType: dataTypeOf(value[name]) }));
255
+ return extras.length ? { ...p, fields: [...p.fields, ...extras] } : p;
256
+ });
257
+ }
258
+ function chipValue(nodeId, port, field) {
259
+ const sample = sampleFor(nodeId);
260
+ if (!sample || !(port in sample)) return null;
261
+ const v = field ? valueAtPath(sample[port], field.split(".")) : sample[port];
262
+ if (v === void 0) return null;
263
+ return previewValue(v);
264
+ }
265
+ const sampleContext = computed(() => {
266
+ const nodeOutputs = {};
267
+ for (const n of props.nodes ?? []) {
268
+ const s = sampleFor(n.id);
269
+ if (s) nodeOutputs[n.id] = s;
270
+ }
271
+ return {
272
+ nodeOutputs,
273
+ variables: Object.fromEntries(Object.entries(props.variables ?? {}).map(([k, v]) => [k, v.default]))
274
+ };
275
+ });
276
+ provide("integrationsSampleContext", sampleContext);
277
+ provide("integrationsNodeNames", computed(
278
+ () => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, labelText(n.name) || n.nodeSlug]))
279
+ ));
280
+ const upstreamSections = computed(() => {
281
+ if (!isNode.value) return [];
282
+ return collectUpstreamNodes(draft.value.id, props.nodes ?? [], props.edges ?? []).map(({ node, direct }) => {
283
+ const m = findManifest(node.nodeSlug, node.nodeVersion);
284
+ return {
285
+ id: node.id,
286
+ name: labelText(node.name) || node.nodeSlug,
287
+ manifest: m,
288
+ direct,
289
+ ports: withSampledFields(portsForNode(node, m, props.dynamicOutputs?.[node.id]), sampleFor(node.id))
290
+ };
291
+ });
292
+ });
293
+ const variableNames = computed(() => Object.keys(props.variables ?? {}));
294
+ function portDotClass(kind, name) {
295
+ if (name === "true") return "bg-emerald-500";
296
+ if (name === "false") return "bg-amber-400";
297
+ if (kind === "error") return "bg-rose-500";
298
+ if (kind === "branch") return "bg-violet-500";
299
+ return "bg-slate-400";
300
+ }
301
+ const lastField = ref(null);
302
+ const lastFocusKind = ref(null);
303
+ const exprInsertHandler = ref(null);
304
+ provide("integrationsExpressionInsert", exprInsertHandler);
305
+ function onCenterFocusIn(e) {
306
+ const t = e.target;
307
+ if (t instanceof HTMLElement && t.closest(".expr-editor")) {
308
+ lastFocusKind.value = "editor";
309
+ return;
310
+ }
311
+ if (t instanceof HTMLTextAreaElement) {
312
+ lastField.value = t;
313
+ lastFocusKind.value = "native";
314
+ return;
315
+ }
316
+ if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) {
317
+ lastField.value = t;
318
+ lastFocusKind.value = "native";
319
+ }
320
+ }
321
+ function insertExpression(expression) {
322
+ if (lastFocusKind.value === "editor" && exprInsertHandler.value) {
323
+ exprInsertHandler.value(expression);
324
+ return;
325
+ }
326
+ const el = lastField.value;
327
+ if (el && el.isConnected) {
328
+ const start = el.selectionStart ?? el.value.length;
329
+ const end = el.selectionEnd ?? start;
330
+ el.setRangeText(expression, start, end, "end");
331
+ el.dispatchEvent(new Event("input", { bubbles: true }));
332
+ el.focus();
333
+ return;
334
+ }
335
+ try {
336
+ void navigator.clipboard?.writeText(expression);
337
+ } catch {
338
+ }
339
+ toast("Expression copied. Click into a field first to insert it directly.");
340
+ }
341
+ 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";
342
+ function onChipDragStart(e, expression) {
343
+ e.dataTransfer?.setData("text/plain", expression);
344
+ if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
345
+ dragActive.value = true;
346
+ }
347
+ const dragActive = ref(false);
348
+ const dropHoverEl = ref(null);
349
+ function clearDropHover() {
350
+ dropHoverEl.value?.classList.remove("chip-drop-hover");
351
+ dropHoverEl.value = null;
352
+ }
353
+ function onChipDragEnd() {
354
+ dragActive.value = false;
355
+ clearDropHover();
356
+ }
357
+ function expressionTarget(t) {
358
+ if (t instanceof HTMLTextAreaElement) return t;
359
+ if (t instanceof HTMLInputElement && !["number", "checkbox", "radio", "range", "file", "color"].includes(t.type)) return t;
360
+ if (t instanceof HTMLElement) return t.closest(".expr-editor");
361
+ return null;
362
+ }
363
+ function onCenterDragEnter(e) {
364
+ if (!dragActive.value) return;
365
+ const el = expressionTarget(e.target);
366
+ if (!el || el === dropHoverEl.value) return;
367
+ clearDropHover();
368
+ dropHoverEl.value = el;
369
+ el.classList.add("chip-drop-hover");
370
+ }
371
+ function onCenterDragLeave(e) {
372
+ if (e.target === dropHoverEl.value) clearDropHover();
373
+ }
374
+ function onCenterDrop() {
375
+ clearDropHover();
376
+ dragActive.value = false;
377
+ }
378
+ const closedNodes = ref(/* @__PURE__ */ new Set());
379
+ watch(upstreamSections, (sections) => {
380
+ const closed = /* @__PURE__ */ new Set();
381
+ for (const s of sections) {
382
+ if (!s.direct) closed.add(s.id);
383
+ }
384
+ closedNodes.value = closed;
385
+ }, { immediate: true });
386
+ function toggleNodeSection(id) {
387
+ const next = new Set(closedNodes.value);
388
+ if (next.has(id)) next.delete(id);
389
+ else next.add(id);
390
+ closedNodes.value = next;
77
391
  }
78
- function setArrayItemField(key, index, fieldKey, value) {
79
- const arr = readArray(key).slice();
80
- arr[index] = { ...arr[index] ?? {}, [fieldKey]: value };
81
- writeConfig(key, arr);
392
+ const inputSearch = ref("");
393
+ const searchQuery = computed(() => inputSearch.value.trim().toLowerCase());
394
+ function sectionClosed(id) {
395
+ return !searchQuery.value && closedNodes.value.has(id);
396
+ }
397
+ const visibleSections = computed(() => {
398
+ const q = searchQuery.value;
399
+ if (!q) return upstreamSections.value;
400
+ return upstreamSections.value.map((s) => {
401
+ if (s.name.toLowerCase().includes(q)) return s;
402
+ const ports = s.ports.map((p) => p.name.toLowerCase().includes(q) ? p : { ...p, fields: p.fields.filter((f) => f.name.toLowerCase().includes(q)) }).filter((p) => p.name.toLowerCase().includes(q) || p.fields.length > 0);
403
+ return { ...s, ports };
404
+ }).filter((s) => s.ports.length > 0);
405
+ });
406
+ const showTriggerChip = computed(() => !searchQuery.value || "trigger.payload".includes(searchQuery.value));
407
+ const visibleVariableNames = computed(() => {
408
+ const q = searchQuery.value;
409
+ return q ? variableNames.value.filter((n) => n.toLowerCase().includes(q)) : variableNames.value;
410
+ });
411
+ const searchEmpty = computed(
412
+ () => !!searchQuery.value && !visibleSections.value.length && !showTriggerChip.value && !visibleVariableNames.value.length
413
+ );
414
+ function highlightSegments(label) {
415
+ const q = searchQuery.value;
416
+ if (!q) return [{ text: label, hit: false }];
417
+ const lower = label.toLowerCase();
418
+ const out = [];
419
+ let i = 0;
420
+ while (i < label.length) {
421
+ const idx = lower.indexOf(q, i);
422
+ if (idx === -1) {
423
+ out.push({ text: label.slice(i), hit: false });
424
+ break;
425
+ }
426
+ if (idx > i) out.push({ text: label.slice(i, idx), hit: false });
427
+ out.push({ text: label.slice(idx, idx + q.length), hit: true });
428
+ i = idx + q.length;
429
+ }
430
+ return out;
431
+ }
432
+ const TYPE_GLYPHS = {
433
+ string: { char: "A", cls: "text-sky-600" },
434
+ number: { char: "#", cls: "text-amber-600" },
435
+ integer: { char: "#", cls: "text-amber-600" },
436
+ boolean: { char: "\u2713", cls: "text-emerald-600" },
437
+ object: { char: "{}", cls: "text-violet-600" },
438
+ array: { char: "[]", cls: "text-violet-600" }
439
+ };
440
+ function typeGlyph(dataType) {
441
+ return dataType ? TYPE_GLYPHS[dataType] ?? null : null;
442
+ }
443
+ const sequence = computed(() => [
444
+ ...(props.triggers ?? []).map((t) => t.id),
445
+ ...(props.nodes ?? []).map((n) => n.id)
446
+ ]);
447
+ const seqIndex = computed(() => sequence.value.indexOf(draft.value.id));
448
+ const hasPrev = computed(() => seqIndex.value > 0);
449
+ const hasNext = computed(() => seqIndex.value >= 0 && seqIndex.value < sequence.value.length - 1);
450
+ function navigate(offset) {
451
+ const target = sequence.value[seqIndex.value + offset];
452
+ if (!target) return;
453
+ if (dirty.value) emit("update", clone(draft.value));
454
+ emit("navigate", target);
455
+ }
456
+ const outputView = ref("schema");
457
+ const selfSample = computed(() => draftNode.value ? sampleFor(draftNode.value.id) : void 0);
458
+ const selfPorts = computed(() => draftNode.value ? withSampledFields(portsForNode(draftNode.value, manifest.value, props.dynamicOutputs?.[draftNode.value.id]), selfSample.value) : []);
459
+ const selfSampleJson = computed(() => selfSample.value ? JSON.stringify(selfSample.value, null, 2) : "");
460
+ const selfPinned = computed(() => !!draftNode.value && !!pinnedSamples.value[draftNode.value.id]);
461
+ watch(selfSample, (s) => {
462
+ if (!s && outputView.value === "json") outputView.value = "schema";
463
+ });
464
+ const pinEditorOpen = ref(false);
465
+ const pinDraft = ref("");
466
+ const pinError = ref(null);
467
+ function openPinEditor() {
468
+ pinDraft.value = JSON.stringify(selfSample.value ?? { default: {} }, null, 2);
469
+ pinError.value = null;
470
+ pinEditorOpen.value = true;
471
+ }
472
+ function savePin() {
473
+ if (!draftNode.value) return;
474
+ let parsed;
475
+ try {
476
+ parsed = JSON.parse(pinDraft.value);
477
+ } catch {
478
+ pinError.value = "Not valid JSON.";
479
+ return;
480
+ }
481
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
482
+ pinError.value = 'Must be an object mapping ports to payloads, e.g. { "default": { \u2026 } }.';
483
+ return;
484
+ }
485
+ pinnedSamples.value = { ...pinnedSamples.value, [draftNode.value.id]: parsed };
486
+ persistPins();
487
+ pinEditorOpen.value = false;
488
+ toast.success("Sample output pinned");
489
+ }
490
+ function removePin() {
491
+ if (!draftNode.value) return;
492
+ const next = { ...pinnedSamples.value };
493
+ delete next[draftNode.value.id];
494
+ pinnedSamples.value = next;
495
+ persistPins();
496
+ pinEditorOpen.value = false;
497
+ }
498
+ const segBase = "rounded px-1.5 py-0.5 text-[11px] font-medium transition";
499
+ const segActive = segBase + " bg-slate-100 text-foreground";
500
+ const segIdle = segBase + " text-muted-foreground hover:text-foreground disabled:opacity-40 disabled:pointer-events-none";
501
+ const api = useIntegrationsApi();
502
+ const testRunning = ref(false);
503
+ const testError = ref(null);
504
+ const testLogs = ref([]);
505
+ const testInputOpen = ref(false);
506
+ const testInputDraft = ref("");
507
+ const primaryUpstreamSample = computed(() => {
508
+ const first = upstreamSections.value.find((s) => s.direct);
509
+ if (!first) return {};
510
+ const def = sampleFor(first.id)?.default;
511
+ return def === void 0 ? {} : def;
512
+ });
513
+ function defaultTestInput() {
514
+ return JSON.stringify({ in: primaryUpstreamSample.value }, null, 2);
515
+ }
516
+ function toggleTestInput() {
517
+ if (!testInputOpen.value && !testInputDraft.value) testInputDraft.value = defaultTestInput();
518
+ testInputOpen.value = !testInputOpen.value;
519
+ }
520
+ async function runTestStep() {
521
+ const wn = draftNode.value;
522
+ if (!wn || testRunning.value) return;
523
+ let inputs;
524
+ try {
525
+ const parsed = JSON.parse(testInputDraft.value.trim() || defaultTestInput());
526
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
527
+ testError.value = "Test input must be a JSON object.";
528
+ return;
529
+ }
530
+ inputs = parsed;
531
+ } catch {
532
+ testError.value = "Test input is not valid JSON.";
533
+ testInputOpen.value = true;
534
+ return;
535
+ }
536
+ testRunning.value = true;
537
+ testError.value = null;
538
+ testLogs.value = [];
539
+ try {
540
+ const res = await api.post(
541
+ `/nodes/${encodeURIComponent(wn.nodeSlug)}/${encodeURIComponent(wn.nodeVersion)}/execute:test`,
542
+ { config: wn.config ?? {}, inputs }
543
+ );
544
+ testLogs.value = res.logs ?? [];
545
+ testOutputs.value = {
546
+ ...testOutputs.value,
547
+ [wn.id]: res.outputs && typeof res.outputs === "object" ? res.outputs : {}
548
+ };
549
+ outputView.value = "json";
550
+ toast.success("Test run finished");
551
+ } catch (e) {
552
+ const body = e.body;
553
+ testError.value = body?.error?.message ?? body?.message ?? e?.message ?? "Test run failed.";
554
+ testLogs.value = body?.logs ?? [];
555
+ } finally {
556
+ testRunning.value = false;
557
+ }
82
558
  }
83
559
  </script>
84
560
 
85
561
  <template>
86
- <aside class="flex h-full w-80 shrink-0 flex-col border-l border-black/[0.06] bg-white">
87
- <template v-if="selection">
88
- <div class="flex items-center justify-between border-b border-black/[0.06] px-3 py-2">
89
- <div class="min-w-0">
90
- <div class="text-mono text-muted-foreground">
91
- {{ isNode ? "Node" : "Trigger" }}
92
- </div>
93
- <div class="text-sm font-semibold truncate text-foreground">
94
- {{ labelText(selection.name) || (isNode ? selection.nodeSlug : selection.type) }}
95
- </div>
562
+ <!-- Fills the editor's near-fullscreen config lightbox; the Dialog owns
563
+ width/height, border and the close control. -->
564
+ <div class="flex min-h-0 flex-1 flex-col bg-white">
565
+ <!-- Header -->
566
+ <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3 pr-14">
567
+ <div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
568
+ <IntegrationsNodeLogo v-if="isNode" :manifest="manifest" :size="20" />
569
+ <component :is="triggerGlyph" v-else :size="18" />
570
+ </div>
571
+ <div class="min-w-0 flex-1">
572
+ <input
573
+ ref="nameInput"
574
+ :value="labelText(draft.name)"
575
+ :placeholder="namePlaceholder"
576
+ class="w-full truncate bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60"
577
+ @input="writeName($event.target.value)"
578
+ >
579
+ <div class="truncate font-mono text-[11px] text-muted-foreground">
580
+ {{ subtitleText }}
96
581
  </div>
582
+ </div>
583
+ <span class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-slate-600">
584
+ {{ groupLabel }}
585
+ </span>
586
+ <div v-if="sequence.length > 1" class="flex shrink-0 items-center gap-0.5 border-l border-black/[0.06] pl-2">
587
+ <button
588
+ type="button"
589
+ :disabled="!hasPrev"
590
+ title="Previous step (saves changes)"
591
+ class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
592
+ @click="navigate(-1)"
593
+ >
594
+ <ChevronLeft :size="14" />
595
+ </button>
97
596
  <button
98
597
  type="button"
99
- class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
100
- @click="emit('close')"
598
+ :disabled="!hasNext"
599
+ title="Next step (saves changes)"
600
+ class="flex size-7 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
601
+ @click="navigate(1)"
101
602
  >
102
- <X :size="14" />
603
+ <ChevronRight :size="14" />
103
604
  </button>
104
605
  </div>
606
+ </div>
105
607
 
106
- <div class="flex-1 overflow-y-auto p-3 space-y-4">
107
- <!-- Display name -->
108
- <div class="space-y-1.5">
109
- <Label class="text-mono text-muted-foreground">Display name</Label>
110
- <Input
111
- :model-value="labelText(selection.name)"
112
- class="h-8 text-sm bg-white"
113
- placeholder="Override the manifest name"
114
- @update:model-value="(v) => writeName(String(v))"
115
- />
608
+ <!-- Body: Input pane + tabbed center -->
609
+ <div class="flex min-h-0 flex-1">
610
+ <!-- Input pane -->
611
+ <aside class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
612
+ <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
613
+ <div class="text-sm font-semibold text-foreground">
614
+ Input
615
+ </div>
616
+ <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
617
+ Available data. Click a value to insert it into the focused field, or drag it onto one.
618
+ </p>
619
+ <div class="relative mt-2">
620
+ <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
621
+ <input
622
+ v-model="inputSearch"
623
+ type="text"
624
+ placeholder="Search data"
625
+ class="h-7 w-full rounded-md border border-black/[0.08] bg-white pl-6.5 pr-2 text-xs outline-none transition placeholder:text-muted-foreground/60 focus:border-black/[0.16]"
626
+ >
627
+ </div>
628
+ <div v-if="workflowId" class="mt-2 flex items-center gap-1.5">
629
+ <button
630
+ v-if="!runInfo"
631
+ type="button"
632
+ :disabled="runLoading"
633
+ class="rounded-md border border-black/[0.08] bg-white px-2 py-1 text-[11px] font-medium text-muted-foreground transition hover:bg-muted/30 hover:text-foreground disabled:opacity-50"
634
+ @click="loadRunData"
635
+ >
636
+ {{ runLoading ? "Loading run data\u2026" : "Use last run data" }}
637
+ </button>
638
+ <template v-else>
639
+ <span class="min-w-0 flex-1 truncate text-[11px] text-emerald-700">
640
+ Values from run #{{ runInfo.id }}
641
+ </span>
642
+ <button
643
+ type="button"
644
+ title="Clear run data"
645
+ class="flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-foreground"
646
+ @click="clearRunData"
647
+ >
648
+ <X :size="11" />
649
+ </button>
650
+ </template>
651
+ </div>
652
+ <p v-if="runError" class="mt-1 text-[11px] leading-snug text-amber-600">
653
+ {{ runError }}
654
+ </p>
655
+ </div>
656
+ <div class="min-h-0 flex-1 space-y-4 overflow-y-auto px-3 py-3">
657
+ <template v-if="isNode">
658
+ <!-- Upstream nodes -->
659
+ <div v-if="!searchQuery || visibleSections.length" class="space-y-3">
660
+ <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
661
+ Nodes
662
+ </div>
663
+ <p v-if="!upstreamSections.length" class="text-xs leading-snug text-muted-foreground">
664
+ No upstream nodes yet. Connect a node into this one to reference its output here.
665
+ </p>
666
+ <div v-for="s in visibleSections" :key="s.id" class="rounded-md border border-black/[0.06]">
667
+ <button
668
+ type="button"
669
+ class="flex w-full items-center gap-1.5 px-2 py-1.5 text-left"
670
+ @click="toggleNodeSection(s.id)"
671
+ >
672
+ <ChevronRight :size="11" class="shrink-0 text-muted-foreground transition-transform" :class="sectionClosed(s.id) ? '' : 'rotate-90'" />
673
+ <IntegrationsNodeLogo :manifest="s.manifest" :size="14" />
674
+ <span class="min-w-0 truncate text-xs font-medium text-foreground">{{ s.name }}</span>
675
+ <span
676
+ v-if="!s.direct"
677
+ class="shrink-0 rounded bg-slate-100 px-1 py-px text-[9px] font-medium uppercase tracking-wider text-slate-500"
678
+ title="Not directly connected; reachable further upstream"
679
+ >
680
+ upstream
681
+ </span>
682
+ </button>
683
+ <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
684
+ <div v-for="p in s.ports" :key="p.name" class="space-y-1 pl-0.5">
685
+ <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
686
+ <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
687
+ <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
688
+ </div>
689
+ <div class="flex flex-wrap gap-1">
690
+ <button
691
+ type="button"
692
+ :class="chipClass"
693
+ :title="nodeOutputExpr(s.id, p.name)"
694
+ draggable="true"
695
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
696
+ @click="insertExpression(nodeOutputExpr(s.id, p.name))"
697
+ ><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
698
+ <button
699
+ v-for="f in p.fields"
700
+ :key="f.name"
701
+ type="button"
702
+ :class="chipClass"
703
+ :title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
704
+ draggable="true"
705
+ @dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
706
+ @click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
707
+ ><span v-if="typeGlyph(f.dataType)" class="mr-1 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span><span v-if="chipValue(s.id, p.name, f.name) !== null" class="text-slate-400">: {{ chipValue(s.id, p.name, f.name) }}</span></button>
708
+ </div>
709
+ </div>
710
+ </div>
711
+ </div>
712
+ </div>
713
+
714
+ <!-- Trigger payload -->
715
+ <div v-if="showTriggerChip" class="space-y-1.5">
716
+ <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
717
+ Trigger
718
+ </div>
719
+ <div class="flex flex-wrap gap-1">
720
+ <button
721
+ type="button"
722
+ :class="chipClass"
723
+ :title="triggerPayloadExpr()"
724
+ draggable="true"
725
+ @dragstart="onChipDragStart($event, triggerPayloadExpr())"
726
+ @click="insertExpression(triggerPayloadExpr())"
727
+ >
728
+ trigger.payload
729
+ </button>
730
+ </div>
731
+ <p class="text-xs leading-snug text-muted-foreground">
732
+ The payload the run started with. Append a path for a single value, e.g. trigger.payload.orderId.
733
+ </p>
734
+ </div>
735
+ </template>
736
+
737
+ <p v-else class="text-xs leading-snug text-muted-foreground">
738
+ Triggers start the workflow, so there is no upstream data. Workflow variables are available everywhere.
739
+ </p>
740
+
741
+ <!-- Workflow variables -->
742
+ <div v-if="!searchQuery || visibleVariableNames.length" class="space-y-1.5">
743
+ <div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
744
+ Variables
745
+ </div>
746
+ <div v-if="visibleVariableNames.length" class="flex flex-wrap gap-1">
747
+ <button
748
+ v-for="name in visibleVariableNames"
749
+ :key="name"
750
+ type="button"
751
+ :class="chipClass"
752
+ :title="variableExpr(name)"
753
+ draggable="true"
754
+ @dragstart="onChipDragStart($event, variableExpr(name))"
755
+ @click="insertExpression(variableExpr(name))"
756
+ ><span v-for="(seg, i) in highlightSegments(name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></button>
757
+ </div>
758
+ <p v-else class="text-xs leading-snug text-muted-foreground">
759
+ No workflow variables defined.
760
+ </p>
761
+ </div>
762
+
763
+ <p v-if="searchEmpty" class="text-xs leading-snug text-muted-foreground">
764
+ Nothing matches "{{ inputSearch.trim() }}". Clear the search to see all available data.
765
+ </p>
766
+ </div>
767
+ </aside>
768
+
769
+ <!-- Center: tabs + content -->
770
+ <section class="flex min-w-0 flex-1 flex-col">
771
+ <div class="flex shrink-0 items-center gap-1 border-b border-black/[0.06] px-4">
772
+ <button
773
+ type="button"
774
+ class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
775
+ :class="tab === 'parameters' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
776
+ @click="tab = 'parameters'"
777
+ >
778
+ Parameters
779
+ </button>
780
+ <button
781
+ v-if="showSettingsTab"
782
+ type="button"
783
+ class="relative -mb-px border-b-2 px-2.5 py-2 text-sm font-medium transition"
784
+ :class="tab === 'settings' ? 'border-foreground text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground'"
785
+ @click="tab = 'settings'"
786
+ >
787
+ Settings
788
+ </button>
116
789
  </div>
117
790
 
118
- <!-- Trigger config (manual / schedule / webhook) -->
119
- <template v-if="!isNode">
120
- <div class="space-y-3">
121
- <template v-if="selection.type === 'schedule'">
791
+ <div
792
+ class="min-h-0 flex-1 overflow-y-auto p-4"
793
+ :class="dragActive ? 'chip-drag-live' : ''"
794
+ @focusin="onCenterFocusIn"
795
+ @dragenter="onCenterDragEnter"
796
+ @dragleave="onCenterDragLeave"
797
+ @drop="onCenterDrop"
798
+ >
799
+ <!-- Parameters: trigger config -->
800
+ <div v-if="!isNode && draftTrigger" class="mx-auto w-full max-w-3xl space-y-3">
801
+ <!-- Active toggle. Persists with the workflow save like every other trigger field. -->
802
+ <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
803
+ <div>
804
+ <div class="text-sm font-medium">Active</div>
805
+ <p class="text-xs text-muted-foreground">
806
+ A run needs at least one active trigger.
807
+ </p>
808
+ </div>
809
+ <Switch
810
+ :model-value="draftTrigger.active !== false"
811
+ @update:model-value="(v) => patchTrigger({ active: v })"
812
+ />
813
+ </div>
814
+ <template v-if="draftTrigger.type === 'schedule'">
122
815
  <div class="space-y-1.5">
123
816
  <Label class="text-mono text-muted-foreground">Cron</Label>
124
817
  <Input
@@ -138,7 +831,7 @@ function setArrayItemField(key, index, fieldKey, value) {
138
831
  />
139
832
  </div>
140
833
  </template>
141
- <template v-else-if="selection.type === 'webhook'">
834
+ <template v-else-if="draftTrigger.type === 'webhook'">
142
835
  <div class="space-y-1.5">
143
836
  <Label class="text-mono text-muted-foreground">Path</Label>
144
837
  <Input
@@ -181,13 +874,11 @@ function setArrayItemField(key, index, fieldKey, value) {
181
874
  </Select>
182
875
  </div>
183
876
  </template>
184
- <template v-else-if="selection.type === 'event'">
877
+ <template v-else-if="draftTrigger.type === 'event'">
185
878
  <div class="space-y-1.5">
186
879
  <Label class="text-mono text-muted-foreground">Event</Label>
187
- <Input
880
+ <EventTypeCombobox
188
881
  :model-value="readTriggerConfig('event_type') ?? ''"
189
- placeholder="customer.created"
190
- class="h-8 text-sm bg-white font-mono"
191
882
  @update:model-value="(v) => writeTriggerConfig('event_type', v)"
192
883
  />
193
884
  <p class="text-sm text-muted-foreground">
@@ -201,250 +892,339 @@ function setArrayItemField(key, index, fieldKey, value) {
201
892
  </p>
202
893
  </template>
203
894
  </div>
204
- </template>
205
895
 
206
- <!-- Node config — generated from manifest -->
207
- <template v-else>
208
- <div v-if="manifest?.config?.length" class="space-y-3">
209
- <div
210
- v-for="field in manifest.config"
211
- :key="field.key"
212
- class="space-y-1.5"
213
- >
896
+ <!-- Parameters: node config, generated from the manifest -->
897
+ <div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-3xl">
898
+ <div v-if="manifest?.config?.length" class="space-y-3">
214
899
  <div class="flex items-center justify-between">
215
- <Label class="text-mono text-muted-foreground">
216
- {{ labelText(field.label) }}{{ field.required ? " *" : "" }}
217
- </Label>
218
- <span v-if="field.expressionAllowed" class="text-sm font-mono text-emerald-600/70">
219
- {{ exprHint }}
220
- </span>
900
+ <span class="text-mono text-muted-foreground">Configuration</span>
901
+ <div class="flex items-center gap-2">
902
+ <span v-if="requiredMissingCount" class="text-sm text-amber-600">
903
+ {{ requiredMissingCount }} required left
904
+ </span>
905
+ <button
906
+ type="button"
907
+ class="flex items-center gap-1 rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
908
+ title="Copy this node's config as JSON"
909
+ @click="copyConfig"
910
+ >
911
+ <Copy :size="12" /> Copy
912
+ </button>
913
+ <button
914
+ type="button"
915
+ class="rounded px-1 py-0.5 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
916
+ title="Replace this node's config from a JSON object in the clipboard"
917
+ @click="pasteConfig"
918
+ >
919
+ Paste
920
+ </button>
921
+ </div>
221
922
  </div>
222
-
223
- <!-- string / expression -->
224
- <Input
225
- v-if="['string', 'expression'].includes(field.type)"
226
- :model-value="readConfig(field.key) ?? ''"
227
- :placeholder="labelText(field.placeholder)"
228
- class="h-8 text-sm bg-white"
229
- :class="field.type === 'expression' ? 'font-mono' : ''"
230
- @update:model-value="(v) => writeConfig(field.key, v)"
923
+ <IntegrationsNodeConfigField
924
+ v-for="field in parameterFields"
925
+ :key="field.key"
926
+ :field="field"
927
+ :config="draftNode.config ?? {}"
928
+ :slug="draftNode.nodeSlug"
929
+ :version="draftNode.nodeVersion"
930
+ :secret-keys="secretKeys"
931
+ :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
932
+ :depth="0"
933
+ :variables="variables"
934
+ :upstream-outputs="upstreamOutputs"
935
+ @set="(k, v) => writeConfig(k, v)"
231
936
  />
937
+ <p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
938
+ All of this node's options live on the Settings tab.
939
+ </p>
940
+ </div>
941
+ <div v-else class="text-sm text-muted-foreground italic">
942
+ This node has no configuration.
943
+ </div>
944
+ </div>
232
945
 
233
- <!-- number -->
234
- <Input
235
- v-else-if="field.type === 'number'"
236
- type="number"
237
- :model-value="readConfig(field.key) ?? ''"
238
- class="h-8 text-sm bg-white"
239
- @update:model-value="(v) => writeConfig(field.key, v === '' ? null : Number(v))"
946
+ <!-- Settings: node-instance execution settings -->
947
+ <div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-3xl space-y-6">
948
+ <!-- Node behavior: settings-like fields from the node's own config -->
949
+ <div v-if="settingsFields.length" class="space-y-3">
950
+ <span class="text-mono text-muted-foreground">Node behavior</span>
951
+ <IntegrationsNodeConfigField
952
+ v-for="field in settingsFields"
953
+ :key="field.key"
954
+ :field="field"
955
+ :config="draftNode.config ?? {}"
956
+ :slug="draftNode.nodeSlug"
957
+ :version="draftNode.nodeVersion"
958
+ :secret-keys="secretKeys"
959
+ :static-keys="(manifest?.config ?? []).map((f2) => f2.key)"
960
+ :depth="0"
961
+ :variables="variables"
962
+ :upstream-outputs="upstreamOutputs"
963
+ @set="(k, v) => writeConfig(k, v)"
240
964
  />
241
-
242
- <!-- boolean -->
243
- <div v-else-if="field.type === 'boolean'" class="flex items-center gap-2 pt-1">
965
+ </div>
966
+ <!-- Retry policy -->
967
+ <section class="space-y-2">
968
+ <div class="flex items-center justify-between rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
969
+ <div>
970
+ <div class="text-sm font-medium">Retry on failure</div>
971
+ <p class="text-xs text-muted-foreground">
972
+ Re-run this node automatically when an attempt fails.
973
+ </p>
974
+ </div>
244
975
  <Switch
245
- :model-value="Boolean(readConfig(field.key))"
246
- @update:model-value="(v) => writeConfig(field.key, v)"
976
+ :model-value="!!draftNode.retry"
977
+ @update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
247
978
  />
248
- <span class="text-sm text-muted-foreground">
249
- {{ labelText(field.description) }}
250
- </span>
251
979
  </div>
252
-
253
- <!-- select -->
254
- <Select
255
- v-else-if="field.type === 'select'"
256
- :model-value="readConfig(field.key) ?? ''"
257
- @update:model-value="(v) => writeConfig(field.key, v)"
258
- >
259
- <SelectTrigger class="h-8 text-sm bg-white">
260
- <SelectValue :placeholder="labelText(field.placeholder) || 'Pick one'" />
261
- </SelectTrigger>
262
- <SelectContent>
263
- <SelectItem v-for="opt in field.options ?? []" :key="String(opt.value)" :value="String(opt.value)">
264
- {{ labelText(opt.label) }}
265
- </SelectItem>
266
- </SelectContent>
267
- </Select>
268
-
269
- <!-- multiselect — pill toggles for now -->
270
- <div v-else-if="field.type === 'multiselect'" class="flex flex-wrap gap-1">
271
- <button
272
- v-for="opt in field.options ?? []"
273
- :key="String(opt.value)"
274
- type="button"
275
- class="rounded-md border border-black/[0.08] px-1.5 py-0.5 text-sm transition"
276
- :class="(readConfig(field.key) ?? []).includes(opt.value) ? 'bg-emerald-500/15 text-emerald-700 border-emerald-500/30' : 'bg-white text-muted-foreground hover:text-foreground'"
277
- @click="() => {
278
- const current = readConfig(field.key) ?? [];
279
- const has = current.includes(opt.value);
280
- writeConfig(field.key, has ? current.filter((v) => v !== opt.value) : [...current, opt.value]);
281
- }"
282
- >
283
- {{ labelText(opt.label) }}
284
- </button>
980
+ <div v-if="draftNode.retry" class="grid grid-cols-2 gap-3 rounded-lg border border-black/[0.06] p-3">
981
+ <div class="space-y-1.5">
982
+ <Label class="text-mono text-muted-foreground">Max attempts</Label>
983
+ <Input
984
+ type="number"
985
+ min="1"
986
+ :model-value="draftNode.retry?.maxAttempts ?? 3"
987
+ class="h-8 text-sm bg-white"
988
+ @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, maxAttempts: Number(v) || 1 } })"
989
+ />
990
+ </div>
991
+ <div class="space-y-1.5">
992
+ <Label class="text-mono text-muted-foreground">Backoff seconds</Label>
993
+ <Input
994
+ type="number"
995
+ min="0"
996
+ :model-value="draftNode.retry?.backoffSeconds ?? 0"
997
+ class="h-8 text-sm bg-white"
998
+ @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, backoffSeconds: Number(v) || 0 } })"
999
+ />
1000
+ </div>
1001
+ <div class="col-span-2 space-y-1.5">
1002
+ <Label class="text-mono text-muted-foreground">Strategy</Label>
1003
+ <Select
1004
+ :model-value="draftNode.retry?.strategy ?? 'exponential'"
1005
+ @update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
1006
+ >
1007
+ <SelectTrigger class="h-8 text-sm bg-white">
1008
+ <SelectValue />
1009
+ </SelectTrigger>
1010
+ <SelectContent>
1011
+ <SelectItem value="linear">linear backoff</SelectItem>
1012
+ <SelectItem value="exponential">exponential backoff</SelectItem>
1013
+ </SelectContent>
1014
+ </Select>
1015
+ </div>
285
1016
  </div>
1017
+ </section>
1018
+
1019
+ <!-- Timeout: engine start-to-close ceiling for one attempt -->
1020
+ <section class="space-y-1.5">
1021
+ <Label class="text-mono text-muted-foreground">Timeout</Label>
1022
+ <Input
1023
+ type="number"
1024
+ min="1"
1025
+ max="7200"
1026
+ placeholder="600"
1027
+ :model-value="draftNode.timeoutSeconds ?? ''"
1028
+ class="h-8 text-sm bg-white"
1029
+ @update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
1030
+ />
1031
+ <p class="text-xs text-muted-foreground">
1032
+ Seconds per attempt before the engine cancels the node. Empty uses the default (10 minutes); maximum 2 hours.
1033
+ </p>
1034
+ </section>
286
1035
 
287
- <!-- secret-ref -->
1036
+ <!-- On error: what happens once the retries are exhausted -->
1037
+ <section class="space-y-1.5">
1038
+ <Label class="text-mono text-muted-foreground">On error</Label>
288
1039
  <Select
289
- v-else-if="field.type === 'secret-ref'"
290
- :model-value="readConfig(field.key) ?? ''"
291
- @update:model-value="(v) => writeConfig(field.key, v)"
1040
+ :model-value="draftNode.onError ?? 'fail'"
1041
+ @update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
292
1042
  >
293
1043
  <SelectTrigger class="h-8 text-sm bg-white">
294
- <SelectValue placeholder="Pick a secret" />
1044
+ <SelectValue />
295
1045
  </SelectTrigger>
296
1046
  <SelectContent>
297
- <SelectItem v-for="k in secretKeys" :key="k" :value="k">{{ k }}</SelectItem>
1047
+ <SelectItem value="fail">Fail the run</SelectItem>
1048
+ <SelectItem value="route">Route to error output</SelectItem>
298
1049
  </SelectContent>
299
1050
  </Select>
1051
+ <p class="text-xs text-muted-foreground">
1052
+ Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
1053
+ </p>
1054
+ </section>
300
1055
 
301
- <!-- credentials-ref — pick a credential of the required type -->
302
- <template v-else-if="field.type === 'credentials-ref'">
303
- <Select
304
- :model-value="readConfig(field.key) ?? ''"
305
- @update:model-value="(v) => writeConfig(field.key, v)"
306
- >
307
- <SelectTrigger class="h-8 text-sm bg-white">
308
- <SelectValue placeholder="Pick a credential" />
309
- </SelectTrigger>
310
- <SelectContent>
311
- <SelectItem v-for="c in credsOfType(field.credentialType)" :key="c.id" :value="c.id">
312
- {{ c.name }}
313
- </SelectItem>
314
- </SelectContent>
315
- </Select>
316
- <p v-if="credNeedsReauth(readConfig(field.key))" class="text-sm text-amber-600">
317
- This credential needs reconnect.
318
- </p>
319
- <NuxtLink to="/integrations/credentials" class="text-sm text-violet-600 hover:underline">
320
- Manage credentials →
321
- </NuxtLink>
322
- </template>
323
-
324
- <!-- array of objects (e.g. headers / mappings) -->
325
- <div v-else-if="field.type === 'array' && field.items?.type === 'object'" class="space-y-2">
326
- <div
327
- v-for="(item, idx) in readArray(field.key)"
328
- :key="idx"
329
- class="rounded-md border border-black/[0.06] bg-muted/20 p-2 space-y-1.5"
330
- >
331
- <div class="flex items-center justify-between">
332
- <span class="text-mono text-muted-foreground">
333
- #{{ idx + 1 }}
334
- </span>
335
- <button
336
- type="button"
337
- class="flex size-5 items-center justify-center rounded text-muted-foreground hover:text-rose-600"
338
- @click="removeArrayItem(field.key, idx)"
339
- >
340
- <Trash2 :size="11" />
341
- </button>
342
- </div>
343
- <div
344
- v-for="sub in field.items.properties ?? []"
345
- :key="sub.key"
346
- class="space-y-1"
347
- >
348
- <Label class="text-mono text-muted-foreground">
349
- {{ labelText(sub.label) }}
350
- </Label>
351
- <Input
352
- :model-value="item[sub.key] ?? ''"
353
- :placeholder="labelText(sub.placeholder)"
354
- class="h-7 text-sm bg-white"
355
- :class="sub.type === 'expression' ? 'font-mono' : ''"
356
- @update:model-value="(v) => setArrayItemField(field.key, idx, sub.key, v)"
357
- />
358
- </div>
359
- </div>
360
- <Button
361
- type="button"
362
- variant="outline"
363
- size="sm"
364
- class="h-7 text-sm w-full"
365
- @click="pushArrayItem(field.key, Object.fromEntries((field.items?.properties ?? []).map((p) => [p.key, ''])))"
366
- >
367
- <Plus :size="11" />
368
- Add row
369
- </Button>
370
- </div>
371
-
372
- <!-- fallback: JSON textarea for complex types -->
1056
+ <!-- Notes: editor-only blob field -->
1057
+ <section class="space-y-1.5">
1058
+ <Label class="text-mono text-muted-foreground">Notes</Label>
373
1059
  <Textarea
374
- v-else
375
- :model-value="readConfig(field.key) === void 0 ? '' : JSON.stringify(readConfig(field.key), null, 2)"
376
- rows="3"
377
- class="text-sm font-mono bg-white"
378
- @update:model-value="(v) => {
379
- try {
380
- writeConfig(field.key, v ? JSON.parse(String(v)) : void 0);
381
- } catch {
382
- }
383
- }"
1060
+ :model-value="draftNode.notes ?? ''"
1061
+ :rows="3"
1062
+ placeholder="Optional note about this node"
1063
+ class="text-sm bg-white"
1064
+ @update:model-value="(v) => patchNode({ notes: String(v) || void 0 })"
384
1065
  />
385
-
386
- <p v-if="field.description" class="text-sm text-muted-foreground">
387
- {{ labelText(field.description) }}
1066
+ <p class="text-xs text-muted-foreground">
1067
+ Only shown in the editor; never sent to the engine.
388
1068
  </p>
1069
+ </section>
1070
+ </div>
1071
+ </div>
1072
+ </section>
1073
+
1074
+ <!-- Output pane: what this node hands downstream -->
1075
+ <aside v-if="isNode" class="flex w-[280px] shrink-0 flex-col border-l border-black/[0.06] bg-muted/10">
1076
+ <div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
1077
+ <div class="flex items-center justify-between gap-2">
1078
+ <div class="text-sm font-semibold text-foreground">
1079
+ Output
1080
+ </div>
1081
+ <div class="flex items-center rounded-md border border-black/[0.06] bg-white p-0.5">
1082
+ <button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
1083
+ Schema
1084
+ </button>
1085
+ <button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
1086
+ JSON
1087
+ </button>
389
1088
  </div>
390
1089
  </div>
391
- <div v-else class="text-sm text-muted-foreground italic">
392
- This node has no configuration.
1090
+ <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1091
+ What downstream nodes receive from this one.
1092
+ </p>
1093
+ </div>
1094
+ <div class="shrink-0 space-y-1.5 border-b border-black/[0.06] px-3 py-2">
1095
+ <div class="flex items-center gap-2">
1096
+ <Button
1097
+ size="sm"
1098
+ class="h-7 flex-1 text-xs"
1099
+ :disabled="testRunning"
1100
+ title="Runs this node once with the current draft config"
1101
+ @click="runTestStep"
1102
+ >
1103
+ {{ testRunning ? "Testing\u2026" : "Test step" }}
1104
+ </Button>
1105
+ <button
1106
+ type="button"
1107
+ class="shrink-0 text-[11px] font-medium text-muted-foreground transition hover:text-foreground"
1108
+ @click="toggleTestInput"
1109
+ >
1110
+ {{ testInputOpen ? "Hide input" : "Edit input" }}
1111
+ </button>
393
1112
  </div>
394
-
395
- <!-- Retry section (always available on nodes) -->
396
- <div class="space-y-1.5 border-t border-black/[0.06] pt-3 mt-3">
397
- <Label class="text-mono text-muted-foreground">Retry policy</Label>
398
- <div class="flex items-center gap-2">
399
- <Switch
400
- :model-value="!!selection.retry"
401
- @update:model-value="(v) => emit('update', { ...selection, retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
402
- />
403
- <span class="text-sm text-muted-foreground">Retry on failure</span>
1113
+ <Textarea
1114
+ v-if="testInputOpen"
1115
+ v-model="testInputDraft"
1116
+ :rows="5"
1117
+ spellcheck="false"
1118
+ class="bg-white font-mono text-[11px]"
1119
+ />
1120
+ <p v-if="testError" class="text-[11px] leading-snug text-rose-600">
1121
+ {{ testError }}
1122
+ </p>
1123
+ <div v-if="testLogs.length" class="max-h-24 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-1.5">
1124
+ <div
1125
+ v-for="(l, i) in testLogs"
1126
+ :key="i"
1127
+ class="font-mono text-[10px] leading-snug"
1128
+ :class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
1129
+ >
1130
+ {{ l.message }}
404
1131
  </div>
405
- <template v-if="selection.retry">
406
- <div class="grid grid-cols-2 gap-2 pt-1">
407
- <Input
408
- type="number"
409
- :model-value="selection.retry?.maxAttempts ?? 3"
410
- class="h-7 text-sm bg-white"
411
- @update:model-value="(v) => emit('update', { ...selection, retry: { ...selection.retry, maxAttempts: Number(v) || 1 } })"
412
- />
413
- <Input
414
- type="number"
415
- :model-value="selection.retry?.backoffSeconds ?? 0"
416
- class="h-7 text-sm bg-white"
417
- @update:model-value="(v) => emit('update', { ...selection, retry: { ...selection.retry, backoffSeconds: Number(v) || 0 } })"
418
- />
1132
+ </div>
1133
+ </div>
1134
+ <div class="min-h-0 flex-1 overflow-y-auto px-3 py-3">
1135
+ <template v-if="outputView === 'schema'">
1136
+ <div v-for="p in selfPorts" :key="p.name" class="mb-3 space-y-1">
1137
+ <div class="flex items-center gap-1.5">
1138
+ <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1139
+ <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
419
1140
  </div>
420
- <Select
421
- :model-value="selection.retry?.strategy ?? 'exponential'"
422
- @update:model-value="(v) => emit('update', { ...selection, retry: { ...selection.retry, strategy: v } })"
1141
+ <div v-if="p.fields.length" class="space-y-0.5">
1142
+ <div v-for="f in p.fields" :key="f.name" class="flex min-w-0 items-baseline gap-1 font-mono text-[11px]">
1143
+ <span v-if="typeGlyph(f.dataType)" class="shrink-0 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span>
1144
+ <span class="shrink-0 text-slate-700">{{ f.name }}</span>
1145
+ <span v-if="chipValue(draft.id, p.name, f.name) !== null" class="min-w-0 truncate text-slate-400">: {{ chipValue(draft.id, p.name, f.name) }}</span>
1146
+ </div>
1147
+ </div>
1148
+ <p v-else class="text-[11px] leading-snug text-muted-foreground">
1149
+ No declared fields<template v-if="chipValue(draft.id, p.name) !== null">; sampled: <span class="font-mono">{{ chipValue(draft.id, p.name) }}</span></template>.
1150
+ </p>
1151
+ </div>
1152
+ <p v-if="!selfPorts.length" class="text-xs leading-snug text-muted-foreground">
1153
+ This node declares no outputs.
1154
+ </p>
1155
+ </template>
1156
+ <pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-2 font-mono text-[11px] leading-snug">{{ selfSampleJson }}</pre>
1157
+ </div>
1158
+ <div class="shrink-0 border-t border-black/[0.06] px-3 py-2">
1159
+ <template v-if="!pinEditorOpen">
1160
+ <div class="flex items-center gap-2">
1161
+ <button
1162
+ type="button"
1163
+ class="rounded-md border border-black/[0.08] bg-white px-2 py-1 text-[11px] font-medium text-muted-foreground transition hover:bg-muted/30 hover:text-foreground"
1164
+ @click="openPinEditor"
423
1165
  >
424
- <SelectTrigger class="h-7 text-sm bg-white">
425
- <SelectValue />
426
- </SelectTrigger>
427
- <SelectContent>
428
- <SelectItem value="linear">linear backoff</SelectItem>
429
- <SelectItem value="exponential">exponential backoff</SelectItem>
430
- </SelectContent>
431
- </Select>
432
- </template>
433
- </div>
434
- </template>
435
- </div>
1166
+ {{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
1167
+ </button>
1168
+ <button
1169
+ v-if="selfPinned"
1170
+ type="button"
1171
+ class="text-[11px] font-medium text-rose-600 transition hover:text-rose-700"
1172
+ @click="removePin"
1173
+ >
1174
+ Unpin
1175
+ </button>
1176
+ </div>
1177
+ <p class="mt-1 text-[11px] leading-snug text-muted-foreground">
1178
+ A pinned sample stands in for run data while you map downstream fields.
1179
+ </p>
1180
+ </template>
1181
+ <template v-else>
1182
+ <Textarea
1183
+ v-model="pinDraft"
1184
+ :rows="8"
1185
+ spellcheck="false"
1186
+ class="bg-white font-mono text-[11px]"
1187
+ />
1188
+ <p v-if="pinError" class="mt-1 text-[11px] text-amber-600">
1189
+ {{ pinError }}
1190
+ </p>
1191
+ <div class="mt-1.5 flex items-center gap-2">
1192
+ <Button size="sm" class="h-7 text-xs" @click="savePin">
1193
+ Pin
1194
+ </Button>
1195
+ <Button size="sm" variant="outline" class="h-7 text-xs" @click="pinEditorOpen = false">
1196
+ Cancel
1197
+ </Button>
1198
+ </div>
1199
+ </template>
1200
+ </div>
1201
+ </aside>
1202
+ </div>
436
1203
 
437
- <div class="border-t border-black/[0.06] p-3">
438
- <Button
439
- variant="outline"
440
- size="sm"
441
- class="w-full h-7 text-sm text-rose-600 hover:text-rose-700"
442
- @click="emit('remove')"
443
- >
444
- <Trash2 :size="12" />
445
- Remove from workflow
1204
+ <!-- Footer: draft semantics — nothing reaches the blob until Save -->
1205
+ <div class="flex shrink-0 items-center justify-between gap-3 border-t border-black/[0.06] px-4 py-3">
1206
+ <Button
1207
+ variant="ghost"
1208
+ size="sm"
1209
+ class="h-8 text-sm text-rose-600 hover:bg-rose-500/10 hover:text-rose-700"
1210
+ @click="emit('remove')"
1211
+ >
1212
+ <Trash2 :size="13" />
1213
+ Remove from workflow
1214
+ </Button>
1215
+ <div class="flex items-center gap-2">
1216
+ <span v-if="dirty" class="text-xs text-muted-foreground">Unsaved changes</span>
1217
+ <Button variant="outline" size="sm" class="h-8" @click="emit('close')">
1218
+ Cancel
1219
+ </Button>
1220
+ <Button size="sm" class="h-8" @click="save">
1221
+ Save
446
1222
  </Button>
447
1223
  </div>
448
- </template>
449
- </aside>
1224
+ </div>
1225
+ </div>
450
1226
  </template>
1227
+
1228
+ <style>
1229
+ .chip-drag-live .expr-editor,.chip-drag-live input:not([type=number]):not([type=checkbox]):not([type=radio]):not([type=range]):not([type=file]):not([type=color]),.chip-drag-live textarea{border-color:rgba(16,185,129,.55);border-style:dashed}.chip-drop-hover{background-color:rgba(16,185,129,.06)!important;border-color:#10b981!important;border-style:solid!important;box-shadow:0 0 0 3px rgba(16,185,129,.15)}
1230
+ </style>