@revenexx/studio-integrations 2.5.0 → 2.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +12 -0
  4. package/dist/runtime/components/Integrations/ExpressionInput.vue +43 -6
  5. package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +12 -0
  6. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +4 -0
  7. package/dist/runtime/components/Integrations/NodeConfigField.vue +8 -1
  8. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +4 -0
  9. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +4 -0
  10. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +4 -0
  11. package/dist/runtime/components/Integrations/NodePalette.vue +7 -1
  12. package/dist/runtime/components/Integrations/RunResponseModal.d.vue.ts +6 -0
  13. package/dist/runtime/components/Integrations/RunResponseModal.vue +27 -4
  14. package/dist/runtime/components/Integrations/RunResponseModal.vue.d.ts +6 -0
  15. package/dist/runtime/components/Integrations/RunsTable.d.vue.ts +8 -1
  16. package/dist/runtime/components/Integrations/RunsTable.vue +14 -11
  17. package/dist/runtime/components/Integrations/RunsTable.vue.d.ts +8 -1
  18. package/dist/runtime/components/Integrations/WorkflowEditor.vue +26 -9
  19. package/dist/runtime/composables/useIntegrationsNodes.js +3 -2
  20. package/dist/runtime/composables/useIntegrationsRuns.d.ts +3 -1
  21. package/dist/runtime/composables/useIntegrationsRuns.js +4 -3
  22. package/dist/runtime/data/integrationNodeCatalog.js +2 -2
  23. package/dist/runtime/data/nodeIcons.d.ts +7 -1
  24. package/dist/runtime/data/nodeIcons.js +6 -3
  25. package/dist/runtime/data/nodeInputData.d.ts +6 -0
  26. package/dist/runtime/data/nodeInputData.js +14 -5
  27. package/dist/runtime/i18n/messages/de.json +13 -0
  28. package/dist/runtime/i18n/messages/en.json +13 -0
  29. package/dist/runtime/pages/integrations/workflows/[id].vue +99 -39
  30. package/dist/runtime/types/integrations.d.ts +10 -1
  31. package/dist/runtime/utils/folderMark.d.ts +20 -0
  32. package/dist/runtime/utils/folderMark.js +12 -0
  33. package/dist/runtime/utils/runFeedQuery.d.ts +9 -1
  34. package/dist/runtime/utils/runFeedQuery.js +14 -4
  35. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - e589a22: A workflow's own Runs tab can be ordered again by the Started and Duration columns, across the workflow's whole history rather than the page in hand, and a new order starts at the first page. The list is no longer re-sorted by start in the browser after it arrives (PO-385).
8
+ - 6ced93a: The `{ }` mapping list says what each earlier output is (list, number, …). A setting that declares `accepts` gets the outputs of that kind first and marked, and an empty one is offered the single fitting output in one click. A required setting the last save refused is named by its label ("Elemente fehlt noch.") instead of the platform's raw sentence (PO-594).
9
+
10
+ ### Patch Changes
11
+
12
+ - 962f50d: Saving a workflow says "Saved" in the header once the platform has taken it. The chip beside Save that reports the build waits three seconds before it appears, so a build that finishes in normal time is no longer announced step by step and then dropped without a word (PO-597).
13
+ - 14dfdf1: A run the platform refuses is headed "Run not started" instead of "Run started — Started in the background" above the reason it did not start, and that reason is what the dialog announces as its description. A refusal is a 4xx answer with no run in it; an answer that carries a run keeps its heading and its way into the run. A run that does not wait and ends in a server error or no answer at all is headed "Start not confirmed", since a run may or may not exist. While the platform has not answered, the dialog is headed "Starting run" or "Run in progress" rather than as if it had. A run that started and then failed or timed out keeps its heading. The dialog's subtitle is set in the ordinary typeface rather than monospace (PO-613).
14
+ - 5c7c2bd: The trigger choice in "New workflow" addresses the author as "Sie", like the rest of the studio: "Starten Sie den Workflow selbst …" and "… nach Ihrem Zeitplan …". The German-copy test now also reads the `de:` texts written straight into the source, where these two had slipped past it (PO-616).
15
+
16
+ ## 2.5.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 2ce4664: "Use revenexx Mail Servers" copies the login the mail server accepts — the sender's name and key — instead of an empty one, and a sender without a secret copies nothing, clears what an earlier pick copied, and says so. A sender created from the panel is copied the same way, and one created without a secret is no longer also reported as created (PO-602, PO-603).
21
+
3
22
  ## 2.5.0
4
23
 
5
24
  ### Minor Changes
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
3
  "configKey": "studioIntegrations",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -4,6 +4,12 @@ type UpstreamOutput = {
4
4
  nodeName?: string;
5
5
  output: string;
6
6
  fields?: string[];
7
+ /** The port's declared data type, and each field's — what an entry IS (PO-594). */
8
+ dataType?: string | null;
9
+ fieldTypes?: Record<string, string | null>;
10
+ /** What the port and each field are called, resolved; the key stands in where absent. */
11
+ portLabel?: string | null;
12
+ fieldLabels?: Record<string, string | null>;
7
13
  };
