@revenexx/studio-integrations 0.5.1 → 0.6.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.
- package/CHANGELOG.md +201 -0
- package/README.md +17 -4
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
- package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
- package/dist/runtime/components/Integrations/Layout.vue +21 -2
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
- package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
- package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
- package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
- package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
- package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
- package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
- package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
- package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
- package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
- package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
- package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
- package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
- package/dist/runtime/composables/useCredentialTypes.js +4 -4
- package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
- package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
- package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
- package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
- package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
- package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
- package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
- package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
- package/dist/runtime/composables/useLoadGate.d.ts +17 -0
- package/dist/runtime/composables/useLoadGate.js +8 -0
- package/dist/runtime/data/branchReorder.d.ts +37 -0
- package/dist/runtime/data/branchReorder.js +71 -0
- package/dist/runtime/data/handleId.d.ts +23 -0
- package/dist/runtime/data/handleId.js +6 -0
- package/dist/runtime/data/integrationTemplates.d.ts +39 -0
- package/dist/runtime/data/integrationTemplates.js +15 -0
- package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
- package/dist/runtime/data/nodeIcons.d.ts +2 -0
- package/dist/runtime/data/nodeIcons.js +73 -0
- package/dist/runtime/data/nodeInputData.d.ts +16 -4
- package/dist/runtime/data/nodeInputData.js +4 -0
- package/dist/runtime/data/payloadForm.d.ts +9 -2
- package/dist/runtime/data/payloadForm.js +27 -3
- package/dist/runtime/data/portTone.d.ts +17 -0
- package/dist/runtime/data/portTone.js +7 -0
- package/dist/runtime/data/webhookAuth.d.ts +18 -0
- package/dist/runtime/data/webhookAuth.js +19 -0
- package/dist/runtime/data/workflowIconCatalog.js +2 -5
- package/dist/runtime/pages/integrations/audit.vue +91 -6
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
- package/dist/runtime/pages/integrations/credentials.vue +119 -21
- package/dist/runtime/pages/integrations/index.vue +49 -44
- package/dist/runtime/pages/integrations/runs.vue +1 -1
- package/dist/runtime/pages/integrations/schedules.vue +51 -7
- package/dist/runtime/pages/integrations/secrets.vue +29 -7
- package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
- package/dist/runtime/pages/integrations/templates/index.vue +57 -86
- package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
- package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
- package/dist/runtime/types/integrations.d.ts +65 -6
- package/dist/runtime/utils/apiError.d.ts +47 -0
- package/dist/runtime/utils/apiError.js +32 -0
- package/dist/runtime/utils/cockpitSources.d.ts +153 -0
- package/dist/runtime/utils/cockpitSources.js +14 -0
- package/dist/runtime/utils/loadGate.d.ts +61 -0
- package/dist/runtime/utils/loadGate.js +22 -0
- package/dist/runtime/utils/localize.d.ts +29 -0
- package/dist/runtime/utils/localize.js +4 -0
- package/dist/runtime/utils/notify.d.ts +59 -0
- package/dist/runtime/utils/notify.js +21 -0
- package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
- package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
- package/dist/runtime/utils/schedule.d.ts +141 -0
- package/dist/runtime/utils/schedule.js +207 -0
- package/dist/runtime/utils/validationFindings.d.ts +101 -0
- package/dist/runtime/utils/validationFindings.js +53 -0
- package/dist/runtime/utils/whenIdle.d.ts +42 -0
- package/dist/runtime/utils/whenIdle.js +12 -0
- package/package.json +8 -2
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
<script setup>
|
|
2
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";
|
|
3
|
+
import { AddCircle as Plus, TrashBinMinimalistic as Trash2, Copy } from "#solar-icons";
|
|
4
|
+
import { ChevronDown, ChevronLeft, ChevronRight, Clock, Database, MessageSquareText, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
|
|
5
5
|
import { toast } from "vue-sonner";
|
|
6
6
|
import {
|
|
7
7
|
Button,
|
|
8
|
+
Dialog,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogHeader,
|
|
12
|
+
DialogTitle,
|
|
8
13
|
Input,
|
|
9
14
|
Label,
|
|
15
|
+
Popover,
|
|
16
|
+
PopoverContent,
|
|
17
|
+
PopoverTrigger,
|
|
10
18
|
Select,
|
|
11
19
|
SelectContent,
|
|
12
20
|
SelectItem,
|
|
21
|
+
SelectSeparator,
|
|
13
22
|
SelectTrigger,
|
|
14
23
|
SelectValue,
|
|
15
24
|
Switch,
|
|
16
|
-
Textarea
|
|
25
|
+
Textarea,
|
|
26
|
+
ToggleGroup,
|
|
27
|
+
ToggleGroupItem
|
|
17
28
|
} from "@revenexx/studio";
|
|
18
29
|
import EventTypeCombobox from "./EventTypeCombobox.vue";
|
|
19
30
|
import { NODE_CATEGORIES, findManifest } from "../../data/integrationNodeCatalog";
|
|
@@ -24,12 +35,16 @@ import {
|
|
|
24
35
|
triggerPayloadExpr,
|
|
25
36
|
variableExpr
|
|
26
37
|
} from "../../data/nodeInputData";
|
|
38
|
+
import { portDotClass } from "../../data/portTone";
|
|
39
|
+
import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
|
|
27
40
|
import {
|
|
28
41
|
dataTypeOf,
|
|
29
42
|
portValuesFromStepOutput,
|
|
30
43
|
previewValue,
|
|
31
44
|
valueAtPath
|
|
32
45
|
} from "../../data/nodeRunData";
|
|
46
|
+
import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
|
|
47
|
+
import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
|
|
33
48
|
const props = defineProps({
|
|
34
49
|
selection: { type: [Object, null], required: true },
|
|
35
50
|
secretKeys: { type: Array, required: true },
|
|
@@ -40,7 +55,8 @@ const props = defineProps({
|
|
|
40
55
|
edges: { type: Array, required: false },
|
|
41
56
|
triggers: { type: Array, required: false },
|
|
42
57
|
dynamicOutputs: { type: Object, required: false },
|
|
43
|
-
workflowId: { type: [Number, String], required: false }
|
|
58
|
+
workflowId: { type: [Number, String], required: false },
|
|
59
|
+
refusedFields: { type: Object, required: false }
|
|
44
60
|
});
|
|
45
61
|
const emit = defineEmits(["update", "close", "remove", "navigate"]);
|
|
46
62
|
function clone(v) {
|
|
@@ -70,15 +86,11 @@ const manifest = computed(() => {
|
|
|
70
86
|
const wn = draftNode.value;
|
|
71
87
|
return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
|
|
72
88
|
});
|
|
73
|
-
function labelText(v) {
|
|
74
|
-
if (!v) return "";
|
|
75
|
-
return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
|
|
76
|
-
}
|
|
77
89
|
const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
|
|
78
90
|
const TRIGGER_LABELS = { manual: "Manual", schedule: "Schedule", webhook: "Webhook", event: "Event" };
|
|
79
91
|
const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
|
|
80
92
|
const namePlaceholder = computed(() => {
|
|
81
|
-
if (draftNode.value) return
|
|
93
|
+
if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
|
|
82
94
|
return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
|
|
83
95
|
});
|
|
84
96
|
const subtitleText = computed(() => {
|
|
@@ -86,20 +98,49 @@ const subtitleText = computed(() => {
|
|
|
86
98
|
if (wn) return wn.nodeSlug + "@" + wn.nodeVersion;
|
|
87
99
|
return TRIGGER_LABELS[draftTrigger.value?.type ?? "manual"] + " trigger";
|
|
88
100
|
});
|
|
101
|
+
const manifestDescription = computed(() => resolveLocalized(manifest.value?.description));
|
|
89
102
|
const groupLabel = computed(() => {
|
|
90
103
|
if (!isNode.value) return "Trigger";
|
|
91
104
|
const groups = manifest.value?.groups;
|
|
92
|
-
if (groups?.length) return
|
|
105
|
+
if (groups?.length) return resolveLocalized(groups[groups.length - 1]);
|
|
93
106
|
return NODE_CATEGORIES.find((c) => c.key === manifest.value?.category)?.label ?? "Node";
|
|
94
107
|
});
|
|
108
|
+
const nameValue = computed(() => {
|
|
109
|
+
const name = draft.value.name;
|
|
110
|
+
return resolveDisplayName(name);
|
|
111
|
+
});
|
|
95
112
|
function writeName(name) {
|
|
96
113
|
draft.value = { ...draft.value, name };
|
|
97
114
|
}
|
|
115
|
+
const noteOpen = ref(false);
|
|
116
|
+
const noteText = computed(() => (draftNode.value?.notes ?? "").trim());
|
|
117
|
+
const noteLabel = computed(() => noteText.value ? "Note" : "Add note");
|
|
118
|
+
function writeNote(v) {
|
|
119
|
+
const text = String(v ?? "");
|
|
120
|
+
patchNode({ notes: text.trim() ? text : void 0 });
|
|
121
|
+
}
|
|
98
122
|
const tab = ref("parameters");
|
|
99
123
|
const showSettingsTab = computed(() => isNode.value);
|
|
124
|
+
const inspectorTabs = computed(() => [
|
|
125
|
+
{ key: "parameters", label: "Parameters" },
|
|
126
|
+
...showSettingsTab.value ? [{ key: "settings", label: "Settings" }, { key: "output", label: "Output" }] : []
|
|
127
|
+
]);
|
|
128
|
+
watch(showSettingsTab, (hasThem) => {
|
|
129
|
+
if (!hasThem) tab.value = "parameters";
|
|
130
|
+
});
|
|
100
131
|
const SETTINGS_FIELD_KEY = /(^|_)(timeout|retry|retries|backoff)|(timeout|retry|retries|backoff)(ms|sec|seconds|attempts)?$/i;
|
|
101
132
|
const parameterFields = computed(() => (manifest.value?.config ?? []).filter((f) => !SETTINGS_FIELD_KEY.test(f.key)));
|
|
102
133
|
const settingsFields = computed(() => (manifest.value?.config ?? []).filter((f) => SETTINGS_FIELD_KEY.test(f.key)));
|
|
134
|
+
const requiredFields = computed(() => parameterFields.value.filter((f) => f.required));
|
|
135
|
+
const optionalFields = computed(() => parameterFields.value.filter((f) => !f.required));
|
|
136
|
+
function optionalShouldOpen() {
|
|
137
|
+
if (!requiredFields.value.length) return true;
|
|
138
|
+
return optionalFields.value.some((f) => props.refusedFields?.[f.key] !== void 0);
|
|
139
|
+
}
|
|
140
|
+
const optionalOpen = ref(optionalShouldOpen());
|
|
141
|
+
watch([() => props.selection?.id, manifest], () => {
|
|
142
|
+
optionalOpen.value = optionalShouldOpen();
|
|
143
|
+
});
|
|
103
144
|
function dependentKeys(changedKey) {
|
|
104
145
|
const fields = manifest.value?.config ?? [];
|
|
105
146
|
const out = /* @__PURE__ */ new Set();
|
|
@@ -139,12 +180,6 @@ function patchTrigger(patch) {
|
|
|
139
180
|
if (!draftTrigger.value) return;
|
|
140
181
|
draft.value = { ...draftTrigger.value, ...patch };
|
|
141
182
|
}
|
|
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
183
|
async function copyConfig() {
|
|
149
184
|
if (!draftNode.value) return;
|
|
150
185
|
try {
|
|
@@ -185,6 +220,263 @@ function writeTriggerConfig(key, value) {
|
|
|
185
220
|
if (!wt) return;
|
|
186
221
|
patchTrigger({ config: { ...wt.config, [key]: value } });
|
|
187
222
|
}
|
|
223
|
+
const MINUTE_INTERVALS = [1, 2, 5, 10, 15, 20, 30];
|
|
224
|
+
const HOUR_INTERVALS = [2, 3, 4, 6, 8, 12];
|
|
225
|
+
const MINUTES_PAST = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
|
|
226
|
+
const DAY_OPTIONS = [
|
|
227
|
+
{ value: 1, short: "Mo", name: "Mondays" },
|
|
228
|
+
{ value: 2, short: "Tu", name: "Tuesdays" },
|
|
229
|
+
{ value: 3, short: "We", name: "Wednesdays" },
|
|
230
|
+
{ value: 4, short: "Th", name: "Thursdays" },
|
|
231
|
+
{ value: 5, short: "Fr", name: "Fridays" },
|
|
232
|
+
{ value: 6, short: "Sa", name: "Saturdays" },
|
|
233
|
+
{ value: 0, short: "Su", name: "Sundays" }
|
|
234
|
+
];
|
|
235
|
+
const FREQUENCY_OPTIONS = [
|
|
236
|
+
{ value: "minutes", label: "Every few minutes" },
|
|
237
|
+
{ value: "hourly", label: "Every hour" },
|
|
238
|
+
{ value: "hours", label: "Every few hours" },
|
|
239
|
+
{ value: "times", label: "Several times a day" },
|
|
240
|
+
{ value: "daily", label: "Every day" },
|
|
241
|
+
{ value: "weekly", label: "Every week" },
|
|
242
|
+
{ value: "monthly", label: "Every month" }
|
|
243
|
+
];
|
|
244
|
+
const HOURS_OF_DAY = Array.from({ length: 24 }, (_, h) => h);
|
|
245
|
+
const FREQUENCY_ADVANCED = "advanced";
|
|
246
|
+
const cronValue = computed(() => readTriggerConfig("cron") ?? "");
|
|
247
|
+
const hasCron = computed(() => cronValue.value.trim() !== "");
|
|
248
|
+
const recurrence = computed(() => parseCron(cronValue.value));
|
|
249
|
+
const advancedOpen = ref(false);
|
|
250
|
+
watch(() => draftTrigger.value?.id, () => {
|
|
251
|
+
advancedOpen.value = false;
|
|
252
|
+
});
|
|
253
|
+
const frequency = computed(() => advancedOpen.value ? FREQUENCY_ADVANCED : recurrence.value?.kind ?? FREQUENCY_ADVANCED);
|
|
254
|
+
const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6];
|
|
255
|
+
const carry = ref({
|
|
256
|
+
hour: 8,
|
|
257
|
+
minute: 0,
|
|
258
|
+
days: [1],
|
|
259
|
+
day: 1,
|
|
260
|
+
minuteInterval: 15,
|
|
261
|
+
hourInterval: 4,
|
|
262
|
+
// "Several times a day" carries its own two sets: its days start at every day,
|
|
263
|
+
// where a weekly schedule's start at one, and its hours start empty so the
|
|
264
|
+
// first pair is built from the time already on screen rather than from a
|
|
265
|
+
// working hour nobody chose.
|
|
266
|
+
hours: [],
|
|
267
|
+
timeDays: ALL_DAYS
|
|
268
|
+
});
|
|
269
|
+
watch(recurrence, (r) => {
|
|
270
|
+
if (!r) return;
|
|
271
|
+
if ("hour" in r) carry.value.hour = r.hour;
|
|
272
|
+
if ("minute" in r) carry.value.minute = r.minute;
|
|
273
|
+
if (r.kind === "weekly") carry.value.days = r.days;
|
|
274
|
+
if (r.kind === "monthly") carry.value.day = r.day;
|
|
275
|
+
if (r.kind === "minutes") carry.value.minuteInterval = r.interval;
|
|
276
|
+
if (r.kind === "hours") carry.value.hourInterval = r.interval;
|
|
277
|
+
if (r.kind === "times") {
|
|
278
|
+
carry.value.hours = r.hours;
|
|
279
|
+
carry.value.timeDays = r.days ?? ALL_DAYS;
|
|
280
|
+
}
|
|
281
|
+
}, { immediate: true });
|
|
282
|
+
function recurrenceFor(kind) {
|
|
283
|
+
const c = carry.value;
|
|
284
|
+
switch (kind) {
|
|
285
|
+
case "minutes":
|
|
286
|
+
return { kind: "minutes", interval: c.minuteInterval };
|
|
287
|
+
case "hourly":
|
|
288
|
+
return { kind: "hourly", minute: c.minute };
|
|
289
|
+
case "hours":
|
|
290
|
+
return { kind: "hours", interval: c.hourInterval, minute: c.minute };
|
|
291
|
+
case "daily":
|
|
292
|
+
return { kind: "daily", hour: c.hour, minute: c.minute };
|
|
293
|
+
case "times":
|
|
294
|
+
return {
|
|
295
|
+
kind: "times",
|
|
296
|
+
minute: c.minute,
|
|
297
|
+
// Two is the smallest set that is "several"; one hour is the daily rhythm.
|
|
298
|
+
// With none carried yet, the rhythm starts from the hour that was on screen
|
|
299
|
+
// and the one after it — a schedule set for 02:00 becomes 02:00 and 03:00
|
|
300
|
+
// rather than losing the time the author had already chosen.
|
|
301
|
+
hours: c.hours.length >= 2 ? c.hours : [c.hour, (c.hour + 1) % 24].sort((a, b) => a - b),
|
|
302
|
+
days: c.timeDays.length ? c.timeDays : null
|
|
303
|
+
};
|
|
304
|
+
case "weekly":
|
|
305
|
+
return { kind: "weekly", days: c.days.length ? c.days : [1], hour: c.hour, minute: c.minute };
|
|
306
|
+
case "monthly":
|
|
307
|
+
return { kind: "monthly", day: c.day, hour: c.hour, minute: c.minute };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
function writeRecurrence(kind) {
|
|
311
|
+
writeTriggerConfig("cron", toCron(recurrenceFor(kind)));
|
|
312
|
+
}
|
|
313
|
+
function applyFrequency(v) {
|
|
314
|
+
const next = v;
|
|
315
|
+
advancedOpen.value = next === FREQUENCY_ADVANCED;
|
|
316
|
+
if (next !== FREQUENCY_ADVANCED) writeRecurrence(next);
|
|
317
|
+
}
|
|
318
|
+
function applyToCurrent() {
|
|
319
|
+
const f = frequency.value;
|
|
320
|
+
if (f !== FREQUENCY_ADVANCED) writeRecurrence(f);
|
|
321
|
+
}
|
|
322
|
+
function pad2(n) {
|
|
323
|
+
return String(n).padStart(2, "0");
|
|
324
|
+
}
|
|
325
|
+
function withCurrent(options, current) {
|
|
326
|
+
return options.includes(current) ? options : [...options, current].sort((a, b) => a - b);
|
|
327
|
+
}
|
|
328
|
+
const minuteIntervalOptions = computed(() => withCurrent(MINUTE_INTERVALS, carry.value.minuteInterval));
|
|
329
|
+
const minutePastOptions = computed(() => withCurrent(MINUTES_PAST, carry.value.minute));
|
|
330
|
+
const timeValue = computed(() => `${pad2(carry.value.hour)}:${pad2(carry.value.minute)}`);
|
|
331
|
+
function applyTime(v) {
|
|
332
|
+
const m = /^(\d{1,2}):(\d{2})/.exec(String(v ?? ""));
|
|
333
|
+
if (!m) return;
|
|
334
|
+
carry.value.hour = Math.min(23, Number(m[1]));
|
|
335
|
+
carry.value.minute = Math.min(59, Number(m[2]));
|
|
336
|
+
applyToCurrent();
|
|
337
|
+
}
|
|
338
|
+
const selectedDays = computed(() => (frequency.value === "times" ? carry.value.timeDays : carry.value.days).map(String));
|
|
339
|
+
function applyDays(v) {
|
|
340
|
+
const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 6);
|
|
341
|
+
const forTimes = frequency.value === "times";
|
|
342
|
+
const previous = forTimes ? carry.value.timeDays : carry.value.days;
|
|
343
|
+
const days = [...new Set(picked.length ? picked : previous)].sort((a, b) => a - b);
|
|
344
|
+
if (forTimes) {
|
|
345
|
+
carry.value.timeDays = days;
|
|
346
|
+
writeRecurrence("times");
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
carry.value.days = days;
|
|
350
|
+
writeRecurrence("weekly");
|
|
351
|
+
}
|
|
352
|
+
const selectedHours = computed(() => carry.value.hours.map(String));
|
|
353
|
+
function applyHours(v) {
|
|
354
|
+
const picked = (Array.isArray(v) ? v : []).map(Number).filter((n) => Number.isInteger(n) && n >= 0 && n <= 23);
|
|
355
|
+
const hours = [...new Set(picked)].sort((a, b) => a - b);
|
|
356
|
+
carry.value.hours = hours.length >= 2 ? hours : carry.value.hours;
|
|
357
|
+
writeRecurrence("times");
|
|
358
|
+
}
|
|
359
|
+
function applyMinuteInterval(v) {
|
|
360
|
+
carry.value.minuteInterval = Number(v);
|
|
361
|
+
writeRecurrence("minutes");
|
|
362
|
+
}
|
|
363
|
+
function applyHourInterval(v) {
|
|
364
|
+
carry.value.hourInterval = Number(v);
|
|
365
|
+
writeRecurrence("hours");
|
|
366
|
+
}
|
|
367
|
+
function applyMinutePast(v) {
|
|
368
|
+
carry.value.minute = Number(v);
|
|
369
|
+
applyToCurrent();
|
|
370
|
+
}
|
|
371
|
+
function applyDayOfMonth(v) {
|
|
372
|
+
carry.value.day = Number(v);
|
|
373
|
+
writeRecurrence("monthly");
|
|
374
|
+
}
|
|
375
|
+
const scheduleSentence = computed(() => {
|
|
376
|
+
const r = recurrence.value;
|
|
377
|
+
if (!r) return null;
|
|
378
|
+
return `${describeRecurrence(r)} \xB7 ${scheduleTimezone(readTriggerConfig("timezone"))}`;
|
|
379
|
+
});
|
|
380
|
+
const webhookUrl = computed(() => {
|
|
381
|
+
const wt = draftTrigger.value;
|
|
382
|
+
if (!wt || wt.type !== "webhook") return null;
|
|
383
|
+
const inbound = wt.config.inbound;
|
|
384
|
+
if (inbound?.url) return inbound.url;
|
|
385
|
+
const base = String(useRuntimeConfig().public.integrationsApi ?? "").replace(/\/+$/, "");
|
|
386
|
+
return base ? `${base}/webhooks/${wt.id}` : null;
|
|
387
|
+
});
|
|
388
|
+
const webhookIsPublic = computed(() => !!draftTrigger.value?.config?.public);
|
|
389
|
+
const authChoice = computed(() => authChoiceFromConfig(draftTrigger.value?.config));
|
|
390
|
+
function writeTriggerConfigPatch(patch) {
|
|
391
|
+
const wt = draftTrigger.value;
|
|
392
|
+
if (!wt) return;
|
|
393
|
+
patchTrigger({ config: { ...wt.config, ...patch } });
|
|
394
|
+
}
|
|
395
|
+
function applyAuthChoice(v) {
|
|
396
|
+
writeTriggerConfigPatch(configPatchForAuthChoice(v));
|
|
397
|
+
}
|
|
398
|
+
function writeAuthConfigKey(key, value) {
|
|
399
|
+
const cfg = draftTrigger.value?.config;
|
|
400
|
+
writeTriggerConfigPatch({ authConfig: { ...cfg?.authConfig, [key]: value || void 0 } });
|
|
401
|
+
}
|
|
402
|
+
const webhookExposed = computed(() => !!draftTrigger.value?.config?.inbound?.url);
|
|
403
|
+
const rotateBusy = ref(false);
|
|
404
|
+
const rotatedSecret = ref(null);
|
|
405
|
+
const rotateOpen = ref(false);
|
|
406
|
+
async function rotateEndpointSecret() {
|
|
407
|
+
const wt = draftTrigger.value;
|
|
408
|
+
if (!wt || !props.workflowId) return;
|
|
409
|
+
rotateBusy.value = true;
|
|
410
|
+
try {
|
|
411
|
+
const api2 = useIntegrationsApi();
|
|
412
|
+
const res = await api2.post(`/workflows/${props.workflowId}/triggers/${wt.id}/rotate-secret`, {});
|
|
413
|
+
const body = res ?? {};
|
|
414
|
+
const secret = body.inbound_secret ?? body.data?.inbound_secret ?? null;
|
|
415
|
+
if (secret) {
|
|
416
|
+
rotatedSecret.value = secret;
|
|
417
|
+
rotateOpen.value = true;
|
|
418
|
+
} else {
|
|
419
|
+
toast.error("The endpoint did not return a secret.");
|
|
420
|
+
}
|
|
421
|
+
} catch (e) {
|
|
422
|
+
toast.error(e.message || "Could not rotate the secret.");
|
|
423
|
+
} finally {
|
|
424
|
+
rotateBusy.value = false;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async function copySecret() {
|
|
428
|
+
if (!rotatedSecret.value) return;
|
|
429
|
+
try {
|
|
430
|
+
await navigator.clipboard.writeText(rotatedSecret.value);
|
|
431
|
+
toast.success("Secret copied");
|
|
432
|
+
} catch {
|
|
433
|
+
toast.error("Could not copy the secret");
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function copyWebhookUrl() {
|
|
437
|
+
if (!webhookUrl.value) return;
|
|
438
|
+
try {
|
|
439
|
+
await navigator.clipboard.writeText(webhookUrl.value);
|
|
440
|
+
toast.success("Webhook URL copied");
|
|
441
|
+
} catch {
|
|
442
|
+
toast.error("Could not copy the URL");
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
|
|
446
|
+
} };
|
|
447
|
+
const eventTopic = computed(() => {
|
|
448
|
+
const t = readTriggerConfig("event_type");
|
|
449
|
+
return typeof t === "string" && t.trim() !== "" ? t.trim() : null;
|
|
450
|
+
});
|
|
451
|
+
watch(eventTopic, (topic) => {
|
|
452
|
+
if (topic) void definedEvents.load();
|
|
453
|
+
}, { immediate: true });
|
|
454
|
+
const eventSample = computed(() => {
|
|
455
|
+
if (!eventTopic.value) return null;
|
|
456
|
+
const sample = definedEvents.emits.value.find((e) => e.topic === eventTopic.value)?.sample;
|
|
457
|
+
return sample ? JSON.stringify(sample, null, 2) : null;
|
|
458
|
+
});
|
|
459
|
+
const manualFields = computed(() => readTriggerConfig("payloadFields") ?? []);
|
|
460
|
+
function writeManualFields(next) {
|
|
461
|
+
writeTriggerConfig("payloadFields", next.length ? next : void 0);
|
|
462
|
+
}
|
|
463
|
+
function setManualField(idx, patch) {
|
|
464
|
+
writeManualFields(manualFields.value.map((f, i) => i === idx ? { ...f, ...patch } : f));
|
|
465
|
+
}
|
|
466
|
+
function addManualField() {
|
|
467
|
+
writeManualFields([...manualFields.value, { path: "", label: "", kind: "text" }]);
|
|
468
|
+
}
|
|
469
|
+
function removeManualField(idx) {
|
|
470
|
+
manualFieldUids.value.splice(idx, 1);
|
|
471
|
+
writeManualFields(manualFields.value.filter((_, i) => i !== idx));
|
|
472
|
+
}
|
|
473
|
+
let nextManualFieldUid = 0;
|
|
474
|
+
const manualFieldUids = ref([]);
|
|
475
|
+
watch(() => manualFields.value.length, (n) => {
|
|
476
|
+
const uids = manualFieldUids.value;
|
|
477
|
+
while (uids.length < n) uids.push(nextManualFieldUid++);
|
|
478
|
+
if (uids.length > n) uids.splice(n);
|
|
479
|
+
}, { immediate: true });
|
|
188
480
|
const { loadForWorkflow, getSteps } = useIntegrationsRuns();
|
|
189
481
|
const runData = ref(null);
|
|
190
482
|
const runInfo = ref(null);
|
|
@@ -275,7 +567,7 @@ const sampleContext = computed(() => {
|
|
|
275
567
|
});
|
|
276
568
|
provide("integrationsSampleContext", sampleContext);
|
|
277
569
|
provide("integrationsNodeNames", computed(
|
|
278
|
-
() => Object.fromEntries((props.nodes ?? []).map((n) => [n.id,
|
|
570
|
+
() => Object.fromEntries((props.nodes ?? []).map((n) => [n.id, resolveDisplayName(n.name) || n.nodeSlug]))
|
|
279
571
|
));
|
|
280
572
|
const upstreamSections = computed(() => {
|
|
281
573
|
if (!isNode.value) return [];
|
|
@@ -283,7 +575,7 @@ const upstreamSections = computed(() => {
|
|
|
283
575
|
const m = findManifest(node.nodeSlug, node.nodeVersion);
|
|
284
576
|
return {
|
|
285
577
|
id: node.id,
|
|
286
|
-
name:
|
|
578
|
+
name: resolveDisplayName(node.name) || node.nodeSlug,
|
|
287
579
|
manifest: m,
|
|
288
580
|
direct,
|
|
289
581
|
ports: withSampledFields(portsForNode(node, m, props.dynamicOutputs?.[node.id]), sampleFor(node.id))
|
|
@@ -291,21 +583,16 @@ const upstreamSections = computed(() => {
|
|
|
291
583
|
});
|
|
292
584
|
});
|
|
293
585
|
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
586
|
const lastField = ref(null);
|
|
302
587
|
const lastFocusKind = ref(null);
|
|
588
|
+
const inputPaneOpen = ref(false);
|
|
303
589
|
const exprInsertHandler = ref(null);
|
|
304
590
|
provide("integrationsExpressionInsert", exprInsertHandler);
|
|
305
591
|
function onCenterFocusIn(e) {
|
|
306
592
|
const t = e.target;
|
|
307
593
|
if (t instanceof HTMLElement && t.closest(".expr-editor")) {
|
|
308
594
|
lastFocusKind.value = "editor";
|
|
595
|
+
inputPaneOpen.value = true;
|
|
309
596
|
return;
|
|
310
597
|
}
|
|
311
598
|
if (t instanceof HTMLTextAreaElement) {
|
|
@@ -339,6 +626,7 @@ function insertExpression(expression) {
|
|
|
339
626
|
toast("Expression copied. Click into a field first to insert it directly.");
|
|
340
627
|
}
|
|
341
628
|
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";
|
|
629
|
+
const rowClass = "flex w-full min-w-0 cursor-grab items-center gap-1.5 rounded-md px-1.5 py-[3px] text-left font-mono text-[11px] text-slate-700 transition hover:bg-slate-100 active:cursor-grabbing";
|
|
342
630
|
function onChipDragStart(e, expression) {
|
|
343
631
|
e.dataTransfer?.setData("text/plain", expression);
|
|
344
632
|
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy";
|
|
@@ -408,6 +696,14 @@ const visibleVariableNames = computed(() => {
|
|
|
408
696
|
const q = searchQuery.value;
|
|
409
697
|
return q ? variableNames.value.filter((n) => n.toLowerCase().includes(q)) : variableNames.value;
|
|
410
698
|
});
|
|
699
|
+
const variableRows = computed(() => visibleVariableNames.value.map((name) => {
|
|
700
|
+
const def = props.variables?.[name]?.default;
|
|
701
|
+
return {
|
|
702
|
+
name,
|
|
703
|
+
glyph: typeGlyph(dataTypeOf(def)),
|
|
704
|
+
value: def === void 0 || def === "" ? null : previewValue(def)
|
|
705
|
+
};
|
|
706
|
+
}));
|
|
411
707
|
const searchEmpty = computed(
|
|
412
708
|
() => !!searchQuery.value && !visibleSections.value.length && !showTriggerChip.value && !visibleVariableNames.value.length
|
|
413
709
|
);
|
|
@@ -563,30 +859,129 @@ async function runTestStep() {
|
|
|
563
859
|
width/height, border and the close control. -->
|
|
564
860
|
<div class="flex min-h-0 flex-1 flex-col bg-white">
|
|
565
861
|
<!-- Header -->
|
|
566
|
-
<div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3
|
|
862
|
+
<div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
|
|
567
863
|
<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
|
-
<
|
|
864
|
+
<IntegrationsNodeMark v-if="isNode" :manifest="manifest" :size="20" />
|
|
569
865
|
<component :is="triggerGlyph" v-else :size="18" />
|
|
570
866
|
</div>
|
|
571
867
|
<div class="min-w-0 flex-1">
|
|
868
|
+
<!-- Named explicitly: the placeholder is the node's slug, which left
|
|
869
|
+
this field accessibly called `revenexx:sftp-list` — a name for the
|
|
870
|
+
type, not for the field. -->
|
|
572
871
|
<input
|
|
573
872
|
ref="nameInput"
|
|
574
|
-
|
|
873
|
+
aria-label="Node name"
|
|
874
|
+
:value="nameValue"
|
|
575
875
|
:placeholder="namePlaceholder"
|
|
576
|
-
class="w-full truncate bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60"
|
|
876
|
+
class="w-full truncate rounded-sm bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground/60 focus:ring-2 focus:ring-primary/40"
|
|
577
877
|
@input="writeName($event.target.value)"
|
|
578
878
|
>
|
|
579
|
-
<div class="
|
|
580
|
-
{{ subtitleText }}
|
|
879
|
+
<div class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
880
|
+
<span class="truncate">{{ subtitleText }}</span>
|
|
881
|
+
<!-- What the node TYPE is for, one click behind the line that names
|
|
882
|
+
the type. Not printed into the header: it is read once, when the
|
|
883
|
+
node is new to someone, and a manifest that writes three
|
|
884
|
+
paragraphs would otherwise push the form off the screen every
|
|
885
|
+
time it is opened by someone who already knows. No description in
|
|
886
|
+
the manifest, no question mark — nothing to ask. -->
|
|
887
|
+
<Popover v-if="manifestDescription">
|
|
888
|
+
<PopoverTrigger as-child>
|
|
889
|
+
<!-- The ring stays 16px because that is what a `?` in a subtitle
|
|
890
|
+
line should look like; the TARGET is grown to 24px by the
|
|
891
|
+
`after` overlay. A 16px target inline under the full-width
|
|
892
|
+
name input misses WCAG 2.5.8 and cannot claim the spacing
|
|
893
|
+
exception — a 24px circle centred here reaches into the input,
|
|
894
|
+
which is a target of its own. And this is the only route to
|
|
895
|
+
the description at all. -->
|
|
896
|
+
<button
|
|
897
|
+
type="button"
|
|
898
|
+
aria-label="What this node does"
|
|
899
|
+
class="relative flex size-4 shrink-0 items-center justify-center rounded-full border border-black/[0.12] text-[10px] font-semibold leading-none transition after:absolute after:-inset-1 after:content-[''] hover:border-black/25 hover:bg-black/[0.04] hover:text-foreground"
|
|
900
|
+
>
|
|
901
|
+
?
|
|
902
|
+
</button>
|
|
903
|
+
</PopoverTrigger>
|
|
904
|
+
<PopoverContent aria-label="What this node does" align="start" class="w-96 rounded-lg border border-black/[0.06] bg-white p-4 shadow-lg">
|
|
905
|
+
<div class="flex items-center gap-2.5">
|
|
906
|
+
<span class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
|
|
907
|
+
<IntegrationsNodeMark :manifest="manifest" :size="18" />
|
|
908
|
+
</span>
|
|
909
|
+
<div class="min-w-0">
|
|
910
|
+
<div class="truncate text-sm font-semibold text-foreground">
|
|
911
|
+
{{ resolveLocalized(manifest?.name) }}
|
|
912
|
+
</div>
|
|
913
|
+
<div class="truncate text-xs text-muted-foreground">
|
|
914
|
+
{{ subtitleText }}
|
|
915
|
+
</div>
|
|
916
|
+
</div>
|
|
917
|
+
</div>
|
|
918
|
+
<!-- Reading matter, so it gets reading measure and scrolls when
|
|
919
|
+
the node package is generous. -->
|
|
920
|
+
<p class="mt-3 max-h-64 overflow-y-auto text-sm leading-relaxed text-foreground/80">
|
|
921
|
+
{{ manifestDescription }}
|
|
922
|
+
</p>
|
|
923
|
+
</PopoverContent>
|
|
924
|
+
</Popover>
|
|
581
925
|
</div>
|
|
582
926
|
</div>
|
|
583
927
|
<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
928
|
{{ groupLabel }}
|
|
585
929
|
</span>
|
|
930
|
+
<!-- The user's own note. A control of its own, deliberately not a second
|
|
931
|
+
paragraph under the name: the line above is what the node TYPE does
|
|
932
|
+
and cannot be edited, this is what one person wrote about this one
|
|
933
|
+
node. Named like the badge on the card, so the canvas and the dialog
|
|
934
|
+
agree on what the thing is called. -->
|
|
935
|
+
<Popover v-if="draftNode" v-model:open="noteOpen">
|
|
936
|
+
<PopoverTrigger as-child>
|
|
937
|
+
<button
|
|
938
|
+
type="button"
|
|
939
|
+
class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
|
|
940
|
+
:class="noteText ? 'border-amber-500/40 bg-amber-500/10 text-amber-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
|
|
941
|
+
>
|
|
942
|
+
<MessageSquareText :size="12" />
|
|
943
|
+
{{ noteLabel }}
|
|
944
|
+
</button>
|
|
945
|
+
</PopoverTrigger>
|
|
946
|
+
<PopoverContent aria-label="Note" align="end" class="w-80 space-y-1.5 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
|
|
947
|
+
<Label class="text-mono text-muted-foreground">Note</Label>
|
|
948
|
+
<Textarea
|
|
949
|
+
aria-label="Note"
|
|
950
|
+
:model-value="draftNode.notes ?? ''"
|
|
951
|
+
:rows="4"
|
|
952
|
+
placeholder="Why this node is here…"
|
|
953
|
+
class="rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
|
|
954
|
+
@update:model-value="writeNote"
|
|
955
|
+
/>
|
|
956
|
+
</PopoverContent>
|
|
957
|
+
</Popover>
|
|
958
|
+
<!-- The Input pane on request. Also opens by itself when the cursor lands
|
|
959
|
+
in an expression field — this is for reading it, or for dragging out
|
|
960
|
+
of it, before a field is chosen.
|
|
961
|
+
|
|
962
|
+
Not offered on a trigger: a trigger starts the workflow, so there is no
|
|
963
|
+
upstream data, and none of its settings takes an expression — the pane
|
|
964
|
+
would open onto its own explanation of why it is empty, in a dialog
|
|
965
|
+
sized for the few controls a trigger has. -->
|
|
966
|
+
<button
|
|
967
|
+
v-if="isNode"
|
|
968
|
+
type="button"
|
|
969
|
+
class="flex shrink-0 items-center gap-1.5 rounded-md border px-2 py-1 text-xs font-medium transition"
|
|
970
|
+
:class="inputPaneOpen ? 'border-emerald-500/40 bg-emerald-500/10 text-emerald-700' : 'border-black/[0.08] text-muted-foreground hover:bg-black/[0.04] hover:text-foreground'"
|
|
971
|
+
:aria-pressed="inputPaneOpen"
|
|
972
|
+
@click="inputPaneOpen = !inputPaneOpen"
|
|
973
|
+
>
|
|
974
|
+
<Database :size="12" />
|
|
975
|
+
Input data
|
|
976
|
+
</button>
|
|
977
|
+
<!-- Icon-only controls: `title` alone leaves them nameless to a screen
|
|
978
|
+
reader (and unaddressable to a test without a CSS selector), so each
|
|
979
|
+
carries the same sentence as an aria-label. -->
|
|
586
980
|
<div v-if="sequence.length > 1" class="flex shrink-0 items-center gap-0.5 border-l border-black/[0.06] pl-2">
|
|
587
981
|
<button
|
|
588
982
|
type="button"
|
|
589
983
|
:disabled="!hasPrev"
|
|
984
|
+
aria-label="Previous step (saves changes)"
|
|
590
985
|
title="Previous step (saves changes)"
|
|
591
986
|
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
987
|
@click="navigate(-1)"
|
|
@@ -596,6 +991,7 @@ async function runTestStep() {
|
|
|
596
991
|
<button
|
|
597
992
|
type="button"
|
|
598
993
|
:disabled="!hasNext"
|
|
994
|
+
aria-label="Next step (saves changes)"
|
|
599
995
|
title="Next step (saves changes)"
|
|
600
996
|
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
997
|
@click="navigate(1)"
|
|
@@ -603,14 +999,25 @@ async function runTestStep() {
|
|
|
603
999
|
<ChevronRight :size="14" />
|
|
604
1000
|
</button>
|
|
605
1001
|
</div>
|
|
1002
|
+
<!-- Close lives in the header row so it lines up with the chevrons
|
|
1003
|
+
(the DialogContent built-in X floats at its own offset). -->
|
|
1004
|
+
<button
|
|
1005
|
+
type="button"
|
|
1006
|
+
aria-label="Close"
|
|
1007
|
+
title="Close"
|
|
1008
|
+
class="-mr-1.5 flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition hover:bg-black/[0.04] hover:text-foreground"
|
|
1009
|
+
@click="emit('close')"
|
|
1010
|
+
>
|
|
1011
|
+
<X :size="15" />
|
|
1012
|
+
</button>
|
|
606
1013
|
</div>
|
|
607
1014
|
|
|
608
1015
|
<!-- Body: Input pane + tabbed center -->
|
|
609
1016
|
<div class="flex min-h-0 flex-1">
|
|
610
1017
|
<!-- Input pane -->
|
|
611
|
-
<aside class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
|
|
1018
|
+
<aside v-if="inputPaneOpen" class="flex w-[300px] shrink-0 flex-col border-r border-black/[0.06] bg-muted/10" @dragend="onChipDragEnd">
|
|
612
1019
|
<div class="shrink-0 border-b border-black/[0.06] px-3 py-2.5">
|
|
613
|
-
<div class="text-
|
|
1020
|
+
<div class="text-mono text-muted-foreground">
|
|
614
1021
|
Input
|
|
615
1022
|
</div>
|
|
616
1023
|
<p class="mt-0.5 text-xs leading-snug text-muted-foreground">
|
|
@@ -621,8 +1028,9 @@ async function runTestStep() {
|
|
|
621
1028
|
<input
|
|
622
1029
|
v-model="inputSearch"
|
|
623
1030
|
type="text"
|
|
1031
|
+
aria-label="Search data"
|
|
624
1032
|
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]"
|
|
1033
|
+
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] focus:ring-2 focus:ring-primary/40"
|
|
626
1034
|
>
|
|
627
1035
|
</div>
|
|
628
1036
|
<div v-if="workflowId" class="mt-2 flex items-center gap-1.5">
|
|
@@ -670,7 +1078,7 @@ async function runTestStep() {
|
|
|
670
1078
|
@click="toggleNodeSection(s.id)"
|
|
671
1079
|
>
|
|
672
1080
|
<ChevronRight :size="11" class="shrink-0 text-muted-foreground transition-transform" :class="sectionClosed(s.id) ? '' : 'rotate-90'" />
|
|
673
|
-
<
|
|
1081
|
+
<IntegrationsNodeMark :manifest="s.manifest" :size="14" :badge="false" />
|
|
674
1082
|
<span class="min-w-0 truncate text-xs font-medium text-foreground">{{ s.name }}</span>
|
|
675
1083
|
<span
|
|
676
1084
|
v-if="!s.direct"
|
|
@@ -686,25 +1094,37 @@ async function runTestStep() {
|
|
|
686
1094
|
<span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
|
|
687
1095
|
<span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
|
|
688
1096
|
</div>
|
|
689
|
-
|
|
1097
|
+
<!-- Schema rows, n8n-style: glyph · key · sampled value. -->
|
|
1098
|
+
<div class="space-y-px">
|
|
690
1099
|
<button
|
|
691
1100
|
type="button"
|
|
692
|
-
:class="
|
|
1101
|
+
:class="rowClass"
|
|
1102
|
+
:aria-label="nodeOutputExpr(s.id, p.name)"
|
|
693
1103
|
:title="nodeOutputExpr(s.id, p.name)"
|
|
694
1104
|
draggable="true"
|
|
695
1105
|
@dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name))"
|
|
696
1106
|
@click="insertExpression(nodeOutputExpr(s.id, p.name))"
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
v-for="
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1107
|
+
>
|
|
1108
|
+
<span class="w-3 shrink-0 text-center font-semibold text-slate-400">{}</span>
|
|
1109
|
+
<span class="shrink-0 font-medium"><span v-for="(seg, i) in highlightSegments(p.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
|
|
1110
|
+
</button>
|
|
1111
|
+
<div class="space-y-px pl-3">
|
|
1112
|
+
<button
|
|
1113
|
+
v-for="f in p.fields"
|
|
1114
|
+
:key="f.name"
|
|
1115
|
+
type="button"
|
|
1116
|
+
:class="rowClass"
|
|
1117
|
+
:aria-label="nodeOutputExpr(s.id, p.name, f.name)"
|
|
1118
|
+
:title="nodeOutputExpr(s.id, p.name, f.name) + (f.dataType ? ' \xB7 ' + f.dataType : '')"
|
|
1119
|
+
draggable="true"
|
|
1120
|
+
@dragstart="onChipDragStart($event, nodeOutputExpr(s.id, p.name, f.name))"
|
|
1121
|
+
@click="insertExpression(nodeOutputExpr(s.id, p.name, f.name))"
|
|
1122
|
+
>
|
|
1123
|
+
<span class="w-3 shrink-0 text-center font-semibold" :class="typeGlyph(f.dataType)?.cls ?? 'text-slate-300'">{{ typeGlyph(f.dataType)?.char ?? "\xB7" }}</span>
|
|
1124
|
+
<span class="shrink-0"><span v-for="(seg, i) in highlightSegments(f.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
|
|
1125
|
+
<span v-if="chipValue(s.id, p.name, f.name) !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ chipValue(s.id, p.name, f.name) }}</span>
|
|
1126
|
+
</button>
|
|
1127
|
+
</div>
|
|
708
1128
|
</div>
|
|
709
1129
|
</div>
|
|
710
1130
|
</div>
|
|
@@ -720,6 +1140,7 @@ async function runTestStep() {
|
|
|
720
1140
|
<button
|
|
721
1141
|
type="button"
|
|
722
1142
|
:class="chipClass"
|
|
1143
|
+
:aria-label="triggerPayloadExpr()"
|
|
723
1144
|
:title="triggerPayloadExpr()"
|
|
724
1145
|
draggable="true"
|
|
725
1146
|
@dragstart="onChipDragStart($event, triggerPayloadExpr())"
|
|
@@ -743,17 +1164,23 @@ async function runTestStep() {
|
|
|
743
1164
|
<div class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
|
744
1165
|
Variables
|
|
745
1166
|
</div>
|
|
746
|
-
|
|
1167
|
+
<!-- Schema rows with the declared default as the value. -->
|
|
1168
|
+
<div v-if="variableRows.length" class="space-y-px">
|
|
747
1169
|
<button
|
|
748
|
-
v-for="
|
|
749
|
-
:key="name"
|
|
1170
|
+
v-for="row in variableRows"
|
|
1171
|
+
:key="row.name"
|
|
750
1172
|
type="button"
|
|
751
|
-
:class="
|
|
752
|
-
:
|
|
1173
|
+
:class="rowClass"
|
|
1174
|
+
:aria-label="variableExpr(row.name)"
|
|
1175
|
+
:title="variableExpr(row.name)"
|
|
753
1176
|
draggable="true"
|
|
754
|
-
@dragstart="onChipDragStart($event, variableExpr(name))"
|
|
755
|
-
@click="insertExpression(variableExpr(name))"
|
|
756
|
-
|
|
1177
|
+
@dragstart="onChipDragStart($event, variableExpr(row.name))"
|
|
1178
|
+
@click="insertExpression(variableExpr(row.name))"
|
|
1179
|
+
>
|
|
1180
|
+
<span class="w-3 shrink-0 text-center font-semibold" :class="row.glyph?.cls ?? 'text-slate-300'">{{ row.glyph?.char ?? "\xB7" }}</span>
|
|
1181
|
+
<span class="shrink-0"><span v-for="(seg, i) in highlightSegments(row.name)" :key="i" :class="seg.hit ? 'rounded-[2px] bg-amber-200/80' : ''">{{ seg.text }}</span></span>
|
|
1182
|
+
<span v-if="row.value !== null" class="min-w-0 flex-1 truncate text-slate-400">{{ row.value }}</span>
|
|
1183
|
+
</button>
|
|
757
1184
|
</div>
|
|
758
1185
|
<p v-else class="text-xs leading-snug text-muted-foreground">
|
|
759
1186
|
No workflow variables defined.
|
|
@@ -768,25 +1195,8 @@ async function runTestStep() {
|
|
|
768
1195
|
|
|
769
1196
|
<!-- Center: tabs + content -->
|
|
770
1197
|
<section class="flex min-w-0 flex-1 flex-col">
|
|
771
|
-
|
|
772
|
-
|
|
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>
|
|
789
|
-
</div>
|
|
1198
|
+
<!-- Same TabBar as the workflow page — one tab look everywhere. -->
|
|
1199
|
+
<TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
|
|
790
1200
|
|
|
791
1201
|
<div
|
|
792
1202
|
class="min-h-0 flex-1 overflow-y-auto p-4"
|
|
@@ -797,111 +1207,416 @@ async function runTestStep() {
|
|
|
797
1207
|
@drop="onCenterDrop"
|
|
798
1208
|
>
|
|
799
1209
|
<!-- Parameters: trigger config -->
|
|
800
|
-
|
|
1210
|
+
<!-- Full width: triggers have no output pane, a capped column
|
|
1211
|
+
would float lost in the wide center. -->
|
|
1212
|
+
<div v-if="!isNode && draftTrigger" class="w-full space-y-3">
|
|
801
1213
|
<!-- Active toggle. Persists with the workflow save like every other trigger field. -->
|
|
802
|
-
<div class="flex items-center
|
|
1214
|
+
<div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
|
|
1215
|
+
<!-- The name lives in the text next to the switch, which is not
|
|
1216
|
+
a <label> — so the switch says it itself. -->
|
|
1217
|
+
<Switch
|
|
1218
|
+
aria-label="Active"
|
|
1219
|
+
class="data-[state=unchecked]:bg-slate-300"
|
|
1220
|
+
:model-value="draftTrigger.active !== false"
|
|
1221
|
+
@update:model-value="(v) => patchTrigger({ active: v })"
|
|
1222
|
+
/>
|
|
803
1223
|
<div>
|
|
804
1224
|
<div class="text-sm font-medium">Active</div>
|
|
805
1225
|
<p class="text-xs text-muted-foreground">
|
|
806
1226
|
A run needs at least one active trigger.
|
|
807
1227
|
</p>
|
|
808
1228
|
</div>
|
|
809
|
-
<Switch
|
|
810
|
-
:model-value="draftTrigger.active !== false"
|
|
811
|
-
@update:model-value="(v) => patchTrigger({ active: v })"
|
|
812
|
-
/>
|
|
813
1229
|
</div>
|
|
1230
|
+
<!--
|
|
1231
|
+
The schedule is picked, not written. Each frequency shows only its
|
|
1232
|
+
own fields, and the expression appears in one place only: the
|
|
1233
|
+
Advanced field, for a schedule no picker can represent.
|
|
1234
|
+
-->
|
|
814
1235
|
<template v-if="draftTrigger.type === 'schedule'">
|
|
815
|
-
<div class="
|
|
816
|
-
<
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
1236
|
+
<div class="grid grid-cols-2 gap-3">
|
|
1237
|
+
<div class="space-y-1.5">
|
|
1238
|
+
<!-- "Frequency", not "Runs": a run is an execution everywhere
|
|
1239
|
+
else in the studio, and there is a whole page of them. -->
|
|
1240
|
+
<Label for="ni-frequency" class="text-mono text-muted-foreground">Frequency</Label>
|
|
1241
|
+
<Select :model-value="frequency" @update:model-value="applyFrequency">
|
|
1242
|
+
<SelectTrigger id="ni-frequency" aria-label="Frequency" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1243
|
+
<SelectValue />
|
|
1244
|
+
</SelectTrigger>
|
|
1245
|
+
<SelectContent>
|
|
1246
|
+
<SelectItem v-for="f in FREQUENCY_OPTIONS" :key="f.value" :value="f.value">{{ f.label }}</SelectItem>
|
|
1247
|
+
<SelectSeparator />
|
|
1248
|
+
<SelectItem :value="FREQUENCY_ADVANCED">Advanced (cron)</SelectItem>
|
|
1249
|
+
</SelectContent>
|
|
1250
|
+
</Select>
|
|
1251
|
+
</div>
|
|
1252
|
+
|
|
1253
|
+
<!-- Every few minutes: the gap between firings. -->
|
|
1254
|
+
<div v-if="frequency === 'minutes'" class="space-y-1.5">
|
|
1255
|
+
<Label for="ni-minute-interval" class="text-mono text-muted-foreground">Interval</Label>
|
|
1256
|
+
<Select :model-value="String(carry.minuteInterval)" @update:model-value="applyMinuteInterval">
|
|
1257
|
+
<SelectTrigger id="ni-minute-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1258
|
+
<SelectValue />
|
|
1259
|
+
</SelectTrigger>
|
|
1260
|
+
<SelectContent>
|
|
1261
|
+
<SelectItem v-for="n in minuteIntervalOptions" :key="n" :value="String(n)">
|
|
1262
|
+
{{ n === 1 ? "Every minute" : `Every ${n} minutes` }}
|
|
1263
|
+
</SelectItem>
|
|
1264
|
+
</SelectContent>
|
|
1265
|
+
</Select>
|
|
1266
|
+
</div>
|
|
1267
|
+
|
|
1268
|
+
<!-- Every few hours: the gap, plus where in the hour it lands. -->
|
|
1269
|
+
<div v-if="frequency === 'hours'" class="space-y-1.5">
|
|
1270
|
+
<Label for="ni-hour-interval" class="text-mono text-muted-foreground">Interval</Label>
|
|
1271
|
+
<Select :model-value="String(carry.hourInterval)" @update:model-value="applyHourInterval">
|
|
1272
|
+
<SelectTrigger id="ni-hour-interval" aria-label="Interval" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1273
|
+
<SelectValue />
|
|
1274
|
+
</SelectTrigger>
|
|
1275
|
+
<SelectContent>
|
|
1276
|
+
<SelectItem v-for="n in HOUR_INTERVALS" :key="n" :value="String(n)">Every {{ n }} hours</SelectItem>
|
|
1277
|
+
</SelectContent>
|
|
1278
|
+
</Select>
|
|
1279
|
+
</div>
|
|
1280
|
+
|
|
1281
|
+
<div v-if="frequency === 'hourly' || frequency === 'hours' || frequency === 'times'" class="space-y-1.5">
|
|
1282
|
+
<Label for="ni-minute-past" class="text-mono text-muted-foreground">Minutes past the hour</Label>
|
|
1283
|
+
<Select :model-value="String(carry.minute)" @update:model-value="applyMinutePast">
|
|
1284
|
+
<SelectTrigger id="ni-minute-past" aria-label="Minutes past the hour" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1285
|
+
<SelectValue />
|
|
1286
|
+
</SelectTrigger>
|
|
1287
|
+
<SelectContent>
|
|
1288
|
+
<SelectItem v-for="n in minutePastOptions" :key="n" :value="String(n)">:{{ pad2(n) }}</SelectItem>
|
|
1289
|
+
</SelectContent>
|
|
1290
|
+
</Select>
|
|
1291
|
+
</div>
|
|
1292
|
+
|
|
1293
|
+
<!-- A fixed time of day: the one field all three calendar
|
|
1294
|
+
rhythms share, which is why it survives a change of
|
|
1295
|
+
frequency. -->
|
|
1296
|
+
<div v-if="frequency === 'daily' || frequency === 'weekly' || frequency === 'monthly'" class="space-y-1.5">
|
|
1297
|
+
<Label for="ni-time" class="text-mono text-muted-foreground">Time</Label>
|
|
1298
|
+
<Input
|
|
1299
|
+
id="ni-time"
|
|
1300
|
+
type="time"
|
|
1301
|
+
aria-label="Time"
|
|
1302
|
+
:model-value="timeValue"
|
|
1303
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white tabular-nums"
|
|
1304
|
+
@update:model-value="applyTime"
|
|
1305
|
+
/>
|
|
1306
|
+
</div>
|
|
1307
|
+
|
|
1308
|
+
<div v-if="frequency === 'monthly'" class="space-y-1.5">
|
|
1309
|
+
<Label for="ni-day-of-month" class="text-mono text-muted-foreground">Day of month</Label>
|
|
1310
|
+
<Select :model-value="String(carry.day)" @update:model-value="applyDayOfMonth">
|
|
1311
|
+
<SelectTrigger id="ni-day-of-month" aria-label="Day of month" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1312
|
+
<SelectValue />
|
|
1313
|
+
</SelectTrigger>
|
|
1314
|
+
<SelectContent>
|
|
1315
|
+
<SelectItem v-for="n in 31" :key="n" :value="String(n)">{{ n }}</SelectItem>
|
|
1316
|
+
</SelectContent>
|
|
1317
|
+
</Select>
|
|
1318
|
+
</div>
|
|
1319
|
+
|
|
1320
|
+
<!-- Advanced: the expression itself, the only place it is shown. -->
|
|
1321
|
+
<div v-if="frequency === FREQUENCY_ADVANCED" class="space-y-1.5">
|
|
1322
|
+
<Label for="ni-cron" class="text-mono text-muted-foreground">Cron expression</Label>
|
|
1323
|
+
<Input
|
|
1324
|
+
id="ni-cron"
|
|
1325
|
+
aria-label="Cron expression"
|
|
1326
|
+
:model-value="cronValue"
|
|
1327
|
+
placeholder="0 9-17 * * 1-5"
|
|
1328
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white font-mono"
|
|
1329
|
+
@update:model-value="(v) => writeTriggerConfig('cron', v)"
|
|
1330
|
+
/>
|
|
1331
|
+
</div>
|
|
823
1332
|
</div>
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
1333
|
+
|
|
1334
|
+
<!--
|
|
1335
|
+
The hours of "several times a day". Two rows of twelve, and the
|
|
1336
|
+
minute above is shared by all of them: cron cannot express times
|
|
1337
|
+
with different minutes in one expression, so 07:30 and 09:15 is two
|
|
1338
|
+
schedule triggers rather than one field per row.
|
|
1339
|
+
-->
|
|
1340
|
+
<div v-if="frequency === 'times'" class="space-y-1.5">
|
|
1341
|
+
<Label class="text-mono text-muted-foreground">Hours</Label>
|
|
1342
|
+
<ToggleGroup
|
|
1343
|
+
type="multiple"
|
|
1344
|
+
aria-label="Hours"
|
|
1345
|
+
:model-value="selectedHours"
|
|
1346
|
+
class="grid w-fit grid-cols-12 gap-1"
|
|
1347
|
+
@update:model-value="applyHours"
|
|
1348
|
+
>
|
|
1349
|
+
<ToggleGroupItem
|
|
1350
|
+
v-for="h in HOURS_OF_DAY"
|
|
1351
|
+
:key="h"
|
|
1352
|
+
:value="String(h)"
|
|
1353
|
+
:aria-label="`${pad2(h)}:${pad2(carry.minute)}`"
|
|
1354
|
+
class="h-7 w-9 rounded-md border border-black/[0.08] bg-white text-xs tabular-nums data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
|
|
1355
|
+
>
|
|
1356
|
+
{{ pad2(h) }}
|
|
1357
|
+
</ToggleGroupItem>
|
|
1358
|
+
</ToggleGroup>
|
|
832
1359
|
</div>
|
|
1360
|
+
|
|
1361
|
+
<!-- Weekdays: full width, because seven toggles in half of the
|
|
1362
|
+
grid would wrap. Serves both rhythms that fall on days. -->
|
|
1363
|
+
<div v-if="frequency === 'weekly' || frequency === 'times'" class="space-y-1.5">
|
|
1364
|
+
<Label class="text-mono text-muted-foreground">Days</Label>
|
|
1365
|
+
<ToggleGroup
|
|
1366
|
+
type="multiple"
|
|
1367
|
+
aria-label="Days"
|
|
1368
|
+
:model-value="selectedDays"
|
|
1369
|
+
class="justify-start gap-1"
|
|
1370
|
+
@update:model-value="applyDays"
|
|
1371
|
+
>
|
|
1372
|
+
<ToggleGroupItem
|
|
1373
|
+
v-for="d in DAY_OPTIONS"
|
|
1374
|
+
:key="d.value"
|
|
1375
|
+
:value="String(d.value)"
|
|
1376
|
+
:aria-label="d.name"
|
|
1377
|
+
class="h-8 w-10 rounded-md border border-black/[0.08] bg-white text-sm data-[state=on]:border-emerald-500/40 data-[state=on]:bg-emerald-500/10 data-[state=on]:text-emerald-700"
|
|
1378
|
+
>
|
|
1379
|
+
{{ d.short }}
|
|
1380
|
+
</ToggleGroupItem>
|
|
1381
|
+
</ToggleGroup>
|
|
1382
|
+
</div>
|
|
1383
|
+
|
|
1384
|
+
<!--
|
|
1385
|
+
The confirmation. The timezone is in it because the platform reads
|
|
1386
|
+
the expression's numbers as wall-clock time in that zone, so a time
|
|
1387
|
+
without its zone would be half an answer.
|
|
1388
|
+
-->
|
|
1389
|
+
<p v-if="scheduleSentence" aria-label="When it fires" class="text-xs text-emerald-700">{{ scheduleSentence }}</p>
|
|
1390
|
+
<!-- No expression at all is its own answer, and the same one the
|
|
1391
|
+
Schedules page gives for this trigger: it is not written as
|
|
1392
|
+
anything, and it does not run. A trigger placed from the palette
|
|
1393
|
+
carries an expression, so this is for one that arrived without
|
|
1394
|
+
one. -->
|
|
1395
|
+
<p v-else-if="!hasCron" aria-label="When it fires" class="text-xs text-muted-foreground">
|
|
1396
|
+
No schedule set — pick a frequency, or write an expression.
|
|
1397
|
+
</p>
|
|
1398
|
+
<p v-else class="text-xs text-muted-foreground">
|
|
1399
|
+
This schedule is written as an expression these settings cannot show. It runs exactly as written.
|
|
1400
|
+
</p>
|
|
1401
|
+
<p v-if="frequency === 'monthly' && carry.day >= 29" class="text-xs text-muted-foreground">
|
|
1402
|
+
Months without this day are skipped.
|
|
1403
|
+
</p>
|
|
833
1404
|
</template>
|
|
834
1405
|
<template v-else-if="draftTrigger.type === 'webhook'">
|
|
835
1406
|
<div class="space-y-1.5">
|
|
836
|
-
<Label class="text-mono text-muted-foreground">
|
|
1407
|
+
<Label for="ni-webhook-url" class="text-mono text-muted-foreground">Webhook URL</Label>
|
|
1408
|
+
<div v-if="webhookUrl" class="flex items-center gap-1.5">
|
|
1409
|
+
<Input
|
|
1410
|
+
id="ni-webhook-url"
|
|
1411
|
+
readonly
|
|
1412
|
+
:model-value="webhookUrl"
|
|
1413
|
+
class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
|
|
1414
|
+
@focus="$event.target.select()"
|
|
1415
|
+
/>
|
|
1416
|
+
<button
|
|
1417
|
+
type="button"
|
|
1418
|
+
title="Copy URL"
|
|
1419
|
+
class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
|
|
1420
|
+
@click="copyWebhookUrl"
|
|
1421
|
+
>
|
|
1422
|
+
<Copy :size="14" />
|
|
1423
|
+
</button>
|
|
1424
|
+
</div>
|
|
1425
|
+
<p class="text-xs text-muted-foreground">
|
|
1426
|
+
<template v-if="webhookIsPublic">Public ingress endpoint — callers authenticate with the endpoint secret issued when the trigger was exposed.</template>
|
|
1427
|
+
<template v-else>Authenticated endpoint: callers need a platform API token (Bearer). Enable the public endpoint for external systems.</template>
|
|
1428
|
+
</p>
|
|
1429
|
+
</div>
|
|
1430
|
+
<div class="flex items-center gap-3 rounded-lg border border-black/[0.06] bg-muted/20 px-3 py-2">
|
|
1431
|
+
<Switch
|
|
1432
|
+
aria-label="Public endpoint"
|
|
1433
|
+
class="data-[state=unchecked]:bg-slate-300"
|
|
1434
|
+
:model-value="webhookIsPublic"
|
|
1435
|
+
@update:model-value="(v) => writeTriggerConfigPatch(v ? { public: true, ...draftTrigger.config.authProfile ? {} : configPatchForAuthChoice('token') } : { public: void 0 })"
|
|
1436
|
+
/>
|
|
1437
|
+
<div>
|
|
1438
|
+
<div class="text-sm font-medium">Public endpoint</div>
|
|
1439
|
+
<p class="text-xs text-muted-foreground">
|
|
1440
|
+
Saving provisions a public URL on the webhook ingress (with its own endpoint secret) — for external systems without a platform token.
|
|
1441
|
+
</p>
|
|
1442
|
+
</div>
|
|
1443
|
+
</div>
|
|
1444
|
+
<div class="grid grid-cols-2 gap-3">
|
|
1445
|
+
<div class="space-y-1.5">
|
|
1446
|
+
<Label for="ni-webhook-method" class="text-mono text-muted-foreground">Method</Label>
|
|
1447
|
+
<Select
|
|
1448
|
+
:model-value="readTriggerConfig('method') ?? 'POST'"
|
|
1449
|
+
@update:model-value="(v) => writeTriggerConfig('method', v)"
|
|
1450
|
+
>
|
|
1451
|
+
<SelectTrigger id="ni-webhook-method" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1452
|
+
<SelectValue />
|
|
1453
|
+
</SelectTrigger>
|
|
1454
|
+
<SelectContent>
|
|
1455
|
+
<SelectItem value="GET">GET</SelectItem>
|
|
1456
|
+
<SelectItem value="POST">POST</SelectItem>
|
|
1457
|
+
<SelectItem value="PUT">PUT</SelectItem>
|
|
1458
|
+
<SelectItem value="PATCH">PATCH</SelectItem>
|
|
1459
|
+
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
1460
|
+
</SelectContent>
|
|
1461
|
+
</Select>
|
|
1462
|
+
</div>
|
|
1463
|
+
<div v-if="webhookIsPublic" class="space-y-1.5">
|
|
1464
|
+
<Label for="ni-webhook-protection" class="text-mono text-muted-foreground">Protection</Label>
|
|
1465
|
+
<Select :model-value="authChoice" @update:model-value="applyAuthChoice">
|
|
1466
|
+
<SelectTrigger id="ni-webhook-protection" size="sm" class="h-8 w-full rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1467
|
+
<SelectValue />
|
|
1468
|
+
</SelectTrigger>
|
|
1469
|
+
<SelectContent>
|
|
1470
|
+
<SelectItem value="token">API key / token header</SelectItem>
|
|
1471
|
+
<SelectItem value="bearer">Bearer token</SelectItem>
|
|
1472
|
+
<SelectItem value="basic">Basic auth</SelectItem>
|
|
1473
|
+
<SelectItem value="none">None (capture everything)</SelectItem>
|
|
1474
|
+
</SelectContent>
|
|
1475
|
+
</Select>
|
|
1476
|
+
<p class="text-xs text-muted-foreground">Verified by the webhook ingress before the workflow runs.</p>
|
|
1477
|
+
</div>
|
|
1478
|
+
</div>
|
|
1479
|
+
<div v-if="webhookIsPublic && authChoice === 'token'" class="space-y-1.5">
|
|
1480
|
+
<Label for="ni-auth-header" class="text-mono text-muted-foreground">Header name</Label>
|
|
837
1481
|
<Input
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
1482
|
+
id="ni-auth-header"
|
|
1483
|
+
:model-value="draftTrigger.config.authConfig?.header ?? ''"
|
|
1484
|
+
placeholder="X-Webhook-Token"
|
|
1485
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
|
|
1486
|
+
@update:model-value="(v) => writeAuthConfigKey('header', String(v))"
|
|
842
1487
|
/>
|
|
1488
|
+
<p class="text-xs text-muted-foreground">The header carrying the endpoint secret (e.g. X-Api-Key). Empty uses X-Webhook-Token.</p>
|
|
843
1489
|
</div>
|
|
844
|
-
<div class="space-y-1.5">
|
|
845
|
-
<Label class="text-mono text-muted-foreground">
|
|
846
|
-
<
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
<SelectItem value="GET">GET</SelectItem>
|
|
855
|
-
<SelectItem value="POST">POST</SelectItem>
|
|
856
|
-
<SelectItem value="PUT">PUT</SelectItem>
|
|
857
|
-
<SelectItem value="PATCH">PATCH</SelectItem>
|
|
858
|
-
<SelectItem value="DELETE">DELETE</SelectItem>
|
|
859
|
-
</SelectContent>
|
|
860
|
-
</Select>
|
|
1490
|
+
<div v-if="webhookIsPublic && authChoice === 'basic'" class="space-y-1.5">
|
|
1491
|
+
<Label for="ni-auth-username" class="text-mono text-muted-foreground">Username</Label>
|
|
1492
|
+
<Input
|
|
1493
|
+
id="ni-auth-username"
|
|
1494
|
+
:model-value="draftTrigger.config.authConfig?.username ?? ''"
|
|
1495
|
+
placeholder="Any username"
|
|
1496
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
|
|
1497
|
+
@update:model-value="(v) => writeAuthConfigKey('username', String(v))"
|
|
1498
|
+
/>
|
|
1499
|
+
<p class="text-xs text-muted-foreground">Optional — pins the Basic-auth username. The password is the endpoint secret.</p>
|
|
861
1500
|
</div>
|
|
862
|
-
<div class="
|
|
863
|
-
<
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
>
|
|
868
|
-
<SelectTrigger class="h-8 text-sm bg-white">
|
|
869
|
-
<SelectValue placeholder="None" />
|
|
870
|
-
</SelectTrigger>
|
|
871
|
-
<SelectContent>
|
|
872
|
-
<SelectItem v-for="k in secretKeys" :key="k" :value="k">{{ k }}</SelectItem>
|
|
873
|
-
</SelectContent>
|
|
874
|
-
</Select>
|
|
1501
|
+
<div v-if="webhookExposed && authChoice !== 'none'" class="flex items-center gap-2">
|
|
1502
|
+
<Button type="button" variant="outline" size="sm" class="h-7 text-sm" :disabled="rotateBusy" @click="rotateEndpointSecret">
|
|
1503
|
+
{{ rotateBusy ? "Rotating\u2026" : "Rotate endpoint secret" }}
|
|
1504
|
+
</Button>
|
|
1505
|
+
<p class="text-xs text-muted-foreground">Issues a new secret (shown once); the previous one keeps working briefly.</p>
|
|
875
1506
|
</div>
|
|
1507
|
+
|
|
1508
|
+
<Dialog v-model:open="rotateOpen">
|
|
1509
|
+
<DialogContent class="sm:max-w-md">
|
|
1510
|
+
<DialogHeader>
|
|
1511
|
+
<DialogTitle>New endpoint secret</DialogTitle>
|
|
1512
|
+
<DialogDescription>
|
|
1513
|
+
Copy it now — it is shown only this once. Give it to the calling system
|
|
1514
|
+
({{ authChoice === "basic" ? "as the Basic-auth password" : authChoice === "bearer" ? "as the Bearer token" : "in the token header" }}).
|
|
1515
|
+
</DialogDescription>
|
|
1516
|
+
</DialogHeader>
|
|
1517
|
+
<div class="flex items-center gap-1.5">
|
|
1518
|
+
<Input
|
|
1519
|
+
readonly
|
|
1520
|
+
:model-value="rotatedSecret ?? ''"
|
|
1521
|
+
class="h-8 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
|
|
1522
|
+
@focus="$event.target.select()"
|
|
1523
|
+
/>
|
|
1524
|
+
<button
|
|
1525
|
+
type="button"
|
|
1526
|
+
title="Copy secret"
|
|
1527
|
+
class="flex size-8 shrink-0 items-center justify-center rounded-md border border-black/[0.08] bg-white text-muted-foreground transition hover:text-foreground"
|
|
1528
|
+
@click="copySecret"
|
|
1529
|
+
>
|
|
1530
|
+
<Copy :size="14" />
|
|
1531
|
+
</button>
|
|
1532
|
+
</div>
|
|
1533
|
+
</DialogContent>
|
|
1534
|
+
</Dialog>
|
|
876
1535
|
</template>
|
|
877
1536
|
<template v-else-if="draftTrigger.type === 'event'">
|
|
878
1537
|
<div class="space-y-1.5">
|
|
879
|
-
<Label class="text-mono text-muted-foreground">Event</Label>
|
|
1538
|
+
<Label for="ni-event-type" class="text-mono text-muted-foreground">Event</Label>
|
|
880
1539
|
<EventTypeCombobox
|
|
1540
|
+
input-id="ni-event-type"
|
|
881
1541
|
:model-value="readTriggerConfig('event_type') ?? ''"
|
|
882
1542
|
@update:model-value="(v) => writeTriggerConfig('event_type', v)"
|
|
883
1543
|
/>
|
|
884
|
-
<p class="text-
|
|
1544
|
+
<p class="text-xs text-muted-foreground">
|
|
885
1545
|
Runs whenever this platform event fires (e.g. <code class="font-mono">customer.created</code>, <code class="font-mono">order.placed</code>). The event payload is available to the workflow as <code class="font-mono">trigger.payload</code>.
|
|
886
1546
|
</p>
|
|
887
1547
|
</div>
|
|
1548
|
+
<div v-if="eventSample" class="space-y-1.5">
|
|
1549
|
+
<!-- A caption, not a field label: it heads a read-only block,
|
|
1550
|
+
so it stays a plain line rather than an orphan <label>. -->
|
|
1551
|
+
<div class="text-mono text-muted-foreground">Sample payload</div>
|
|
1552
|
+
<pre class="max-h-72 overflow-auto rounded-md border border-black/[0.06] bg-muted/20 p-2.5 font-mono text-[11px] leading-relaxed text-slate-700">{{ eventSample }}</pre>
|
|
1553
|
+
<p class="text-xs text-muted-foreground">From the event catalogue — the shape this event delivers as <code class="font-mono">trigger.payload</code>.</p>
|
|
1554
|
+
</div>
|
|
1555
|
+
<p v-else-if="eventTopic" class="text-xs text-muted-foreground">
|
|
1556
|
+
No sample payload in the event catalogue for this topic.
|
|
1557
|
+
</p>
|
|
888
1558
|
</template>
|
|
889
1559
|
<template v-else>
|
|
890
|
-
<
|
|
891
|
-
|
|
892
|
-
|
|
1560
|
+
<div class="space-y-1.5">
|
|
1561
|
+
<!-- Caption for the field list below, not a label for one
|
|
1562
|
+
control — the row's own controls are named individually. -->
|
|
1563
|
+
<div class="text-mono text-muted-foreground">Run payload fields</div>
|
|
1564
|
+
<p class="text-xs text-muted-foreground">
|
|
1565
|
+
What the Run dialog asks for when this workflow is started by hand. Each field lands in
|
|
1566
|
+
<code class="font-mono">trigger.payload.<key></code>; a File field becomes a manual upload.
|
|
1567
|
+
Fields referenced by nodes via <code class="font-mono">trigger.payload.*</code> are added automatically.
|
|
1568
|
+
</p>
|
|
1569
|
+
</div>
|
|
1570
|
+
<div v-if="manualFields.length" class="space-y-1.5">
|
|
1571
|
+
<div v-for="(mf, idx) in manualFields" :key="manualFieldUids[idx] ?? idx" class="flex items-center gap-1.5">
|
|
1572
|
+
<!-- A repeating row has no room for visible labels, so each
|
|
1573
|
+
control carries its own name and says which row it is. -->
|
|
1574
|
+
<Select :model-value="mf.kind ?? 'text'" @update:model-value="(v) => setManualField(idx, { kind: v })">
|
|
1575
|
+
<SelectTrigger :aria-label="`Kind of payload field ${idx + 1}`" size="sm" class="h-8 w-24 shrink-0 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1576
|
+
<SelectValue />
|
|
1577
|
+
</SelectTrigger>
|
|
1578
|
+
<SelectContent>
|
|
1579
|
+
<SelectItem value="text">Text</SelectItem>
|
|
1580
|
+
<SelectItem value="file">File</SelectItem>
|
|
1581
|
+
</SelectContent>
|
|
1582
|
+
</Select>
|
|
1583
|
+
<Input
|
|
1584
|
+
:aria-label="`Key of payload field ${idx + 1}`"
|
|
1585
|
+
:model-value="mf.path"
|
|
1586
|
+
placeholder="key (e.g. orderId or import.file)"
|
|
1587
|
+
class="h-8 w-[38%] rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-sm"
|
|
1588
|
+
@update:model-value="(v) => setManualField(idx, { path: String(v) })"
|
|
1589
|
+
/>
|
|
1590
|
+
<Input
|
|
1591
|
+
:aria-label="`Label of payload field ${idx + 1}`"
|
|
1592
|
+
:model-value="mf.label ?? ''"
|
|
1593
|
+
placeholder="Label shown in the Run dialog"
|
|
1594
|
+
class="h-8 min-w-0 flex-1 rounded-md border border-black/[0.08] ring-0 shadow-none bg-white text-sm"
|
|
1595
|
+
@update:model-value="(v) => setManualField(idx, { label: String(v) })"
|
|
1596
|
+
/>
|
|
1597
|
+
<button
|
|
1598
|
+
type="button"
|
|
1599
|
+
:aria-label="`Remove payload field ${idx + 1}`"
|
|
1600
|
+
class="flex size-7 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:text-rose-600"
|
|
1601
|
+
@click="removeManualField(idx)"
|
|
1602
|
+
>
|
|
1603
|
+
<Trash2 :size="13" />
|
|
1604
|
+
</button>
|
|
1605
|
+
</div>
|
|
1606
|
+
</div>
|
|
1607
|
+
<Button type="button" variant="outline" size="sm" class="h-7 text-sm" @click="addManualField">
|
|
1608
|
+
<Plus :size="12" />
|
|
1609
|
+
Add field
|
|
1610
|
+
</Button>
|
|
893
1611
|
</template>
|
|
894
1612
|
</div>
|
|
895
1613
|
|
|
896
1614
|
<!-- Parameters: node config, generated from the manifest -->
|
|
897
|
-
<div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-
|
|
1615
|
+
<div v-else-if="tab === 'parameters' && draftNode" class="mx-auto w-full max-w-5xl">
|
|
898
1616
|
<div v-if="manifest?.config?.length" class="space-y-3">
|
|
899
1617
|
<div class="flex items-center justify-between">
|
|
900
1618
|
<span class="text-mono text-muted-foreground">Configuration</span>
|
|
901
1619
|
<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
1620
|
<button
|
|
906
1621
|
type="button"
|
|
907
1622
|
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"
|
|
@@ -921,7 +1636,7 @@ async function runTestStep() {
|
|
|
921
1636
|
</div>
|
|
922
1637
|
</div>
|
|
923
1638
|
<IntegrationsNodeConfigField
|
|
924
|
-
v-for="field in
|
|
1639
|
+
v-for="field in requiredFields"
|
|
925
1640
|
:key="field.key"
|
|
926
1641
|
:field="field"
|
|
927
1642
|
:config="draftNode.config ?? {}"
|
|
@@ -932,8 +1647,51 @@ async function runTestStep() {
|
|
|
932
1647
|
:depth="0"
|
|
933
1648
|
:variables="variables"
|
|
934
1649
|
:upstream-outputs="upstreamOutputs"
|
|
1650
|
+
:refused="refusedFields?.[field.key]"
|
|
935
1651
|
@set="(k, v) => writeConfig(k, v)"
|
|
936
1652
|
/>
|
|
1653
|
+
|
|
1654
|
+
<!-- Optional settings, folded. The count is on the control on
|
|
1655
|
+
purpose: "Show 9 optional settings" is a decision the user can
|
|
1656
|
+
take without opening it, "Show more" is not. -->
|
|
1657
|
+
<template v-if="optionalFields.length">
|
|
1658
|
+
<button
|
|
1659
|
+
v-if="!optionalOpen"
|
|
1660
|
+
type="button"
|
|
1661
|
+
class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
|
|
1662
|
+
@click="optionalOpen = true"
|
|
1663
|
+
>
|
|
1664
|
+
<ChevronRight :size="13" />
|
|
1665
|
+
Show {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
|
|
1666
|
+
</button>
|
|
1667
|
+
<template v-else>
|
|
1668
|
+
<button
|
|
1669
|
+
v-if="requiredFields.length"
|
|
1670
|
+
type="button"
|
|
1671
|
+
class="flex items-center gap-1.5 rounded px-1 py-1 text-sm text-muted-foreground transition hover:text-foreground"
|
|
1672
|
+
@click="optionalOpen = false"
|
|
1673
|
+
>
|
|
1674
|
+
<ChevronDown :size="13" />
|
|
1675
|
+
Hide {{ optionalFields.length }} optional setting{{ optionalFields.length === 1 ? "" : "s" }}
|
|
1676
|
+
</button>
|
|
1677
|
+
<IntegrationsNodeConfigField
|
|
1678
|
+
v-for="field in optionalFields"
|
|
1679
|
+
:key="field.key"
|
|
1680
|
+
:field="field"
|
|
1681
|
+
:config="draftNode.config ?? {}"
|
|
1682
|
+
:slug="draftNode.nodeSlug"
|
|
1683
|
+
:version="draftNode.nodeVersion"
|
|
1684
|
+
:secret-keys="secretKeys"
|
|
1685
|
+
:static-keys="(manifest.config ?? []).map((f2) => f2.key)"
|
|
1686
|
+
:depth="0"
|
|
1687
|
+
:variables="variables"
|
|
1688
|
+
:upstream-outputs="upstreamOutputs"
|
|
1689
|
+
:refused="refusedFields?.[field.key]"
|
|
1690
|
+
@set="(k, v) => writeConfig(k, v)"
|
|
1691
|
+
/>
|
|
1692
|
+
</template>
|
|
1693
|
+
</template>
|
|
1694
|
+
|
|
937
1695
|
<p v-if="!parameterFields.length" class="text-sm text-muted-foreground italic">
|
|
938
1696
|
All of this node's options live on the Settings tab.
|
|
939
1697
|
</p>
|
|
@@ -944,10 +1702,19 @@ async function runTestStep() {
|
|
|
944
1702
|
</div>
|
|
945
1703
|
|
|
946
1704
|
<!-- Settings: node-instance execution settings -->
|
|
947
|
-
<div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-
|
|
948
|
-
<!--
|
|
1705
|
+
<div v-else-if="tab === 'settings' && draftNode" class="mx-auto w-full max-w-5xl space-y-6">
|
|
1706
|
+
<!-- The node's OWN options that happen to be about waiting or
|
|
1707
|
+
retrying. They read like engine settings and sit next to them,
|
|
1708
|
+
which is why both blocks say out loud which is which: a node's
|
|
1709
|
+
"Poll timeout" and the engine's "Timeout" are otherwise two
|
|
1710
|
+
identical-looking numbers with nothing to tell them apart. -->
|
|
949
1711
|
<div v-if="settingsFields.length" class="space-y-3">
|
|
950
|
-
<
|
|
1712
|
+
<div>
|
|
1713
|
+
<span class="text-mono text-muted-foreground">Options of this node</span>
|
|
1714
|
+
<p class="text-xs text-muted-foreground">
|
|
1715
|
+
Settings this node type defines for itself.
|
|
1716
|
+
</p>
|
|
1717
|
+
</div>
|
|
951
1718
|
<IntegrationsNodeConfigField
|
|
952
1719
|
v-for="field in settingsFields"
|
|
953
1720
|
:key="field.key"
|
|
@@ -960,51 +1727,67 @@ async function runTestStep() {
|
|
|
960
1727
|
:depth="0"
|
|
961
1728
|
:variables="variables"
|
|
962
1729
|
:upstream-outputs="upstreamOutputs"
|
|
1730
|
+
:refused="refusedFields?.[field.key]"
|
|
963
1731
|
@set="(k, v) => writeConfig(k, v)"
|
|
964
1732
|
/>
|
|
965
1733
|
</div>
|
|
1734
|
+
<!-- The rule only earns its place when there is something above to
|
|
1735
|
+
divide from. -->
|
|
1736
|
+
<div :class="settingsFields.length ? 'border-t border-black/[0.06] pt-5' : ''">
|
|
1737
|
+
<span class="text-mono text-muted-foreground">How the engine runs this step</span>
|
|
1738
|
+
<p class="text-xs text-muted-foreground">
|
|
1739
|
+
The same settings on every node type, applied by the engine around whatever the node does.
|
|
1740
|
+
</p>
|
|
1741
|
+
</div>
|
|
1742
|
+
|
|
966
1743
|
<!-- Retry policy -->
|
|
967
|
-
|
|
968
|
-
|
|
1744
|
+
<!-- One card: the toggle heads it, the policy fields expand
|
|
1745
|
+
inside — they belong to the same schematic unit. -->
|
|
1746
|
+
<section class="rounded-lg border border-black/[0.06] bg-muted/20">
|
|
1747
|
+
<div class="flex items-center gap-3 px-3 py-2">
|
|
1748
|
+
<Switch
|
|
1749
|
+
aria-label="Retry on failure"
|
|
1750
|
+
class="data-[state=unchecked]:bg-slate-300"
|
|
1751
|
+
:model-value="!!draftNode.retry"
|
|
1752
|
+
@update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
|
|
1753
|
+
/>
|
|
969
1754
|
<div>
|
|
970
1755
|
<div class="text-sm font-medium">Retry on failure</div>
|
|
971
1756
|
<p class="text-xs text-muted-foreground">
|
|
972
1757
|
Re-run this node automatically when an attempt fails.
|
|
973
1758
|
</p>
|
|
974
1759
|
</div>
|
|
975
|
-
<Switch
|
|
976
|
-
:model-value="!!draftNode.retry"
|
|
977
|
-
@update:model-value="(v) => patchNode({ retry: v ? { maxAttempts: 3, backoffSeconds: 1, strategy: 'exponential' } : void 0 })"
|
|
978
|
-
/>
|
|
979
1760
|
</div>
|
|
980
|
-
<div v-if="draftNode.retry" class="grid grid-cols-
|
|
1761
|
+
<div v-if="draftNode.retry" class="grid grid-cols-4 gap-3 border-t border-black/[0.06] p-3">
|
|
981
1762
|
<div class="space-y-1.5">
|
|
982
|
-
<Label class="text-mono text-muted-foreground">Max attempts</Label>
|
|
1763
|
+
<Label for="ni-retry-attempts" class="text-mono text-muted-foreground">Max attempts</Label>
|
|
983
1764
|
<Input
|
|
1765
|
+
id="ni-retry-attempts"
|
|
984
1766
|
type="number"
|
|
985
1767
|
min="1"
|
|
986
1768
|
:model-value="draftNode.retry?.maxAttempts ?? 3"
|
|
987
|
-
class="h-8 text-sm bg-white"
|
|
1769
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
|
|
988
1770
|
@update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, maxAttempts: Number(v) || 1 } })"
|
|
989
1771
|
/>
|
|
990
1772
|
</div>
|
|
991
1773
|
<div class="space-y-1.5">
|
|
992
|
-
<Label class="text-mono text-muted-foreground">Backoff seconds</Label>
|
|
1774
|
+
<Label for="ni-retry-backoff" class="text-mono text-muted-foreground">Backoff seconds</Label>
|
|
993
1775
|
<Input
|
|
1776
|
+
id="ni-retry-backoff"
|
|
994
1777
|
type="number"
|
|
995
1778
|
min="0"
|
|
996
1779
|
:model-value="draftNode.retry?.backoffSeconds ?? 0"
|
|
997
|
-
class="h-8 text-sm bg-white"
|
|
1780
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
|
|
998
1781
|
@update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, backoffSeconds: Number(v) || 0 } })"
|
|
999
1782
|
/>
|
|
1000
1783
|
</div>
|
|
1001
1784
|
<div class="col-span-2 space-y-1.5">
|
|
1002
|
-
<Label class="text-mono text-muted-foreground">Strategy</Label>
|
|
1785
|
+
<Label for="ni-retry-strategy" class="text-mono text-muted-foreground">Strategy</Label>
|
|
1003
1786
|
<Select
|
|
1004
1787
|
:model-value="draftNode.retry?.strategy ?? 'exponential'"
|
|
1005
1788
|
@update:model-value="(v) => patchNode({ retry: { ...draftNode.retry, strategy: v } })"
|
|
1006
1789
|
>
|
|
1007
|
-
<SelectTrigger class="h-8 text-sm bg-white">
|
|
1790
|
+
<SelectTrigger id="ni-retry-strategy" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1008
1791
|
<SelectValue />
|
|
1009
1792
|
</SelectTrigger>
|
|
1010
1793
|
<SelectContent>
|
|
@@ -1018,14 +1801,15 @@ async function runTestStep() {
|
|
|
1018
1801
|
|
|
1019
1802
|
<!-- Timeout: engine start-to-close ceiling for one attempt -->
|
|
1020
1803
|
<section class="space-y-1.5">
|
|
1021
|
-
<Label class="text-mono text-muted-foreground">Timeout</Label>
|
|
1804
|
+
<Label for="ni-timeout" class="text-mono text-muted-foreground">Timeout</Label>
|
|
1022
1805
|
<Input
|
|
1806
|
+
id="ni-timeout"
|
|
1023
1807
|
type="number"
|
|
1024
1808
|
min="1"
|
|
1025
1809
|
max="7200"
|
|
1026
1810
|
placeholder="600"
|
|
1027
1811
|
:model-value="draftNode.timeoutSeconds ?? ''"
|
|
1028
|
-
class="h-8 text-sm bg-white"
|
|
1812
|
+
class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white"
|
|
1029
1813
|
@update:model-value="(v) => patchNode({ timeoutSeconds: Number(v) > 0 ? Math.min(Math.trunc(Number(v)), 7200) : void 0 })"
|
|
1030
1814
|
/>
|
|
1031
1815
|
<p class="text-xs text-muted-foreground">
|
|
@@ -1035,12 +1819,12 @@ async function runTestStep() {
|
|
|
1035
1819
|
|
|
1036
1820
|
<!-- On error: what happens once the retries are exhausted -->
|
|
1037
1821
|
<section class="space-y-1.5">
|
|
1038
|
-
<Label class="text-mono text-muted-foreground">On error</Label>
|
|
1822
|
+
<Label for="ni-on-error" class="text-mono text-muted-foreground">On error</Label>
|
|
1039
1823
|
<Select
|
|
1040
1824
|
:model-value="draftNode.onError ?? 'fail'"
|
|
1041
1825
|
@update:model-value="(v) => patchNode({ onError: v === 'route' ? 'route' : void 0 })"
|
|
1042
1826
|
>
|
|
1043
|
-
<SelectTrigger class="h-8 text-sm bg-white">
|
|
1827
|
+
<SelectTrigger id="ni-on-error" size="sm" class="h-8 rounded-md border border-black/[0.08] ring-0 shadow-none text-sm bg-white">
|
|
1044
1828
|
<SelectValue />
|
|
1045
1829
|
</SelectTrigger>
|
|
1046
1830
|
<SelectContent>
|
|
@@ -1052,165 +1836,153 @@ async function runTestStep() {
|
|
|
1052
1836
|
Applies after the retry policy is exhausted. A Stop and Error node always fails the run.
|
|
1053
1837
|
</p>
|
|
1054
1838
|
</section>
|
|
1839
|
+
</div>
|
|
1055
1840
|
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1841
|
+
<!-- Test: run this one step and look at what came back. Its own tab
|
|
1842
|
+
rather than a permanent column — it is the last thing done, if it
|
|
1843
|
+
is done at all, and it needs the width when it is. -->
|
|
1844
|
+
<div v-else-if="tab === 'output' && draftNode" class="mx-auto w-full max-w-5xl space-y-5">
|
|
1845
|
+
<section class="space-y-2">
|
|
1846
|
+
<div class="flex items-center justify-between gap-2">
|
|
1847
|
+
<p class="text-xs text-muted-foreground">
|
|
1848
|
+
What downstream nodes receive from this one. Declared by the node type;
|
|
1849
|
+
filled with real values once the step has run.
|
|
1850
|
+
</p>
|
|
1851
|
+
<div class="flex shrink-0 items-center rounded-md border border-black/[0.06] bg-white p-0.5">
|
|
1852
|
+
<button type="button" :class="outputView === 'schema' ? segActive : segIdle" @click="outputView = 'schema'">
|
|
1853
|
+
Schema
|
|
1854
|
+
</button>
|
|
1855
|
+
<button type="button" :disabled="!selfSample" :class="outputView === 'json' ? segActive : segIdle" @click="outputView = 'json'">
|
|
1856
|
+
JSON
|
|
1857
|
+
</button>
|
|
1858
|
+
</div>
|
|
1859
|
+
</div>
|
|
1860
|
+
<template v-if="outputView === 'schema'">
|
|
1861
|
+
<IntegrationsNodeOutputSchema :ports="selfPorts" :value-at="(port, field2) => chipValue(draft.id, port, field2)" />
|
|
1862
|
+
</template>
|
|
1863
|
+
<pre v-else class="overflow-auto rounded-md border border-black/[0.06] bg-white p-3 font-mono text-xs leading-snug">{{ selfSampleJson }}</pre>
|
|
1864
|
+
</section>
|
|
1865
|
+
|
|
1866
|
+
<section class="space-y-2 border-t border-black/[0.06] pt-4">
|
|
1867
|
+
<span class="text-mono text-muted-foreground">Real values</span>
|
|
1868
|
+
<div class="flex items-center gap-2">
|
|
1869
|
+
<Button
|
|
1870
|
+
size="sm"
|
|
1871
|
+
class="h-8"
|
|
1872
|
+
:disabled="testRunning"
|
|
1873
|
+
title="Runs this node once with the current draft config"
|
|
1874
|
+
@click="runTestStep"
|
|
1875
|
+
>
|
|
1876
|
+
{{ testRunning ? "Testing\u2026" : "Test step" }}
|
|
1877
|
+
</Button>
|
|
1878
|
+
<button
|
|
1879
|
+
type="button"
|
|
1880
|
+
class="rounded px-1 py-1 text-sm font-medium text-muted-foreground transition hover:text-foreground"
|
|
1881
|
+
@click="toggleTestInput"
|
|
1882
|
+
>
|
|
1883
|
+
{{ testInputOpen ? "Hide input" : "Edit input" }}
|
|
1884
|
+
</button>
|
|
1885
|
+
<span class="text-xs text-muted-foreground">
|
|
1886
|
+
Runs this step alone, against the draft config — the workflow is not started.
|
|
1887
|
+
</span>
|
|
1888
|
+
</div>
|
|
1059
1889
|
<Textarea
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1890
|
+
v-if="testInputOpen"
|
|
1891
|
+
v-model="testInputDraft"
|
|
1892
|
+
aria-label="Test input"
|
|
1893
|
+
:rows="8"
|
|
1894
|
+
spellcheck="false"
|
|
1895
|
+
class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
|
|
1065
1896
|
/>
|
|
1066
|
-
<p class="text-
|
|
1067
|
-
|
|
1897
|
+
<p v-if="testError" class="text-sm text-rose-600">
|
|
1898
|
+
{{ testError }}
|
|
1068
1899
|
</p>
|
|
1900
|
+
<div v-if="testLogs.length" class="max-h-48 space-y-0.5 overflow-y-auto rounded-md border border-black/[0.06] bg-white p-2">
|
|
1901
|
+
<div
|
|
1902
|
+
v-for="(l, i) in testLogs"
|
|
1903
|
+
:key="i"
|
|
1904
|
+
class="font-mono text-[11px] leading-snug"
|
|
1905
|
+
:class="l.level === 'error' ? 'text-rose-600' : l.level === 'warn' ? 'text-amber-600' : 'text-slate-600'"
|
|
1906
|
+
>
|
|
1907
|
+
{{ l.message }}
|
|
1908
|
+
</div>
|
|
1909
|
+
</div>
|
|
1069
1910
|
</section>
|
|
1070
|
-
</div>
|
|
1071
|
-
</div>
|
|
1072
|
-
</section>
|
|
1073
1911
|
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
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>
|
|
1112
|
-
</div>
|
|
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 }}
|
|
1131
|
-
</div>
|
|
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>
|
|
1140
|
-
</div>
|
|
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>
|
|
1912
|
+
<section class="space-y-2 border-t border-black/[0.06] pt-4">
|
|
1913
|
+
<template v-if="!pinEditorOpen">
|
|
1914
|
+
<div class="flex items-center gap-2">
|
|
1915
|
+
<Button variant="outline" size="sm" class="h-8" @click="openPinEditor">
|
|
1916
|
+
{{ selfPinned ? "Edit pinned sample" : "Pin sample output" }}
|
|
1917
|
+
</Button>
|
|
1918
|
+
<button
|
|
1919
|
+
v-if="selfPinned"
|
|
1920
|
+
type="button"
|
|
1921
|
+
class="text-sm font-medium text-rose-600 transition hover:text-rose-700"
|
|
1922
|
+
@click="removePin"
|
|
1923
|
+
>
|
|
1924
|
+
Unpin
|
|
1925
|
+
</button>
|
|
1926
|
+
<span class="text-xs text-muted-foreground">
|
|
1927
|
+
A pinned sample stands in for run data while you map downstream fields.
|
|
1928
|
+
</span>
|
|
1146
1929
|
</div>
|
|
1147
|
-
</
|
|
1148
|
-
<
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
</
|
|
1168
|
-
|
|
1169
|
-
|
|
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>
|
|
1930
|
+
</template>
|
|
1931
|
+
<template v-else>
|
|
1932
|
+
<Textarea
|
|
1933
|
+
v-model="pinDraft"
|
|
1934
|
+
aria-label="Pinned sample output"
|
|
1935
|
+
:rows="10"
|
|
1936
|
+
spellcheck="false"
|
|
1937
|
+
class="rounded-md border border-black/[0.08] ring-0 shadow-none bg-white font-mono text-xs"
|
|
1938
|
+
/>
|
|
1939
|
+
<p v-if="pinError" class="text-sm text-amber-600">
|
|
1940
|
+
{{ pinError }}
|
|
1941
|
+
</p>
|
|
1942
|
+
<div class="flex items-center gap-2">
|
|
1943
|
+
<Button size="sm" class="h-8" @click="savePin">
|
|
1944
|
+
Pin
|
|
1945
|
+
</Button>
|
|
1946
|
+
<Button size="sm" variant="outline" class="h-8" @click="pinEditorOpen = false">
|
|
1947
|
+
Cancel
|
|
1948
|
+
</Button>
|
|
1949
|
+
</div>
|
|
1950
|
+
</template>
|
|
1951
|
+
</section>
|
|
1952
|
+
</div>
|
|
1200
1953
|
</div>
|
|
1201
|
-
</
|
|
1954
|
+
</section>
|
|
1202
1955
|
</div>
|
|
1203
1956
|
|
|
1204
1957
|
<!-- Footer: draft semantics — nothing reaches the blob until Save -->
|
|
1205
1958
|
<div class="flex shrink-0 items-center justify-between gap-3 border-t border-black/[0.06] px-4 py-3">
|
|
1959
|
+
<!-- Quiet until reached for: a bare glyph that unfolds into its name and
|
|
1960
|
+
its colour on hover or focus.
|
|
1961
|
+
|
|
1962
|
+
It is the rarest of the four ways to delete a node — the canvas
|
|
1963
|
+
offers the card's toolbar, the context menu and the Delete key — and
|
|
1964
|
+
it sits in the row the user reads to decide what happens to their
|
|
1965
|
+
edits, where a third, louder option reads as one of the answers.
|
|
1966
|
+
Rose carries a meaning now, too: a setting the last save refused.
|
|
1967
|
+
Spending it on a footer button weakens that.
|
|
1968
|
+
|
|
1969
|
+
The name is on the button itself, not only in the revealed span, so
|
|
1970
|
+
it reaches a screen reader without a pointer. Revealing on focus as
|
|
1971
|
+
well as hover is what keeps it usable from the keyboard. The button
|
|
1972
|
+
grows leftmost into empty space, so nothing else moves. -->
|
|
1206
1973
|
<Button
|
|
1207
1974
|
variant="ghost"
|
|
1208
1975
|
size="sm"
|
|
1209
|
-
|
|
1976
|
+
aria-label="Remove from workflow"
|
|
1977
|
+
class="group h-8 gap-0 text-sm text-muted-foreground transition-colors hover:bg-rose-500/10 hover:text-rose-700 focus-visible:bg-rose-500/10 focus-visible:text-rose-700"
|
|
1210
1978
|
@click="emit('remove')"
|
|
1211
1979
|
>
|
|
1212
1980
|
<Trash2 :size="13" />
|
|
1213
|
-
|
|
1981
|
+
<span
|
|
1982
|
+
class="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-150 group-hover:ml-1.5 group-hover:max-w-48 group-hover:opacity-100 group-focus-visible:ml-1.5 group-focus-visible:max-w-48 group-focus-visible:opacity-100"
|
|
1983
|
+
>
|
|
1984
|
+
Remove from workflow
|
|
1985
|
+
</span>
|
|
1214
1986
|
</Button>
|
|
1215
1987
|
<div class="flex items-center gap-2">
|
|
1216
1988
|
<span v-if="dirty" class="text-xs text-muted-foreground">Unsaved changes</span>
|