8
14
  type __VLS_Props = {
9
15
  modelValue: unknown;
@@ -12,6 +18,12 @@ type __VLS_Props = {
12
18
  upstreamOutputs?: UpstreamOutput[];
13
19
  /** Draw the editor as at fault — the field above owns the reason. */
14
20
  invalid?: boolean;
21
+ /**
22
+ * What a mapped value has to be, as the setting declares it (`array` for a list).
23
+ * The outputs of that kind are listed first and marked, and an empty setting with
24
+ * exactly one of them below it offers that one in a click (PO-594).
25
+ */
26
+ accepts?: string;
15
27
  /** The setting's label, so the editable region has an accessible name. */
16
28
  label?: string;
17
29
  /** The setting takes several lines: Enter opens one instead of being
@@ -16,6 +16,7 @@ const props = defineProps({
16
16
  variables: { type: Object, required: false },
17
17
  upstreamOutputs: { type: Array, required: false },
18
18
  invalid: { type: Boolean, required: false },
19
+ accepts: { type: String, required: false },
19
20
  label: { type: String, required: false },
20
21
  multiline: { type: Boolean, required: false }
21
22
  });
@@ -221,6 +222,7 @@ const preview = computed(() => {
221
222
  if (!sampleContext) return null;
222
223
  return resolveExpressionText(text.value, sampleContext.value);
223
224
  });
225
+ const wantedKind = computed(() => props.accepts && props.accepts !== "any" ? props.accepts : null);
224
226
  const sections = computed(() => {
225
227
  const out = [];
226
228
  const vars = props.variables ?? {};
@@ -230,27 +232,41 @@ const sections = computed(() => {
230
232
  }));
231
233
  if (varItems.length) out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.variables"), items: varItems });
232
234
  const upstream = props.upstreamOutputs ?? [];
235
+ const fits = (kind) => !!wantedKind.value && kind === wantedKind.value;
233
236
  const nodeItems = upstream.flatMap((u) => {
234
237
  const base = "nodes." + u.nodeId + ".outputs." + u.output;
235
238
  const rows = [{
236
- label: (u.nodeName || u.nodeId) + " \u2192 " + u.output,
237
- expr: tok(base)
239
+ label: (u.nodeName || u.nodeId) + " \u2192 " + (u.portLabel || u.output),
240
+ expr: tok(base),
241
+ kind: u.dataType ?? null,
242
+ fits: fits(u.dataType)
238
243
  }];
239
244
  for (const f of u.fields ?? []) {
240
- rows.push({ label: (u.nodeName || u.nodeId) + " \u2192 " + u.output + "." + f, expr: tok(base + "." + f) });
245
+ const kind = u.fieldTypes?.[f] ?? null;
246
+ rows.push({ label: (u.nodeName || u.nodeId) + " \u2192 " + (u.portLabel || u.output) + " \u2192 " + (u.fieldLabels?.[f] || f), expr: tok(base + "." + f), kind, fits: fits(kind) });
241
247
  }
242
248
  return rows;
243
249
  });
250
+ if (wantedKind.value) nodeItems.sort((a, b) => Number(!!b.fits) - Number(!!a.fits));
244
251
  if (nodeItems.length) out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.nodeOutputs"), items: nodeItems });
245
252
  const primary = upstream[0];
246
253
  const inItems = [{ label: t("integration.nodeConfig.expression.suggestion.inputPayload"), expr: tok("in") }];
247
254
  for (const f of primary?.fields ?? []) {
248
- inItems.push({ label: "input." + f, expr: tok("in." + f) });
255
+ inItems.push({ label: "input." + f, expr: tok("in." + f), kind: primary?.fieldTypes?.[f] ?? null });
249
256
  }
250
257
  out.push({ heading: t("integration.nodeConfig.expression.suggestion.heading.input"), items: inItems });
251
258
  return out;
252
259
  });
253
260
  const hasSuggestions = computed(() => sections.value.some((s) => s.items.length > 0));
261
+ function kindLabel(kind) {
262
+ return kind && ["array", "object", "string", "number", "boolean"].includes(kind) ? t("integration.nodeConfig.expression.kind." + kind) : null;
263
+ }
264
+ const singleFit = computed(() => {
265
+ if (!wantedKind.value) return null;
266
+ const nodes = sections.value.find((s) => s.heading === t("integration.nodeConfig.expression.suggestion.heading.nodeOutputs"));
267
+ const fitting = (nodes?.items ?? []).filter((i) => i.fits);
268
+ return fitting.length === 1 ? fitting[0] : null;
269
+ });
254
270
  const query = ref("");
255
271
  const filteredSections = computed(() => {
256
272
  const q = query.value.trim().toLowerCase();
@@ -269,6 +285,9 @@ function toggle() {
269
285
  function onTriggerClick(e) {
270
286
  if (e.detail === 0) toggle();
271
287
  }
288
+ function onFitClick(e) {
289
+ if (e.detail === 0 && singleFit.value) insertToken(singleFit.value.expr);
290
+ }
272
291
  function onFocusOut(e) {
273
292
  if (wrapRef.value && e.relatedTarget instanceof globalThis.Node && wrapRef.value.contains(e.relatedTarget)) return;
274
293
  open.value = false;
@@ -343,6 +362,17 @@ function insert(expr) {
343
362
  </button>
344
363
  </div>
345
364
 
365
+ <button
366
+ v-if="singleFit && !text"
367
+ type="button"
368
+ class="mt-1 inline-flex max-w-full items-center gap-1 rounded-md border border-emerald-600/30 bg-emerald-50 px-2 py-1 text-left text-xs text-emerald-800 transition hover:bg-emerald-100"
369
+ @mousedown.prevent="insertToken(singleFit.expr)"
370
+ @click="onFitClick"
371
+ >
372
+ <span class="shrink-0 font-medium">{{ t("integration.nodeConfig.expression.useFit") }}</span>
373
+ <span class="min-w-0 break-words">{{ singleFit.label }}</span>
374
+ </button>
375
+
346
376
  <p
347
377
  v-if="preview"
348
378
  class="mt-1 flex min-w-0 items-baseline gap-1 text-[11px]"
@@ -388,10 +418,17 @@ function insert(expr) {
388
418
  :key="item.expr"
389
419
  type="button"
390
420
  class="block w-full px-2 py-1 text-left transition hover:bg-muted/50"
421
+ :class="wantedKind && !item.fits && section.heading === t('integration.nodeConfig.expression.suggestion.heading.nodeOutputs') ? 'opacity-60' : ''"
391
422
  @mousedown.prevent="insert(item.expr)"
392
423
  >
393
- <div class="break-words text-muted-foreground">
394
- {{ item.label }}
424
+ <div class="flex flex-wrap items-baseline gap-x-1.5 text-muted-foreground">
425
+ <span class="min-w-0 break-words">{{ item.label }}</span>
426
+ <span
427
+ v-if="kindLabel(item.kind)"
428
+ class="rounded px-1 text-[10px] font-medium uppercase tracking-wide"
429
+ :class="item.fits ? 'bg-emerald-100 text-emerald-800' : 'bg-muted text-muted-foreground'"
430
+ >{{ kindLabel(item.kind) }}</span>
431
+ <span v-if="item.fits" class="text-[10px] font-medium text-emerald-700">{{ t("integration.nodeConfig.expression.fits") }}</span>
395
432
  </div>
396
433
  <div class="break-all font-mono text-emerald-700">
397
434
  {{ item.expr }}
@@ -4,6 +4,12 @@ type UpstreamOutput = {
4
4
  nodeName?: string;
5
5
  output: string;
6
6
  fields?: string[];
7
+ /** The port's declared data type, and each field's — what an entry IS (PO-594). */
8
+ dataType?: string | null;
9
+ fieldTypes?: Record<string, string | null>;
10
+ /** What the port and each field are called, resolved; the key stands in where absent. */
11
+ portLabel?: string | null;
12
+ fieldLabels?: Record<string, string | null>;
7
13
  };
8
14
  type __VLS_Props = {
9
15
  modelValue: unknown;
@@ -12,6 +18,12 @@ type __VLS_Props = {
12
18
  upstreamOutputs?: UpstreamOutput[];
13
19
  /** Draw the editor as at fault — the field above owns the reason. */
14
20
  invalid?: boolean;
21
+ /**
22
+ * What a mapped value has to be, as the setting declares it (`array` for a list).
23
+ * The outputs of that kind are listed first and marked, and an empty setting with
24
+ * exactly one of them below it offers that one in a click (PO-594).
25
+ */
26
+ accepts?: string;
15
27
  /** The setting's label, so the editable region has an accessible name. */
16
28
  label?: string;
17
29
  /** The setting takes several lines: Enter opens one instead of being
@@ -18,6 +18,10 @@ type __VLS_Props = {
18
18
  nodeName?: string;
19
19
  output: string;
20
20
  fields?: string[];
21
+ dataType?: string | null;
22
+ fieldTypes?: Record<string, string | null>;
23
+ portLabel?: string | null;
24
+ fieldLabels?: Record<string, string | null>;
21
25
  }>;
22
26
  /** Why the last save refused this setting, if it did. */
23
27
  refused?: string;
@@ -85,7 +85,12 @@ const formatError = computed(() => {
85
85
  }
86
86
  return null;
87
87
  });
88
- const fieldError = computed(() => formatError.value ?? props.refused ?? null);
88
+ const fieldError = computed(() => formatError.value ?? refusedSentence.value ?? null);
89
+ const refusedSentence = computed(() => {
90
+ const r = props.refused;
91
+ if (!r) return null;
92
+ return /^Required config field '[^']+' is missing\.$/.test(r) ? t("integration.nodeConfig.required.missing", { label: resolveLocalized(props.field.label) || props.field.key }) : r;
93
+ });
89
94
  const invalid = computed(() => !!fieldError.value);
90
95
  const constraintHint = computed(() => {
91
96
  const val = props.field.validation;
@@ -452,6 +457,7 @@ function setArrayItemField(index, fieldKey, v) {
452
457
  :placeholder="resolveLocalized(field.placeholder)"
453
458
  :variables="variables"
454
459
  :upstream-outputs="upstreamOutputs"
460
+ :accepts="field.accepts"
455
461
  :invalid="invalid"
456
462
  :label="controlName"
457
463
  :multiline="isMultiline"
@@ -489,6 +495,7 @@ function setArrayItemField(index, fieldKey, v) {
489
495
  :placeholder="resolveLocalized(field.placeholder)"
490
496
  :variables="variables"
491
497
  :upstream-outputs="upstreamOutputs"
498
+ :accepts="field.accepts"
492
499
  :invalid="invalid"
493
500
  :label="controlName"
494
501
  :multiline="isMultiline"
@@ -18,6 +18,10 @@ type __VLS_Props = {
18
18
  nodeName?: string;
19
19
  output: string;
20
20
  fields?: string[];
21
+ dataType?: string | null;
22
+ fieldTypes?: Record<string, string | null>;
23
+ portLabel?: string | null;
24
+ fieldLabels?: Record<string, string | null>;
21
25
  }>;
22
26
  /** Why the last save refused this setting, if it did. */
23
27
  refused?: string;
@@ -11,6 +11,10 @@ type __VLS_Props = {
11
11
  nodeName?: string;
12
12
  output: string;
13
13
  fields?: string[];
14
+ dataType?: string | null;
15
+ fieldTypes?: Record<string, string | null>;
16
+ portLabel?: string | null;
17
+ fieldLabels?: Record<string, string | null>;
14
18
  }>;
15
19
  /** Rename intent (context menu / F2): focus + select the name field on open. */
16
20
  autofocusName?: boolean;
@@ -11,6 +11,10 @@ type __VLS_Props = {
11
11
  nodeName?: string;
12
12
  output: string;
13
13
  fields?: string[];
14
+ dataType?: string | null;
15
+ fieldTypes?: Record<string, string | null>;
16
+ portLabel?: string | null;
17
+ fieldLabels?: Record<string, string | null>;
14
18
  }>;
15
19
  /** Rename intent (context menu / F2): focus + select the name field on open. */
16
20
  autofocusName?: boolean;
@@ -6,6 +6,8 @@ import * as Lucide from "lucide-vue-next";
6
6
  import { INTEGRATION_NODE_CATALOG, isOffered, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
7
7
  import { resolveLocalized, studioLocale } from "../../utils/localize";
8
8
  import { orderFolderLeaves, orderFolders } from "../../utils/paletteOrder";
9
+ import { declaredFolderIcon } from "../../utils/folderMark";
10
+ import { nodeIcon } from "../../data/nodeIcons";
9
11
  const { load: loadNodes } = useIntegrationsNodes();
10
12
  onMounted(() => {
11
13
  void loadNodes();
@@ -179,6 +181,9 @@ const currentGroup = computed(() => {
179
181
  }
180
182
  return cursor ?? null;
181
183
  });
184
+ const appRowIcons = computed(() => new Map(
185
+ appRows.value.map(([label, g]) => [label, declaredFolderIcon(subtreeManifests(g))])
186
+ ));
182
187
  const levelTitle = computed(() => {
183
188
  if (triggersLevel.value) return t("integration.canvas.palette.group.triggers");
184
189
  return currentGroup.value?.label ?? t("integration.canvas.palette.title.addNode");
@@ -469,7 +474,8 @@ const searching = computed(() => search.value.trim().length > 0);
469
474
  @click="pushPath(['__apps', label])"
470
475
  >
471
476
  <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-white ring-1 ring-black/[0.06]">
472
- <IntegrationsNodeMark v-if="groupLogoManifest(group)" :manifest="groupLogoManifest(group)" :size="14" logo-only />
477
+ <component :is="nodeIcon(appRowIcons.get(label))" v-if="appRowIcons.get(label)" :size="13" class="text-violet-600" />
478
+ <IntegrationsNodeMark v-else-if="groupLogoManifest(group)" :manifest="groupLogoManifest(group)" :size="14" logo-only />
473
479
  <Lucide.Package v-else :size="13" class="text-violet-600" />
474
480
  </span>
475
481
  <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ label }}</span>
@@ -38,6 +38,12 @@ type __VLS_Props = {
38
38
  error?: string | null;
39
39
  /** The run behind this result (sync failure / async start) for deep-linking. */
40
40
  runId?: string | null;
41
+ /**
42
+ * The platform answered the press with a refusal, so no run exists — as opposed
43
+ * to a run that started and then failed or outran the wait. Decided by the page,
44
+ * which has the status code; `error` alone cannot tell the two apart.
45
+ */
46
+ refused?: boolean;
41
47
  };
42
48
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
43
49
  "update:open": (args_0: boolean) => any;
@@ -21,7 +21,8 @@ const props = defineProps({
21
21
  result: { type: null, required: false },
22
22
  following: { type: Boolean, required: false },
23
23
  error: { type: [String, null], required: false },
24
- runId: { type: [String, null], required: false }
24
+ runId: { type: [String, null], required: false },
25
+ refused: { type: Boolean, required: false }
25
26
  });
26
27
  const emit = defineEmits(["update:open", "view-run"]);
27
28
  const t = useIntegrationsT();
@@ -40,6 +41,17 @@ const ended = computed(() => {
40
41
  return isTerminalRun(props.run);
41
42
  });
42
43
  const returnedNothing = computed(() => props.result == null || typeof props.result === "object" && !Array.isArray(props.result) && !Object.keys(props.result).length);
44
+ const refused = computed(() => !props.loading && !!props.refused);
45
+ const unconfirmed = computed(() => !props.loading && !refused.value && props.mode === "async" && !!props.error && !runNumber.value);
46
+ const title = computed(() => {
47
+ if (props.loading) {
48
+ return props.mode === "async" ? t("integration.runs.response.title.starting") : t("integration.runs.response.title.running");
49
+ }
50
+ if (refused.value) return t("integration.runs.response.title.refused");
51
+ if (unconfirmed.value) return t("integration.runs.response.title.unconfirmed");
52
+ return props.mode === "async" ? t("integration.runs.response.title.async") : t("integration.runs.response.title.sync");
53
+ });
54
+ const withoutRun = computed(() => refused.value || unconfirmed.value);
43
55
  function pretty(value) {
44
56
  return typeof value === "string" ? value : JSON.stringify(value, null, 2);
45
57
  }
@@ -55,8 +67,12 @@ function pretty(value) {
55
67
  of the screen at once and took the buttons below it out of reach (PO-402). -->
56
68
  <DialogContent class="max-w-[94vw] overflow-hidden sm:max-w-4xl">
57
69
  <DialogHeader>
58
- <DialogTitle>{{ mode === "async" ? t("integration.runs.response.title.async") : t("integration.runs.response.title.sync") }}</DialogTitle>
59
- <DialogDescription class="font-mono text-sm">
70
+ <DialogTitle>{{ title }}</DialogTitle>
71
+ <!-- No subtitle where no run is known to exist: "started in the background"
72
+ is exactly what may not have happened. The reason in the body is then
73
+ the dialog's description instead, so it is announced with the heading
74
+ without standing in the page twice. -->
75
+ <DialogDescription v-if="!withoutRun" class="text-sm">
60
76
  {{ mode === "async" ? t("integration.runs.response.description.async") : t("integration.runs.response.description.sync") }}
61
77
  </DialogDescription>
62
78
  </DialogHeader>
@@ -71,7 +87,14 @@ function pretty(value) {
71
87
  </div>
72
88
 
73
89
  <template v-else-if="error">
74
- <div class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700">
90
+ <DialogDescription
91
+ v-if="withoutRun"
92
+ as="div"
93
+ class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700"
94
+ >
95
+ {{ error }}
96
+ </DialogDescription>
97
+ <div v-else class="rounded-md border border-rose-500/20 bg-rose-500/5 px-3 py-2 text-rose-700">
75
98
  {{ error }}
76
99
  </div>
77
100
  <div v-if="runId" class="mt-3 flex justify-end">
@@ -38,6 +38,12 @@ type __VLS_Props = {
38
38
  error?: string | null;
39
39
  /** The run behind this result (sync failure / async start) for deep-linking. */
40
40
  runId?: string | null;
41
+ /**
42
+ * The platform answered the press with a refusal, so no run exists — as opposed
43
+ * to a run that started and then failed or outran the wait. Decided by the page,
44
+ * which has the status code; `error` alone cannot tell the two apart.
45
+ */
46
+ refused?: boolean;
41
47
  };
42
48
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
43
49
  "update:open": (args_0: boolean) => any;
@@ -30,7 +30,13 @@ type __VLS_Props = {
30
30
  /** The narrowing in force. Owned by the page, drawn here. */
31
31
  status?: RunStatus[];
32
32
  triggerType?: TriggerType | '';
33
- /** The order the platform is reading the list in. Server mode only. */
33
+ /**
34
+ * The owning list asks the platform for its order even though the table is not
35
+ * in server mode — the workflow's own Runs tab, which pages and orders on the
36
+ * server but keeps its own pager and search (PO-385). Server mode implies it.
37
+ */
38
+ orderable?: boolean;
39
+ /** The order the platform is reading the list in. Server mode or orderable only. */
34
40
  sort?: RunSort;
35
41
  direction?: RunSortDirection;
36
42
  };
@@ -74,5 +80,6 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
74
80
  pagination: boolean;
75
81
  showWorkflow: boolean;
76
82
  serverMode: boolean;
83
+ orderable: boolean;
77
84
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
78
85
  export default _default;
@@ -25,6 +25,7 @@ const props = defineProps({
25
25
  perPage: { type: Number, required: false, default: 25 },
26
26
  status: { type: Array, required: false },
27
27
  triggerType: { type: String, required: false, default: "" },
28
+ orderable: { type: Boolean, required: false, default: false },
28
29
  sort: { type: null, required: false, default: DEFAULT_RUN_SORT },
29
30
  direction: { type: String, required: false, default: DEFAULT_RUN_DIRECTION }
30
31
  });
@@ -91,22 +92,24 @@ const SERVER_SORTS = {
91
92
  started: "started_at",
92
93
  duration: "duration"
93
94
  };
95
+ const ordersOnServer = computed(() => props.serverMode || props.orderable);
94
96
  function sortableColumn(key) {
95
- return props.serverMode && key in SERVER_SORTS;
97
+ return ordersOnServer.value && key in SERVER_SORTS;
96
98
  }
97
99
  const sortedColumn = computed(() => {
98
100
  const entry = Object.entries(SERVER_SORTS).find(([, value]) => value === props.sort);
99
101
  return entry ? entry[0] : null;
100
102
  });
101
- const serverModels = computed(() => props.serverMode ? {
102
- sort: tableSort.value,
103
- "onUpdate:sort": onTableSort,
104
- // 1-based here, as the platform pages; the shared table counts from zero.
105
- page: page.value - 1,
106
- "onUpdate:page": (p) => {
107
- page.value = p + 1;
108
- }
109
- } : {});
103
+ const serverModels = computed(() => ({
104
+ ...ordersOnServer.value ? { sort: tableSort.value, "onUpdate:sort": onTableSort } : {},
105
+ ...props.serverMode ? {
106
+ // 1-based here, as the platform pages; the shared table counts from zero.
107
+ page: page.value - 1,
108
+ "onUpdate:page": (p) => {
109
+ page.value = p + 1;
110
+ }
111
+ } : {}
112
+ }));
110
113
  const columns = computed(() => [
111
114
  // Sorted and searched by the word on screen, not by the raw field: a column
112
115
  // with no `searchGetter` is searched through its `sortGetter`, so a German
@@ -125,7 +128,7 @@ const columns = computed(() => [
125
128
  // what a search has to match.
126
129
  { key: "trigger", label: t("integration.runs.table.column.trigger"), width: "120px", sortable: sortableColumn("trigger"), sortGetter: (r) => triggerTypeLabel(r.triggerType) },
127
130
  { key: "started", label: t("integration.runs.table.column.started"), width: "120px", sortable: sortableColumn("started"), sortGetter: (r) => r.startedAt },
128
- { key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: sortableColumn("duration"), sortGetter: (r) => r.durationMs ?? -1 },
131
+ { key: "duration", label: t("integration.runs.table.column.duration"), width: "100px", sortable: sortableColumn("duration"), sortGetter: (r) => r.durationMs ?? null },
129
132
  { key: "actions", label: "", width: "52px" }
130
133
  ]);
131
134
  </script>
@@ -30,7 +30,13 @@ type __VLS_Props = {
30
30
  /** The narrowing in force. Owned by the page, drawn here. */
31
31
  status?: RunStatus[];
32
32
  triggerType?: TriggerType | '';
33
- /** The order the platform is reading the list in. Server mode only. */
33
+ /**
34
+ * The owning list asks the platform for its order even though the table is not
35
+ * in server mode — the workflow's own Runs tab, which pages and orders on the
36
+ * server but keeps its own pager and search (PO-385). Server mode implies it.
37
+ */
38
+ orderable?: boolean;
39
+ /** The order the platform is reading the list in. Server mode or orderable only. */
34
40
  sort?: RunSort;
35
41
  direction?: RunSortDirection;
36
42
  };
@@ -74,5 +80,6 @@ declare const _default: import("vue").DefineComponent<__VLS_PublicProps, {}, {},
74
80
  pagination: boolean;
75
81
  showWorkflow: boolean;
76
82
  serverMode: boolean;
83
+ orderable: boolean;
77
84
  }, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
78
85
  export default _default;
@@ -50,7 +50,7 @@ import WorkflowEdgeView from "./WorkflowEdgeView.vue";
50
50
  import { layoutWorkflowNodes } from "./workflowLayout";
51
51
  import { defaultsFor } from "../../utils/configDefaults";
52
52
  import { TRIGGER_TEMPLATES, findManifest } from "../../data/integrationNodeCatalog";
53
- import { isTerminalManifest, portsForNode } from "../../data/nodeInputData";
53
+ import { collectUpstreamNodes, isTerminalManifest, portsForNode } from "../../data/nodeInputData";
54
54
  import { computeLoopBodies, iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
55
55
  import { inputHandleId, outputHandleId, portFromHandleId } from "../../data/handleId";
56
56
  import { OPPOSITE_SIDE } from "./canvasSides";
@@ -868,17 +868,34 @@ const selectedUpstreamOutputs = computed(() => {
868
868
  if (!id) return [];
869
869
  const isNode = blob.value.nodes.some((n) => n.id === id);
870
870
  if (!isNode) return [];
871
- return blob.value.edges.filter((e) => e.to.nodeId === id).flatMap((e) => {
872
- const source = blob.value.nodes.find((n) => n.id === e.from.nodeId);
873
- if (!source) return [];
874
- const port = portsForNode(source, findManifest(source.nodeSlug, source.nodeVersion), dynamicOutputs.value[source.id]).find((p) => p.name === e.from.output);
875
- return [{
871
+ const entries = [];
872
+ const seen = /* @__PURE__ */ new Set();
873
+ const add = (source, output) => {
874
+ if (!source || seen.has(source.id + "\0" + output)) return;
875
+ seen.add(source.id + "\0" + output);
876
+ entries.push({ source, output });
877
+ };
878
+ for (const e of blob.value.edges.filter((e2) => e2.to.nodeId === id)) add(blob.value.nodes.find((n) => n.id === e.from.nodeId), e.from.output);
879
+ for (const { node } of collectUpstreamNodes(id, blob.value.nodes, blob.value.edges)) {
880
+ for (const e of blob.value.edges.filter((e2) => e2.from.nodeId === node.id)) add(node, e.from.output);
881
+ }
882
+ return entries.map(({ source, output }) => {
883
+ const port = portsForNode(source, findManifest(source.nodeSlug, source.nodeVersion), dynamicOutputs.value[source.id]).find((p) => p.name === output);
884
+ return {
876
885
  nodeId: source.id,
877
886
  // Called what its card calls it, not by its slug (PO-601).
878
887
  nodeName: nodeDisplayName(source),
879
- output: e.from.output,
880
- fields: port?.fields.map((f) => f.name)
881
- }];
888
+ output,
889
+ fields: port?.fields.map((f) => f.name),
890
+ // What each one is, so the `{ }` list can say "list" and point a setting
891
+ // that wants one at it (PO-594). The names alone made five entries alike.
892
+ dataType: port?.dataType ?? null,
893
+ fieldTypes: Object.fromEntries((port?.fields ?? []).map((f) => [f.name, f.dataType])),
894
+ // What the port and its fields are called, so the list reads "Rechnungen"
895
+ // where the key is `invoices` (PO-594). Absent where the manifest names none.
896
+ portLabel: resolveLocalized(port?.label) || null,
897
+ fieldLabels: Object.fromEntries((port?.fields ?? []).map((f) => [f.name, resolveLocalized(f.label) || null]))
898
+ };
882
899
  });
883
900
  });
884
901
  function applyInspectorUpdate(next) {
@@ -25,8 +25,9 @@ export function useIntegrationsNodes() {
25
25
  slug: n.manifest.slug ?? n.slug,
26
26
  version: n.manifest.version ?? n.version,
27
27
  name: n.manifest.name ?? n.name,
28
- // Keep the source package — the palette groups app nodes by it.
29
- __package: n.package ? { name: n.package.name, label: n.package.label ?? null } : void 0,
28
+ // Keep the source package — the palette groups app nodes by it, and
29
+ // marks a package's folder with the icon it declares (PO-640).
30
+ __package: n.package ? { name: n.package.name, label: n.package.label ?? null, icon: n.package.icon ?? null } : void 0,
30
31
  // Registered and built are two different things: a version whose code
31
32
  // was never built can be drawn but not executed, so the palette must
32
33
  // be able to leave it out (PO-338). Missing field = assume built.
@@ -1,5 +1,5 @@
1
1
  import type { RunStatus, RunStepRow, WorkflowRun } from '../types/integrations.js';
2
- import type { RunFeedQuery } from '../utils/runFeedQuery.js';
2
+ import type { RunFeedQuery, RunSort, RunSortDirection } from '../utils/runFeedQuery.js';
3
3
  export type { RunFeedQuery };
4
4
  interface RunSummary {
5
5
  total: number;
@@ -25,6 +25,8 @@ export declare function useIntegrationsRuns(): {
25
25
  reconcile?: boolean;
26
26
  page?: number;
27
27
  perPage?: number;
28
+ sort?: RunSort;
29
+ direction?: RunSortDirection;
28
30
  }) => Promise<{
29
31
  runs: WorkflowRun[];
30
32
  total: number;
@@ -11,7 +11,8 @@ import {
11
11
  mapRunStatus,
12
12
  runFeedNarrowingString,
13
13
  runFeedQueryString,
14
- withRunFeedPatch
14
+ withRunFeedPatch,
15
+ workflowRunsQueryString
15
16
  } from "../utils/runFeedQuery.js";
16
17
  const EMPTY_SUMMARY = { total: 0, in_flight: 0, by_status: {} };
17
18
  export function useIntegrationsRuns() {
@@ -120,9 +121,9 @@ export function useIntegrationsRuns() {
120
121
  const page = opts.page ?? 1;
121
122
  const perPage = opts.perPage ?? 25;
122
123
  const res = await api.get(
123
- `/workflows/${workflowId}/runs?page=${page}&per_page=${perPage}`
124
+ `/workflows/${workflowId}/runs?${workflowRunsQueryString(page, perPage, opts.sort, opts.direction)}`
124
125
  );
125
- let list2 = (res?.data ?? []).map((r) => mapRun(r, workflowName)).sort((a, b) => new Date(b.startedAt).getTime() - new Date(a.startedAt).getTime());
126
+ let list2 = (res?.data ?? []).map((r) => mapRun(r, workflowName));
126
127
  if (opts.reconcile) {
127
128
  const active = list2.filter((r) => r.status === "running" || r.status === "queued").sort((a, b) => Number(b.id) - Number(a.id)).slice(0, SYNC_LIMIT);
128
129
  await Promise.allSettled(active.map(async (r) => {
@@ -59,7 +59,7 @@ export const TRIGGER_TEMPLATES = [
59
59
  name: { en: "Manual", de: "Manuell" },
60
60
  description: {
61
61
  en: "Start the workflow yourself with the Run button or an API call.",
62
- de: "Starte den Workflow selbst \xFCber den Run-Button oder einen API-Aufruf."
62
+ de: "Starten Sie den Workflow selbst \xFCber den Run-Button oder einen API-Aufruf."
63
63
  },
64
64
  defaultConfig: {}
65
65
  },
@@ -69,7 +69,7 @@ export const TRIGGER_TEMPLATES = [
69
69
  name: { en: "Schedule", de: "Zeitplan" },
70
70
  description: {
71
71
  en: "Runs automatically on a schedule you define, for example every night.",
72
- de: "L\xE4uft automatisch nach deinem Zeitplan, zum Beispiel jede Nacht."
72
+ de: "L\xE4uft automatisch nach Ihrem Zeitplan, zum Beispiel jede Nacht."
73
73
  },
74
74
  defaultConfig: { cron: "0 * * * *", timezone: "Europe/Berlin" }
75
75
  },
@@ -15,5 +15,11 @@
15
15
  * unquoted key the day the linter unquoted them.
16
16
  */
17
17
  export declare const MDI_TO_LUCIDE: Record<string, string>;
18
+ /**
19
+ * Resolve a manifest icon name to a Lucide component, or `null` when nothing
20
+ * by that name exists — for a caller that must tell an unknown name from a
21
+ * declared `lucide:box` (`declaredFolderIcon`).
22
+ */
23
+ export declare function resolveNodeIcon(name?: string | null): unknown;
18
24
  /** Resolve a manifest icon name to a Lucide component (Box as last resort). */
19
- export declare function nodeIcon(name?: string | null): unknown;
25
+ export declare function nodeIcon(name?: string | null): {};
@@ -108,13 +108,16 @@ export const MDI_TO_LUCIDE = {
108
108
  whatsapp: "MessageCircle",
109
109
  xml: "CodeXml"
110
110
  };
111
- export function nodeIcon(name) {
112
- if (!name) return Lucide.Box;
111
+ export function resolveNodeIcon(name) {
112
+ if (!name) return null;
113
113
  const bare = name.includes(":") ? name.split(":").pop() : name;
114
114
  const mapped = name.startsWith("lucide:") ? void 0 : MDI_TO_LUCIDE[bare];
115
115
  if (mapped && Lucide[mapped]) {
116
116
  return Lucide[mapped];
117
117
  }
118
118
  const pascal = bare.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
119
- return Lucide[pascal] || Lucide.Box;
119
+ return Lucide[pascal] || null;
120
+ }
121
+ export function nodeIcon(name) {
122
+ return resolveNodeIcon(name) || Lucide.Box;
120
123
  }
@@ -26,12 +26,18 @@ export interface FieldEntry {
26
26
  * on {@link PortEntry.description}.
27
27
  */
28
28
  description?: LocalizedString;
29
+ /** What the field is called, unresolved, where the manifest names it (PO-594). */
30
+ label?: LocalizedString;
29
31
  }
30
32
  export interface PortEntry {
31
33
  name: string;
32
34
  kind: OutputKind;
33
35
  /** Declared payload fields of this port (schema; may be empty). */
34
36
  fields: FieldEntry[];
37
+ /** The port's own declared data type; absent where the manifest gives none. */
38
+ dataType?: string;
39
+ /** What the port is called, unresolved, where the manifest names it (PO-594). */
40
+ label?: LocalizedString;
35
41
  /**
36
42
  * What the publisher says flows through this port, **unresolved** — the
37
43
  * localised map as the manifest sent it, not a string.
@@ -42,19 +42,28 @@ function fieldEntries(raw) {
42
42
  return {
43
43
  name,
44
44
  dataType: typeof s?.dataType === "string" ? s.dataType : null,
45
- ...describedBy(s?.description)
45
+ ...describedBy(s?.description),
46
+ ...labelledBy(s?.label)
46
47
  };
47
48
  });
48
49
  }
49
50
  return [];
50
51
  }
51
52
  function describedBy(raw) {
52
- if (typeof raw === "string") return raw.trim() ? { description: raw.trim() } : {};
53
+ const v = cleanLocalized(raw);
54
+ return v === void 0 ? {} : { description: v };
55
+ }
56
+ function labelledBy(raw) {
57
+ const v = cleanLocalized(raw);
58
+ return v === void 0 ? {} : { label: v };
59
+ }
60
+ function cleanLocalized(raw) {
61
+ if (typeof raw === "string") return raw.trim() ? raw.trim() : void 0;
53
62
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
54
63
  const entries = Object.entries(raw).filter((e) => typeof e[1] === "string" && e[1].trim() !== "").map(([k, v]) => [k, v.trim()]);
55
- return entries.length ? { description: Object.fromEntries(entries) } : {};
64
+ return entries.length ? Object.fromEntries(entries) : void 0;
56
65
  }
57
- return {};
66
+ return void 0;
58
67
  }
59
68
  export function inputPortsForManifest(manifest) {
60
69
  const raw = manifest?.inputs;
@@ -78,7 +87,7 @@ export function portsForNode(node, manifest, resolvedOutputs) {
78
87
  const ports = [];
79
88
  for (const o of list) {
80
89
  if (o.name) {
81
- ports.push({ name: o.name, kind: o.kind, fields: fieldEntries(o.fields), ...describedBy(o.description) });
90
+ ports.push({ name: o.name, kind: o.kind, fields: fieldEntries(o.fields), ...typeof o.dataType === "string" ? { dataType: o.dataType } : {}, ...labelledBy(o.label), ...describedBy(o.description) });
82
91
  } else if (o.sourceFromConfig) {
83
92
  const raw = node.config?.[o.sourceFromConfig];
84
93
  const values = Array.isArray(raw) ? raw : [];
@@ -467,6 +467,7 @@
467
467
  "integration.editor.splice.error.terminal": "{name} beendet den Run und kann nicht in eine Verbindung eingefügt werden.",
468
468
  "integration.editor.splice.toast": "Node aus der Palette auswählen — er wird in die Verbindung eingefügt.",
469
469
  "integration.editor.state.loading": "Wird geladen…",
470
+ "integration.editor.status.saved": "Gespeichert",
470
471
  "integration.editor.status.unsavedChanges": "Nicht gespeicherte Änderungen",
471
472
  "integration.editor.sticky.starterContent": "## Ich bin eine Notiz\n**Doppelklicken**, um mich zu bearbeiten. **Fett**, *kursiv*, `Code`, [Links](https://revenexx.com) und\n- Listen funktionieren",
472
473
  "integration.editor.tab.deadLetters": "Verpasste Runs",
@@ -563,8 +564,14 @@
563
564
  "integration.nodeConfig.dynamicSchema.reloading": "Felder werden neu geladen…",
564
565
  "integration.nodeConfig.dynamicSchema.resolving": "Felder werden geladen…",
565
566
  "integration.nodeConfig.expression.badge.title": "Akzeptiert ${{ … }}-Ausdrücke",
567
+ "integration.nodeConfig.expression.fits": "passt",
566
568
  "integration.nodeConfig.expression.insertAria": "Ausdruck einfügen — verfügbare Daten für diese Einstellung",
567
569
  "integration.nodeConfig.expression.insertTitle": "Ausdruck einfügen",
570
+ "integration.nodeConfig.expression.kind.array": "Liste",
571
+ "integration.nodeConfig.expression.kind.boolean": "Ja/Nein",
572
+ "integration.nodeConfig.expression.kind.number": "Zahl",
573
+ "integration.nodeConfig.expression.kind.object": "Objekt",
574
+ "integration.nodeConfig.expression.kind.string": "Text",
568
575
  "integration.nodeConfig.expression.picker.aria": "Verfügbare Daten für diese Einstellung",
569
576
  "integration.nodeConfig.expression.picker.empty": "Nichts entspricht „{query}“.",
570
577
  "integration.nodeConfig.expression.picker.filterAria": "Verfügbare Daten filtern",
@@ -581,6 +588,7 @@
581
588
  "integration.nodeConfig.expression.suggestion.inputPayload": "Eingabe-Payload",
582
589
  "integration.nodeConfig.expression.toggle.off": "Zurück zum normalen Feld (löscht den Ausdruck)",
583
590
  "integration.nodeConfig.expression.toggle.on": "Diesen Wert stattdessen aus einem vorherigen Schritt zuordnen",
591
+ "integration.nodeConfig.expression.useFit": "Übernehmen:",
584
592
  "integration.nodeConfig.field.requiredMark": "Erforderlich",
585
593
  "integration.nodeConfig.hint.charMax": "bis zu {max} Zeichen",
586
594
  "integration.nodeConfig.hint.charMin": "mindestens {min} Zeichen",
@@ -625,6 +633,7 @@
625
633
  "integration.nodeConfig.queryBuilder.row.valueAria": "Filter {n} — Wert",
626
634
  "integration.nodeConfig.queryBuilder.row.valueListPlaceholder": "Wert, Wert, …",
627
635
  "integration.nodeConfig.queryBuilder.row.valuePlaceholder": "Wert",
636
+ "integration.nodeConfig.required.missing": "{label} fehlt noch.",
628
637
  "integration.nodeConfig.resolve.action.retry": "RETRY",
629
638
  "integration.nodeConfig.resolve.error.building": "Node-Bundle wird noch erstellt.",
630
639
  "integration.nodeConfig.resolve.error.generic": "Auflösung fehlgeschlagen",
@@ -973,7 +982,11 @@
973
982
  "integration.runs.response.nothingReturned": "Der Run hat nichts zurückgegeben.",
974
983
  "integration.runs.response.runNumber": "Run #{id}",
975
984
  "integration.runs.response.title.async": "Run gestartet",
985
+ "integration.runs.response.title.refused": "Run nicht gestartet",
986
+ "integration.runs.response.title.running": "Run läuft",
987
+ "integration.runs.response.title.starting": "Run wird gestartet",
976
988
  "integration.runs.response.title.sync": "Run-Antwort",
989
+ "integration.runs.response.title.unconfirmed": "Start nicht bestätigt",
977
990
  "integration.runs.status.cancelled": "abgebrochen",
978
991
  "integration.runs.status.failed": "fehlgeschlagen",
979
992
  "integration.runs.status.queued": "wartet",
@@ -467,6 +467,7 @@
467
467
  "integration.editor.splice.error.terminal": "{name} ends the run and cannot be inserted into a connection.",
468
468
  "integration.editor.splice.toast": "Pick a node from the palette — it will be inserted into the connection.",
469
469
  "integration.editor.state.loading": "Loading…",
470
+ "integration.editor.status.saved": "Saved",
470
471
  "integration.editor.status.unsavedChanges": "Unsaved changes",
471
472
  "integration.editor.sticky.starterContent": "## I'm a note\n**Double-click** to edit me. **Bold**, *italic*, `code`, [links](https://revenexx.com) and\n- lists work",
472
473
  "integration.editor.tab.deadLetters": "Missed runs",
@@ -563,8 +564,14 @@
563
564
  "integration.nodeConfig.dynamicSchema.reloading": "reloading fields…",
564
565
  "integration.nodeConfig.dynamicSchema.resolving": "Resolving fields…",
565
566
  "integration.nodeConfig.expression.badge.title": "Accepts ${{ … }} expressions",
567
+ "integration.nodeConfig.expression.fits": "fits",
566
568
  "integration.nodeConfig.expression.insertAria": "Insert expression — available data for this setting",
567
569
  "integration.nodeConfig.expression.insertTitle": "Insert expression",
570
+ "integration.nodeConfig.expression.kind.array": "List",
571
+ "integration.nodeConfig.expression.kind.boolean": "Yes/no",
572
+ "integration.nodeConfig.expression.kind.number": "Number",
573
+ "integration.nodeConfig.expression.kind.object": "Object",
574
+ "integration.nodeConfig.expression.kind.string": "Text",
568
575
  "integration.nodeConfig.expression.picker.aria": "Available data for this setting",
569
576
  "integration.nodeConfig.expression.picker.empty": "Nothing matches “{query}”.",
570
577
  "integration.nodeConfig.expression.picker.filterAria": "Filter available data",
@@ -581,6 +588,7 @@
581
588
  "integration.nodeConfig.expression.suggestion.inputPayload": "input payload",
582
589
  "integration.nodeConfig.expression.toggle.off": "Back to the regular control (clears the expression)",
583
590
  "integration.nodeConfig.expression.toggle.on": "Map this value from a previous step instead",
591
+ "integration.nodeConfig.expression.useFit": "Use:",
584
592
  "integration.nodeConfig.field.requiredMark": "Required",
585
593
  "integration.nodeConfig.hint.charMax": "up to {max} characters",
586
594
  "integration.nodeConfig.hint.charMin": "at least {min} characters",
@@ -625,6 +633,7 @@
625
633
  "integration.nodeConfig.queryBuilder.row.valueAria": "Filter {n} — value",
626
634
  "integration.nodeConfig.queryBuilder.row.valueListPlaceholder": "value, value, …",
627
635
  "integration.nodeConfig.queryBuilder.row.valuePlaceholder": "value",
636
+ "integration.nodeConfig.required.missing": "{label} is still missing.",
628
637
  "integration.nodeConfig.resolve.action.retry": "RETRY",
629
638
  "integration.nodeConfig.resolve.error.building": "Node bundle still building.",
630
639
  "integration.nodeConfig.resolve.error.generic": "Resolve failed",
@@ -973,7 +982,11 @@
973
982
  "integration.runs.response.nothingReturned": "The run returned nothing.",
974
983
  "integration.runs.response.runNumber": "Run #{id}",
975
984
  "integration.runs.response.title.async": "Run started",
985
+ "integration.runs.response.title.refused": "Run not started",
986
+ "integration.runs.response.title.running": "Run in progress",
987
+ "integration.runs.response.title.starting": "Starting run",
976
988
  "integration.runs.response.title.sync": "Run response",
989
+ "integration.runs.response.title.unconfirmed": "Start not confirmed",
977
990
  "integration.runs.status.cancelled": "cancelled",
978
991
  "integration.runs.status.failed": "failed",
979
992
  "integration.runs.status.queued": "queued",
@@ -1,6 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
3
- import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, RefreshCircle as Loader2, MenuDots as MoreHorizontal, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
3
+ import { AltArrowLeft as ArrowLeft, AltArrowRight as Chevron, CheckCircle, RefreshCircle as Loader2, MenuDots as MoreHorizontal, Play, Refresh as RetryIcon, Diskette as Save, SidebarMinimalistic as RailIcon } from "#solar-icons";
4
4
  import {
5
5
  AlertDialog,
6
6
  AlertDialogCancel,
@@ -45,6 +45,7 @@ import { isTerminalRun, unwrapRun } from "../../../utils/runEnvelope";
45
45
  import { findingsSummary, groupFindingsByNode, nodeDisplayLabel } from "../../../utils/validationFindings";
46
46
  import { payloadFieldsFromBlob } from "../../../data/payloadForm";
47
47
  import WorkflowIconView from "../../../components/Integrations/WorkflowIconView.vue";
48
+ import { DEFAULT_RUN_DIRECTION, DEFAULT_RUN_SORT } from "../../../utils/runFeedQuery";
48
49
  const t = useIntegrationsT();
49
50
  const route = useRoute();
50
51
  const router = useRouter();
@@ -120,6 +121,27 @@ const reauthCredentials = computed(() => {
120
121
  return needsReauth.value.filter((c) => blob.includes(c.id));
121
122
  });
122
123
  const buildWorking = computed(() => workflow.value?.buildStatus === "building" || workflow.value?.buildStatus === "pending");
124
+ const BUILD_CHIP_DELAY_MS = 3e3;
125
+ const buildSlow = ref(false);
126
+ let buildSlowTimer;
127
+ watch(buildWorking, (working) => {
128
+ clearTimeout(buildSlowTimer);
129
+ buildSlow.value = false;
130
+ if (working) buildSlowTimer = setTimeout(() => {
131
+ buildSlow.value = true;
132
+ }, BUILD_CHIP_DELAY_MS);
133
+ }, { immediate: true });
134
+ const buildChipShown = computed(() => buildWorking.value && buildSlow.value);
135
+ const SAVED_SHOWN_MS = 4e3;
136
+ const justSaved = ref(false);
137
+ let justSavedTimer;
138
+ function announceSaved() {
139
+ clearTimeout(justSavedTimer);
140
+ justSaved.value = true;
141
+ justSavedTimer = setTimeout(() => {
142
+ justSaved.value = false;
143
+ }, SAVED_SHOWN_MS);
144
+ }
123
145
  const buildFailed = computed(() => workflow.value?.buildStatus === "failed");
124
146
  const waitingForBuild = ref(false);
125
147
  const BUILD_WAIT_POLL_MS = BUILD_SETTLE_POLL_MS;
@@ -153,7 +175,11 @@ function settleBuildStatus() {
153
175
  read: () => get(workflow.value.id)
154
176
  });
155
177
  }
156
- onUnmounted(() => buildSettle.stop());
178
+ onUnmounted(() => {
179
+ buildSettle.stop();
180
+ clearTimeout(buildSlowTimer);
181
+ clearTimeout(justSavedTimer);
182
+ });
157
183
  const buildNotice = computed(() => {
158
184
  switch (workflow.value?.buildStatus) {
159
185
  case "building":
@@ -295,6 +321,7 @@ const syncError = ref(null);
295
321
  const runObject = ref(null);
296
322
  const followingRun = ref(false);
297
323
  const runModalMode = ref("sync");
324
+ const runRefused = ref(false);
298
325
  const runModalRunId = ref(null);
299
326
  function extractRunId(res) {
300
327
  if (res && typeof res === "object" && "id" in res) {
@@ -314,6 +341,11 @@ function viewRunFromModal(id2) {
314
341
  syncOpen.value = false;
315
342
  void router.push({ query: { ...route.query, run: id2 } });
316
343
  }
344
+ function isRefusal(e) {
345
+ if (!(e instanceof IntegrationsApiError)) return false;
346
+ if (e.status < 400 || e.status >= 500 || e.status === 408) return false;
347
+ return extractRunId(e.body?.run) === null;
348
+ }
317
349
  function syncErrorMessage(e) {
318
350
  if (e instanceof IntegrationsApiError) {
319
351
  if (e.status === 504) return t("integration.editor.run.error.syncTimeout");
@@ -336,6 +368,7 @@ function refusedForSettings(e) {
336
368
  return /[.!?]$/.test(line) ? line : `${line}.`;
337
369
  }
338
370
  async function reportRunFailure(e, fallback) {
371
+ runRefused.value = isRefusal(e);
339
372
  const settings = refusedForSettings(e);
340
373
  if (!settings) {
341
374
  syncError.value = fallback(e);
@@ -368,6 +401,7 @@ async function run(mode) {
368
401
  syncLoading.value = true;
369
402
  syncResponse.value = null;
370
403
  syncError.value = null;
404
+ runRefused.value = false;
371
405
  if (mode === "sync") {
372
406
  try {
373
407
  syncResponse.value = await triggerRun(workflow.value.id, payload, "sync");
@@ -480,6 +514,8 @@ const runsPage = ref(1);
480
514
  const runsTotal = ref(0);
481
515
  const runsLastPage = ref(1);
482
516
  const runsWanted = ref(1);
517
+ const runsSort = ref(DEFAULT_RUN_SORT);
518
+ const runsDirection = ref(DEFAULT_RUN_DIRECTION);
483
519
  let runsSeq = 0;
484
520
  let runsLoadingSeq = 0;
485
521
  const RUNS_POLL_MS = 3e3;
@@ -580,9 +616,11 @@ async function loadRuns(page = runsWanted.value, opts = {}) {
580
616
  runsError.value = null;
581
617
  try {
582
618
  const res = await loadForWorkflow(workflow.value.id, workflow.value.name, {
583
- reconcile: page === 1,
619
+ reconcile: true,
584
620
  page,
585
- perPage: RUNS_PER_PAGE
621
+ perPage: RUNS_PER_PAGE,
622
+ sort: runsSort.value,
623
+ direction: runsDirection.value
586
624
  });
587
625
  if (seq !== runsSeq) return;
588
626
  wfRuns.value = res.runs;
@@ -598,6 +636,11 @@ async function loadRuns(page = runsWanted.value, opts = {}) {
598
636
  if (!opts.silent && seq === runsLoadingSeq) runsLoading.value = false;
599
637
  }
600
638
  }
639
+ function reorderRuns(order) {
640
+ runsSort.value = order.sort;
641
+ runsDirection.value = order.direction;
642
+ void loadRuns(1);
643
+ }
601
644
  function goRunsPage(p) {
602
645
  const next = Math.min(Math.max(1, p), runsLastPage.value);
603
646
  if (next !== runsWanted.value) void loadRuns(next);
@@ -747,6 +790,7 @@ async function save() {
747
790
  executionMode: d.executionMode
748
791
  });
749
792
  draft.value = null;
793
+ announceSaved();
750
794
  settleBuildStatus();
751
795
  } catch (e) {
752
796
  const issues = apiValidationIssues(e);
@@ -880,7 +924,7 @@ async function confirmDeactivate() {
880
924
  </template>
881
925
  <template v-else>
882
926
  <span
883
- v-if="buildWorking"
927
+ v-if="buildChipShown"
884
928
  class="inline-flex h-7 shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-sky-500/25 bg-sky-500/10 px-2.5 text-sm text-sky-700"
885
929
  :title="buildNotice?.text"
886
930
  >
@@ -888,40 +932,15 @@ async function confirmDeactivate() {
888
932
  {{ workflow.buildStatus === "building" ? t("integration.editor.build.chip.building") : t("integration.editor.build.chip.queued") }}
889
933
  </span>
890
934
  <span v-if="isDirty" class="shrink-0 whitespace-nowrap text-sm text-amber-600">{{ t("integration.editor.status.unsavedChanges") }}</span>
891
- <!-- What is done to the WORKFLOW, as opposed to the draft in front
892
- of it: the buttons beside this menu discard and save an
893
- afternoon's work, and these two make and unmake the thing that
894
- work belongs to. Two reasons they are a menu rather than two more
895
- buttons in a row of five. Deleting cannot be undone and has no
896
- business a mis-click away from Save; and a lone Delete under a
897
- Duplicate that stayed a button reads as an oversight rather than
898
- as a grouping — the menu only holds together while it holds both.
899
- Icon-only, so it carries a name of its own. -->
900
- <DropdownMenu>
901
- <DropdownMenuTrigger as-child>
902
- <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
903
- <MoreHorizontal :size="12" />
904
- </Button>
905
- </DropdownMenuTrigger>
906
- <DropdownMenuContent align="end">
907
- <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
908
- <DropdownMenuSeparator />
909
- <DropdownMenuItem @select="duplicateOpen = true">
910
- {{ t("integration.editor.action.duplicate.label") }}
911
- </DropdownMenuItem>
912
- <DropdownMenuItem @select="exporter?.start(workflow.id)">
913
- {{ t("integration.workflowFile.action.export") }}
914
- </DropdownMenuItem>
915
- <DropdownMenuItem @select="importInput?.click()">
916
- {{ t("integration.workflowFile.action.import") }}
917
- </DropdownMenuItem>
918
- <DropdownMenuSeparator />
919
- <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
920
- {{ t("integration.common.action.delete") }}
921
- </DropdownMenuItem>
922
- </DropdownMenuContent>
923
- </DropdownMenu>
924
- <Button variant="outline" class="h-7 text-sm" :disabled="!isDirty || saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
935
+ <span v-else-if="justSaved" role="status" class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-sm text-emerald-700">
936
+ <CheckCircle :size="12" />
937
+ {{ t("integration.editor.status.saved") }}
938
+ </span>
939
+ <!-- Only while there is something to discard (PO-597). Save stays
940
+ put when disabled, so the row keeps its anchor; a Discard that can
941
+ never do anything was only noise beside it. The row is right-aligned,
942
+ so appearing here moves nothing the operator is about to press. -->
943
+ <Button v-if="isDirty" variant="outline" class="h-7 text-sm" :disabled="saving" :title="t('integration.editor.action.discard.title')" @click="discardChanges">
925
944
  {{ t("integration.editor.action.discard.label") }}
926
945
  </Button>
927
946
  <Button class="h-7 text-sm" :disabled="!isDirty || saving" @click="save">
@@ -975,6 +994,42 @@ async function confirmDeactivate() {
975
994
  {{ t("integration.editor.action.activate.title.invalid", validationWarnings?.length ?? 0) }}
976
995
  </span>
977
996
  </div>
997
+ <!-- What is done to the WORKFLOW, as opposed to the draft in front
998
+ of it: the buttons beside this menu discard and save an
999
+ afternoon's work, and these two make and unmake the thing that
1000
+ work belongs to. Two reasons they are a menu rather than two more
1001
+ buttons in a row of five. Deleting cannot be undone and has no
1002
+ business a mis-click away from Save; and a lone Delete under a
1003
+ Duplicate that stayed a button reads as an oversight rather than
1004
+ as a grouping — the menu only holds together while it holds both.
1005
+ Icon-only, so it carries a name of its own. Last in the row, where
1006
+ a menu of further actions is looked for: between the status text
1007
+ and Discard it split the row's two halves and read as a stray
1008
+ button (PO-597). -->
1009
+ <DropdownMenu>
1010
+ <DropdownMenuTrigger as-child>
1011
+ <Button variant="outline" size="icon" class="size-7" :disabled="saving" :aria-label="t('integration.editor.action.more.aria')">
1012
+ <MoreHorizontal :size="12" />
1013
+ </Button>
1014
+ </DropdownMenuTrigger>
1015
+ <DropdownMenuContent align="end">
1016
+ <DropdownMenuLabel>{{ t("integration.editor.action.more.label") }}</DropdownMenuLabel>
1017
+ <DropdownMenuSeparator />
1018
+ <DropdownMenuItem @select="duplicateOpen = true">
1019
+ {{ t("integration.editor.action.duplicate.label") }}
1020
+ </DropdownMenuItem>
1021
+ <DropdownMenuItem @select="exporter?.start(workflow.id)">
1022
+ {{ t("integration.workflowFile.action.export") }}
1023
+ </DropdownMenuItem>
1024
+ <DropdownMenuItem @select="importInput?.click()">
1025
+ {{ t("integration.workflowFile.action.import") }}
1026
+ </DropdownMenuItem>
1027
+ <DropdownMenuSeparator />
1028
+ <DropdownMenuItem class="text-destructive focus:text-destructive" @select="askDelete">
1029
+ {{ t("integration.common.action.delete") }}
1030
+ </DropdownMenuItem>
1031
+ </DropdownMenuContent>
1032
+ </DropdownMenu>
978
1033
  </template>
979
1034
  </template>
980
1035
 
@@ -1103,8 +1158,12 @@ async function confirmDeactivate() {
1103
1158
  :loading="runsLoading"
1104
1159
  :pagination="false"
1105
1160
  :show-workflow="false"
1161
+ orderable
1162
+ :sort="runsSort"
1163
+ :direction="runsDirection"
1106
1164
  :empty-text="t('integration.editor.runs.empty')"
1107
1165
  @changed="() => loadRuns()"
1166
+ @reorder="reorderRuns"
1108
1167
  />
1109
1168
 
1110
1169
  <!-- Server pagination — the endpoint pages, so the whole history is reachable. -->
@@ -1226,6 +1285,7 @@ async function confirmDeactivate() {
1226
1285
  :following="followingRun"
1227
1286
  :error="syncError"
1228
1287
  :run-id="runModalRunId"
1288
+ :refused="runRefused"
1229
1289
  @update:open="(v) => syncOpen = v"
1230
1290
  @view-run="viewRunFromModal"
1231
1291
  />
@@ -292,6 +292,10 @@ export interface ConfigField {
292
292
  default?: unknown;
293
293
  placeholder?: LocalizedString;
294
294
  expressionAllowed?: boolean;
295
+ /** What a value mapped into this setting has to be (`array` for a list from an
296
+ * earlier step). The `{ }` list puts the outputs of that kind first and an
297
+ * empty setting offers a single one in a click (PO-594). */
298
+ accepts?: string;
295
299
  /** The setting takes several lines — a mail body, a list of instructions one
296
300
  * per line — so it is drawn with room for them. Read for `string` and
297
301
  * `expression` fields; every other type ignores it (a dropdown has no
@@ -407,10 +411,15 @@ export interface NodeManifest {
407
411
  */
408
412
  iteration?: boolean;
409
413
  config?: ConfigField[];
410
- /** Source package (editor-side enrichment from GET /nodes; not manifest data). */
414
+ /**
415
+ * Source package (editor-side enrichment from GET /nodes; not manifest data).
416
+ * `icon` is the glyph the package declares for its palette folder
417
+ * (`revenexx.icon`, a `lucide:<name>`; PO-640), null when it declares none.
418
+ */
411
419
  __package?: {
412
420
  name: string;
413
421
  label: string | null;
422
+ icon?: string | null;
414
423
  };
415
424
  /**
416
425
  * Where this node stands in its package's own list of nodes, 0 first
@@ -0,0 +1,20 @@
1
+ import type { NodeManifest } from '../types/integrations.js';
2
+ /**
3
+ * The icon a palette folder directly under Integrations is marked by, when its
4
+ * package declares one (PO-640, `specs/node-marks.md` AC-4): `revenexx.icon` in
5
+ * the package's `package.json`, a `lucide:<name>`, served as `package.icon`.
6
+ *
7
+ * Declaring is optional. `null` means the folder keeps the mark it always had —
8
+ * the first logo among its nodes, else their first image (`groupLogoManifest`).
9
+ * So does a declaration the folder cannot use:
10
+ *
11
+ * - a folder several packages share counts only when every one of them declares
12
+ * the same icon — one that declares nothing, or another icon, leaves the folder
13
+ * its logo, so a package filing a node under a vendor's folder cannot take it over;
14
+ * - a name no Lucide glyph resolves — it would draw the generic `Box`, which says
15
+ * less than the logo it would replace.
16
+ *
17
+ * Within one package any node may carry the declaration: a node a newer release
18
+ * dropped is still served from the older release, whose package declared nothing.
19
+ */
20
+ export declare function declaredFolderIcon(manifests: NodeManifest[]): string | null;
@@ -0,0 +1,12 @@
1
+ import { resolveNodeIcon } from "../data/nodeIcons.js";
2
+ export function declaredFolderIcon(manifests) {
3
+ const byPackage = /* @__PURE__ */ new Map();
4
+ for (const m of manifests) {
5
+ const name = m.__package?.name ?? "";
6
+ byPackage.set(name, byPackage.get(name) || m.__package?.icon || null);
7
+ }
8
+ const declared = new Set(byPackage.values());
9
+ if (declared.size !== 1) return null;
10
+ const [icon] = declared;
11
+ return icon && resolveNodeIcon(icon) ? icon : null;
12
+ }
@@ -72,7 +72,8 @@ export declare function acceptsResume(apiStatus: string | null | undefined, stat
72
72
  */
73
73
  export declare function apiStatusesFor(words: readonly RunStatus[]): string[];
74
74
  /**
75
- * The orders this studio can ask `GET /runs` for.
75
+ * The orders this studio can ask `GET /runs` for — and a workflow's own
76
+ * `GET /workflows/{id}/runs`, which takes the same names (PO-385).
76
77
  *
77
78
  * Three of the four `RunSort` names the service accepts. Two absences are the
78
79
  * platform's: ordering by the workflow's name would need a join on the paging
@@ -121,6 +122,13 @@ export interface RunFeedQuery {
121
122
  * empty `status` as a value and answers 422.
122
123
  */
123
124
  export declare function runFeedQueryString(query: RunFeedQuery): string;
125
+ /**
126
+ * The query string for a workflow's own `GET /workflows/{id}/runs`: a page, and
127
+ * the order it is read in. That endpoint narrows by nothing yet (PO-319), so this
128
+ * is the paging and the order alone — the order sent by the same rule as the
129
+ * tenant feed's, so the two lists can never ask for one order two ways.
130
+ */
131
+ export declare function workflowRunsQueryString(page: number, perPage: number, sort?: RunSort, direction?: RunSortDirection): string;
124
132
  /**
125
133
  * The narrowing alone, without the paging — what `GET /runs/summary` is read
126
134
  * with so the pager's total describes the same rows as the page beside it.
@@ -26,12 +26,22 @@ export function runFeedQueryString(query) {
26
26
  params.set("page", String(query.page));
27
27
  params.set("per_page", String(query.perPage));
28
28
  for (const [key, value] of narrowingParams(query)) params.set(key, value);
29
- if (query.sort !== DEFAULT_RUN_SORT || query.direction !== DEFAULT_RUN_DIRECTION) {
30
- params.set("sort", query.sort);
31
- params.set("direction", query.direction);
32
- }
29
+ setOrder(params, query.sort, query.direction);
30
+ return params.toString();
31
+ }
32
+ export function workflowRunsQueryString(page, perPage, sort = DEFAULT_RUN_SORT, direction = DEFAULT_RUN_DIRECTION) {
33
+ const params = new URLSearchParams();
34
+ params.set("page", String(page));
35
+ params.set("per_page", String(perPage));
36
+ setOrder(params, sort, direction);
33
37
  return params.toString();
34
38
  }
39
+ function setOrder(params, sort, direction) {
40
+ if (sort !== DEFAULT_RUN_SORT || direction !== DEFAULT_RUN_DIRECTION) {
41
+ params.set("sort", sort);
42
+ params.set("direction", direction);
43
+ }
44
+ }
35
45
  export function runFeedNarrowingString(query) {
36
46
  return narrowingParams(query).toString();
37
47
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-integrations",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "type": "module",
5
5
  "description": "Integration Studio (core: workflows, node editor, runs, schedules, credentials, secrets, audit) as a standalone, publishable Nuxt module.",
6
6
  "repository": {