@revenexx/studio-integrations 0.7.0 → 1.1.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 (88) hide show
  1. package/CHANGELOG.md +633 -0
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +2 -1
  4. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +4 -1
  5. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +8 -2
  6. package/dist/runtime/components/Integrations/DeadLettersPanel.vue +2 -6
  7. package/dist/runtime/components/Integrations/ExpressionInput.vue +5 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +16 -3
  10. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  11. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +1 -0
  12. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue +2 -0
  14. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  15. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  16. package/dist/runtime/components/Integrations/NodeInspector.vue +135 -10
  17. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  18. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +34 -8
  20. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  21. package/dist/runtime/components/Integrations/NodePalette.vue +12 -8
  22. package/dist/runtime/components/Integrations/RunDetailPanel.vue +9 -6
  23. package/dist/runtime/components/Integrations/RunsTable.vue +2 -2
  24. package/dist/runtime/components/Integrations/StickyNoteView.vue +45 -11
  25. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  26. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  27. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  28. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -3
  29. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +12 -3
  30. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +5 -4
  31. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  32. package/dist/runtime/components/Integrations/WorkflowEditor.vue +79 -12
  33. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  34. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  35. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +13 -3
  36. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  37. package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +13 -0
  38. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +95 -29
  39. package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +13 -0
  40. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +11 -10
  41. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  42. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +37 -26
  43. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  44. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -5
  45. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +53 -14
  46. package/dist/runtime/components/Integrations/WorkflowsTable.vue +9 -4
  47. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  48. package/dist/runtime/composables/useIntegrationsCredentials.js +2 -2
  49. package/dist/runtime/composables/useIntegrationsDeadLetters.d.ts +9 -2
  50. package/dist/runtime/composables/useIntegrationsDeadLetters.js +27 -18
  51. package/dist/runtime/composables/useIntegrationsNodes.js +11 -1
  52. package/dist/runtime/composables/useIntegrationsRuns.d.ts +30 -1
  53. package/dist/runtime/composables/useIntegrationsRuns.js +70 -29
  54. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +7 -14
  55. package/dist/runtime/composables/useIntegrationsSchedules.js +80 -35
  56. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +30 -3
  57. package/dist/runtime/composables/useIntegrationsTriggers.js +18 -7
  58. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  59. package/dist/runtime/composables/useIntegrationsWorkflows.js +25 -4
  60. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  61. package/dist/runtime/data/integrationNodeCatalog.d.ts +22 -1
  62. package/dist/runtime/data/integrationNodeCatalog.js +8 -4
  63. package/dist/runtime/data/loopBody.d.ts +129 -0
  64. package/dist/runtime/data/loopBody.js +76 -0
  65. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  66. package/dist/runtime/data/nodeInputData.js +30 -5
  67. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  68. package/dist/runtime/data/triggerGlyphs.js +11 -0
  69. package/dist/runtime/i18n/messages/de.json +62 -33
  70. package/dist/runtime/i18n/messages/en.json +32 -3
  71. package/dist/runtime/pages/integrations/credentials.vue +4 -1
  72. package/dist/runtime/pages/integrations/index.vue +32 -34
  73. package/dist/runtime/pages/integrations/runs.vue +22 -10
  74. package/dist/runtime/pages/integrations/schedules.vue +64 -7
  75. package/dist/runtime/pages/integrations/templates/[slug].vue +13 -5
  76. package/dist/runtime/pages/integrations/workflows/[id].vue +193 -104
  77. package/dist/runtime/types/integrations.d.ts +64 -1
  78. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  79. package/dist/runtime/utils/credentialState.d.ts +6 -1
  80. package/dist/runtime/utils/credentialState.js +1 -1
  81. package/dist/runtime/utils/localize.d.ts +34 -0
  82. package/dist/runtime/utils/localize.js +22 -7
  83. package/dist/runtime/utils/pollBudget.d.ts +1 -1
  84. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  85. package/dist/runtime/utils/servedPaths.js +3 -0
  86. package/dist/runtime/utils/statusStyle.d.ts +52 -0
  87. package/dist/runtime/utils/statusStyle.js +26 -0
  88. package/package.json +1 -1
@@ -259,7 +259,10 @@ async function submit() {
259
259
  class="rounded-md px-3 py-2 text-sm"
260
260
  :class="testResult.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
261
261
  >
262
- {{ testResult.ok ? t("integration.credentials.shared.test.ok") : testResult.error ?? t("integration.credentials.shared.test.failed") }}
262
+ <!-- The type's own sentence, either way. This is the surface where it is
263
+ worth the most: nothing is stored yet, so the operator is deciding
264
+ whether these access data reach what they meant (specs/credential-test.md). -->
265
+ {{ testResult.ok ? testResult.message ?? t("integration.credentials.shared.test.ok") : testResult.error ?? testResult.message ?? t("integration.credentials.shared.test.failed") }}
263
266
  </div>
264
267
  </div>
265
268
 
@@ -214,7 +214,13 @@ async function connect() {
214
214
  {{ t("integration.credentials.detail.action.reconnect") }}
215
215
  </Button>
216
216
  </div>
217
- <p v-if="state.key === 'failed' && state.message" class="mt-1 text-sm break-words opacity-90">
217
+ <!-- The stored sentence, and only while nothing fresher is on screen. The
218
+ live box below shows the result of a test just run, and the reload that
219
+ follows it puts the very same sentence here — so both would print it
220
+ twice, and past the length the platform truncates at, twice differently.
221
+ The freshly returned one wins, being whole (specs/credential-test.md
222
+ AC-1). -->
223
+ <p v-if="state.message && !liveTest" class="mt-1 text-sm break-words opacity-90">
218
224
  {{ state.message }}
219
225
  </p>
220
226
  <p v-else-if="state.key === 'untested'" class="mt-1 text-sm opacity-80">
@@ -314,7 +320,7 @@ async function connect() {
314
320
  class="rounded-md px-3 py-2 text-sm"
315
321
  :class="liveTest.ok ? 'bg-emerald-500/10 text-emerald-700' : 'bg-rose-500/10 text-rose-700'"
316
322
  >
317
- {{ liveTest.ok ? t("integration.credentials.shared.test.ok") : liveTest.error ?? t("integration.credentials.shared.test.failed") }}
323
+ {{ liveTest.ok ? liveTest.message ?? t("integration.credentials.shared.test.ok") : liveTest.error ?? liveTest.message ?? t("integration.credentials.shared.test.failed") }}
318
324
  </div>
319
325
 
320
326
  <!-- The save was refused. Same rule as the create dialog: stated here, and at
@@ -3,6 +3,7 @@ import { onMounted, ref, watch } from "vue";
3
3
  import { RefreshCircle as Loader2, Refresh as ReplayIcon, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
4
  import { Badge, Button } from "@revenexx/studio";
5
5
  import { toast } from "vue-sonner";
6
+ import { deadLetterStatusClass, deadLetterStatusLabel } from "../../utils/statusStyle";
6
7
  const props = defineProps({
7
8
  workflowId: { type: Number, required: true }
8
9
  });
@@ -62,11 +63,6 @@ async function onDiscard(dl) {
62
63
  busyId.value = null;
63
64
  }
64
65
  }
65
- function statusClass(status) {
66
- if (status === "pending") return "bg-amber-500/15 text-amber-700 border-amber-500/25";
67
- if (status === "replayed") return "bg-emerald-500/15 text-emerald-700 border-emerald-500/25";
68
- return "bg-slate-500/15 text-slate-600 border-slate-500/20";
69
- }
70
66
  function fmt(iso) {
71
67
  if (!iso) return "\u2014";
72
68
  try {
@@ -117,7 +113,7 @@ function fmt(iso) {
117
113
  :key="dl.id"
118
114
  class="flex items-start gap-3 px-3 py-2.5"
119
115
  >
120
- <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', statusClass(dl.status)]">{{ dl.status }}</Badge>
116
+ <Badge variant="outline" :class="['border text-xs font-medium uppercase shrink-0 mt-0.5', deadLetterStatusClass(dl.status)]">{{ deadLetterStatusLabel(dl.status) }}</Badge>
121
117
  <div class="min-w-0 flex-1">
122
118
  <div class="text-sm break-words">{{ dl.reason || t("integration.deadLetters.reason.fallback") }}</div>
123
119
  <div class="mt-0.5 flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs text-muted-foreground font-mono">
@@ -220,8 +220,13 @@ function insert(expr) {
220
220
  <span class="min-w-0 truncate font-mono" :class="preview.misses ? 'text-amber-600' : 'text-emerald-700'">{{ preview.resolved }}</span>
221
221
  </p>
222
222
 
223
+ <!-- Named, because the same expressions are offered twice on this surface —
224
+ here and in the panel beside the form — and a popup nothing can address
225
+ is a popup nothing can test either. -->
223
226
  <div
224
227
  v-if="open && hasSuggestions"
228
+ role="group"
229
+ :aria-label="t('integration.nodeConfig.expression.picker.aria')"
225
230
  class="absolute right-0 top-full z-50 mt-1 flex max-h-72 w-80 flex-col rounded-md border border-black/[0.08] bg-white text-sm shadow-lg"
226
231
  >
227
232
  <div class="shrink-0 border-b border-black/[0.06] p-1">
@@ -5,6 +5,9 @@ type __VLS_Props = {
5
5
  field: ConfigField;
6
6
  config: Record<string, unknown>;
7
7
  modelValue: unknown;
8
+ /** The settings this one sits among, so a dependency can be named as the
9
+ * operator sees it rather than by its config key. */
10
+ siblingFields?: ConfigField[];
8
11
  };
9
12
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
10
13
  "update:modelValue": (value: unknown) => any;
@@ -1,5 +1,6 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
+ import { isVersionExecutable } from "../../data/integrationNodeCatalog";
3
4
  import { ChevronDown } from "lucide-vue-next";
4
5
  import { Input } from "@revenexx/studio";
5
6
  import { resolveLocalized } from "../../utils/localize";
@@ -8,12 +9,14 @@ const props = defineProps({
8
9
  version: { type: String, required: true },
9
10
  field: { type: Object, required: true },
10
11
  config: { type: Object, required: true },
11
- modelValue: { type: null, required: true }
12
+ modelValue: { type: null, required: true },
13
+ siblingFields: { type: Array, required: false }
12
14
  });
13
15
  const emit = defineEmits(["update:modelValue"]);
14
16
  const t = useIntegrationsT();
15
17
  const search = ref("");
16
18
  const focused = ref(false);
19
+ const versionNotReady = computed(() => !isVersionExecutable(props.slug, props.version));
17
20
  const q = useNodeConfigResolve({
18
21
  slug: () => props.slug,
19
22
  version: () => props.version,
@@ -22,6 +25,10 @@ const q = useNodeConfigResolve({
22
25
  dependsOn: () => props.field.dependsOn,
23
26
  search: () => focused.value ? search.value : ""
24
27
  });
28
+ const unsatisfiedLabels = computed(() => q.unsatisfied.value.map((key) => {
29
+ const sibling = props.siblingFields?.find((f) => f.key === key);
30
+ return sibling && resolveLocalized(sibling.label) || key;
31
+ }));
25
32
  const options = computed(() => q.data.value?.options ?? []);
26
33
  const isMulti = computed(() => props.field.type === "multiselect");
27
34
  const disabledControlClass = "flex h-8 items-center rounded-md border border-black/[0.08] bg-white px-2 text-sm text-muted-foreground opacity-50";
@@ -76,10 +83,16 @@ function toggleMulti(optValue) {
76
83
 
77
84
  <template>
78
85
  <div>
86
+ <!-- the pinned version cannot be run, so there is nothing to resolve -->
87
+ <template v-if="versionNotReady">
88
+ <div :class="disabledControlClass">—</div>
89
+ <p class="mt-1 text-sm text-amber-600">{{ t("integration.nodeConfig.dynamicField.versionNotReady") }}</p>
90
+ </template>
91
+
79
92
  <!-- gated: required dependencies still unset -->
80
- <template v-if="q.gated.value">
93
+ <template v-else-if="q.gated.value">
81
94
  <div :class="disabledControlClass">—</div>
82
- <p class="mt-1 text-sm text-amber-600">{{ t("integration.nodeConfig.dynamicField.gated", { keys: q.unsatisfied.value.join(", ") }) }}</p>
95
+ <p class="mt-1 text-sm text-amber-600">{{ t("integration.nodeConfig.dynamicField.gated", { keys: unsatisfiedLabels.join(", ") }) }}</p>
83
96
  </template>
84
97
 
85
98
  <!-- first load: no data yet -->
@@ -5,6 +5,9 @@ type __VLS_Props = {
5
5
  field: ConfigField;
6
6
  config: Record<string, unknown>;
7
7
  modelValue: unknown;
8
+ /** The settings this one sits among, so a dependency can be named as the
9
+ * operator sees it rather than by its config key. */
10
+ siblingFields?: ConfigField[];
8
11
  };
9
12
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
10
13
  "update:modelValue": (value: unknown) => any;
@@ -87,6 +87,7 @@ const resolvedFields = computed(() => q.data.value?.fields ?? []);
87
87
  :version="version"
88
88
  :secret-keys="secretKeys"
89
89
  :static-keys="staticKeys"
90
+ :sibling-fields="resolvedFields"
90
91
  :depth="(depth ?? 0) + 1"
91
92
  @set="(k, v) => emit('set', k, v)"
92
93
  />
@@ -6,6 +6,9 @@ type __VLS_Props = {
6
6
  version: string;
7
7
  secretKeys: string[];
8
8
  staticKeys?: string[];
9
+ /** The whole set of settings this one belongs to — a `dynamic` field names the
10
+ * ones it waits for out of it (NodeConfigDynamicField). */
11
+ siblingFields?: ConfigField[];
9
12
  depth?: number;
10
13
  variables?: Record<string, WorkflowVariable>;
11
14
  upstreamOutputs?: Array<{
@@ -23,6 +23,7 @@ const props = defineProps({
23
23
  version: { type: String, required: true },
24
24
  secretKeys: { type: Array, required: true },
25
25
  staticKeys: { type: Array, required: false },
26
+ siblingFields: { type: Array, required: false },
26
27
  depth: { type: Number, required: false },
27
28
  variables: { type: Object, required: false },
28
29
  upstreamOutputs: { type: Array, required: false },
@@ -395,6 +396,7 @@ function setArrayItemField(index, fieldKey, v) {
395
396
  :field="field"
396
397
  :config="config"
397
398
  :model-value="value"
399
+ :sibling-fields="siblingFields"
398
400
  @update:model-value="write"
399
401
  />
400
402
 
@@ -6,6 +6,9 @@ type __VLS_Props = {
6
6
  version: string;
7
7
  secretKeys: string[];
8
8
  staticKeys?: string[];
9
+ /** The whole set of settings this one belongs to — a `dynamic` field names the
10
+ * ones it waits for out of it (NodeConfigDynamicField). */
11
+ siblingFields?: ConfigField[];
9
12
  depth?: number;
10
13
  variables?: Record<string, WorkflowVariable>;
11
14
  upstreamOutputs?: Array<{
@@ -28,13 +28,13 @@ type __VLS_Props = {
28
28
  refusedFields?: Record<string, string>;
29
29
  };
30
30
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
31
- close: () => any;
32
31
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
+ close: () => any;
33
33
  remove: () => any;
34
34
  navigate: (id: string) => any;
35
35
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
36
- onClose?: (() => any) | undefined;
37
36
  onUpdate?: ((next: WorkflowTrigger | WorkflowNode) => any) | undefined;
37
+ onClose?: (() => any) | undefined;
38
38
  onRemove?: (() => any) | undefined;
39
39
  onNavigate?: ((id: string) => any) | undefined;
40
40
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -1,7 +1,8 @@
1
1
  <script setup>
2
2
  import { computed, nextTick, onMounted, provide, ref, watch } from "vue";
3
- import { AddCircle as Plus, TrashBinMinimalistic as TrashBin, Copy } from "#solar-icons";
4
- import { ChevronDown, ChevronLeft, ChevronRight, Clock, Database, MessageSquareText, Play, Search, Webhook, X, Zap } from "lucide-vue-next";
3
+ import { AddCircle as Plus, Copy, Magnifier as Search, TrashBinMinimalistic as TrashBin } from "#solar-icons";
4
+ import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
5
+ import { ChevronDown, ChevronLeft, ChevronRight, Database, MessageSquareText, X } from "lucide-vue-next";
5
6
  import { toast } from "vue-sonner";
6
7
  import {
7
8
  Button,
@@ -27,15 +28,18 @@ import {
27
28
  ToggleGroupItem
28
29
  } from "@revenexx/studio";
29
30
  import EventTypeCombobox from "./EventTypeCombobox.vue";
30
- import { findManifest, nodeCategoryLabel } from "../../data/integrationNodeCatalog";
31
+ import { findManifest, nodeCategoryLabel, isVersionExecutable, newestExecutableVersion } from "../../data/integrationNodeCatalog";
31
32
  import {
32
33
  collectUpstreamNodes,
34
+ inputPortsForManifest,
33
35
  nodeOutputExpr,
34
36
  portsForNode,
35
37
  triggerPayloadExpr,
36
38
  variableExpr
37
39
  } from "../../data/nodeInputData";
38
40
  import { portDotClass } from "../../data/portTone";
41
+ import { computeLoopBodies, manifestIterates } from "../../data/loopBody";
42
+ import { nodeDisplayLabel } from "../../utils/validationFindings";
39
43
  import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
40
44
  import {
41
45
  dataTypeOf,
@@ -87,14 +91,21 @@ const manifest = computed(() => {
87
91
  const wn = draftNode.value;
88
92
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
89
93
  });
90
- const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
94
+ const versionExecutable = computed(() => {
95
+ const wn = draftNode.value;
96
+ return wn ? isVersionExecutable(wn.nodeSlug, wn.nodeVersion) : true;
97
+ });
98
+ const readyVersion = computed(() => {
99
+ const wn = draftNode.value;
100
+ return wn ? newestExecutableVersion(wn.nodeSlug) : null;
101
+ });
91
102
  const TRIGGER_LABELS = computed(() => ({
92
103
  manual: t("integration.nodeInspector.trigger.type.manual"),
93
104
  schedule: t("integration.nodeInspector.trigger.type.schedule"),
94
105
  webhook: t("integration.nodeInspector.trigger.type.webhook"),
95
106
  event: t("integration.nodeInspector.trigger.type.event")
96
107
  }));
97
- const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
108
+ const triggerGlyph = computed(() => glyphForTrigger(draftTrigger.value?.type));
98
109
  const namePlaceholder = computed(() => {
99
110
  if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
100
111
  return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
@@ -115,6 +126,7 @@ const nameValue = computed(() => {
115
126
  const name = draft.value.name;
116
127
  return resolveDisplayName(name);
117
128
  });
129
+ const dialogTitle = computed(() => t("integration.nodeInspector.dialogTitle", { name: nameValue.value || namePlaceholder.value }));
118
130
  function writeName(name) {
119
131
  draft.value = { ...draft.value, name };
120
132
  }
@@ -759,8 +771,25 @@ function navigate(offset) {
759
771
  emit("navigate", target);
760
772
  }
761
773
  const outputView = ref("schema");
774
+ const selfInputs = computed(() => inputPortsForManifest(manifest.value));
762
775
  const selfSample = computed(() => draftNode.value ? sampleFor(draftNode.value.id) : void 0);
763
776
  const selfPorts = computed(() => draftNode.value ? withSampledFields(portsForNode(draftNode.value, manifest.value, props.dynamicOutputs?.[draftNode.value.id]), selfSample.value) : []);
777
+ const loopBody = computed(() => {
778
+ if (!draftNode.value || !manifestIterates(manifest.value)) return null;
779
+ const nodes = props.nodes ?? [];
780
+ const [body] = computeLoopBodies([draftNode.value.id], props.edges ?? []);
781
+ if (!body) return null;
782
+ const labelOf = (id) => {
783
+ const n = nodes.find((x) => x.id === id);
784
+ return n ? nodeDisplayLabel(n) : id;
785
+ };
786
+ const inBody = (ids) => nodes.filter((n) => ids.has(n.id)).map((n) => labelOf(n.id));
787
+ return {
788
+ size: body.nodeIds.size,
789
+ names: inBody(body.nodeIds),
790
+ joined: inBody(body.joinedWithDone)
791
+ };
792
+ });
764
793
  const selfSampleJson = computed(() => selfSample.value ? JSON.stringify(selfSample.value, null, 2) : "");
765
794
  const selfPinned = computed(() => !!draftNode.value && !!pinnedSamples.value[draftNode.value.id]);
766
795
  watch(selfSample, (s) => {
@@ -867,6 +896,11 @@ async function runTestStep() {
867
896
  <!-- Fills the editor's near-fullscreen config lightbox; the Dialog owns
868
897
  width/height, border and the close control. -->
869
898
  <div class="flex min-h-0 flex-1 flex-col bg-white">
899
+ <!-- The dialog's accessible name. Not drawn: the header below says the same
900
+ thing in the name field and the line under it, and says it better. -->
901
+ <DialogTitle class="sr-only">
902
+ {{ dialogTitle }}
903
+ </DialogTitle>
870
904
  <!-- Header -->
871
905
  <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
872
906
  <div class="flex size-9 shrink-0 items-center justify-center rounded-md border border-black/[0.06] bg-slate-50 text-slate-600">
@@ -1032,6 +1066,36 @@ async function runTestStep() {
1032
1066
  <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1033
1067
  {{ t("integration.nodeInspector.input.description") }}
1034
1068
  </p>
1069
+ <!-- What this node accepts, as opposed to the list below, which is what
1070
+ other nodes offer it. It sits in the header because somebody
1071
+ choosing a value is already reading here, and because the port
1072
+ belongs to the node being configured rather than to any row in the
1073
+ list (AC-20). -->
1074
+ <div v-if="isNode && selfInputs.length" class="mt-2 space-y-1 border-t border-black/[0.06] pt-2">
1075
+ <div v-for="p in selfInputs" :key="p.name" class="space-y-0.5">
1076
+ <div class="flex items-baseline gap-1.5">
1077
+ <!-- Not the uppercase treatment the section labels below use, and
1078
+ that is the point: NODES / TRIGGER / VARIABLES each head a list
1079
+ of values a click inserts, so they are a series of *sources*.
1080
+ This heads nothing and is not a source — it is a statement about
1081
+ the node being configured. Dressed as a fourth member of that
1082
+ series it told the reader there was a fourth place to take data
1083
+ from. -->
1084
+ <span
1085
+ class="text-xs font-medium text-muted-foreground"
1086
+ :class="selfInputs.length > 1 ? 'font-mono' : ''"
1087
+ >
1088
+ {{ selfInputs.length > 1 ? `${p.name}:` : t("integration.nodeInspector.input.expects.heading") }}
1089
+ </span>
1090
+ <span class="font-mono text-[10px] text-slate-500">
1091
+ {{ p.dataType }}<template v-if="p.dataType"> · </template>{{ p.required ? t("integration.nodeInspector.input.expects.required") : t("integration.nodeInspector.input.expects.optional") }}
1092
+ </span>
1093
+ </div>
1094
+ <p v-if="resolveLocalized(p.description)" class="text-xs leading-snug text-muted-foreground">
1095
+ {{ resolveLocalized(p.description) }}
1096
+ </p>
1097
+ </div>
1098
+ </div>
1035
1099
  <div class="relative mt-2">
1036
1100
  <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
1037
1101
  <input
@@ -1098,7 +1162,30 @@ async function runTestStep() {
1098
1162
  </span>
1099
1163
  </button>
1100
1164
  <div v-show="!sectionClosed(s.id)" class="space-y-2 border-t border-black/[0.06] px-2 py-2">
1101
- <div v-for="p in s.ports" :key="p.name" class="space-y-1 pl-0.5">
1165
+ <!-- These ports belong to an UPSTREAM node, so the sentence is that
1166
+ node's. On a 300px column it is a tooltip rather than text: the
1167
+ values below are what this panel is for, and a sentence per port
1168
+ pushes them out of view. The readable copy is the Output tab
1169
+ (AC-19).
1170
+
1171
+ The title hangs on the port's whole block, not on its name row:
1172
+ that row is only drawn for a node with several ports or a
1173
+ branch/error one, so a single `default` port — which is most
1174
+ nodes, No Operation among them — would have had nowhere to carry
1175
+ it and the tooltip would have been dead in the commonest case.
1176
+
1177
+ What that buys is uneven, and it is the reason this is a
1178
+ convenience rather than a promise: each field row carries its own
1179
+ title (the expression to copy), and a child title wins, so the
1180
+ sentence is read by resting on the port's name or the space around
1181
+ its rows rather than on a row itself. Where a reader is owed the
1182
+ text outright, that is the Output tab. -->
1183
+ <div
1184
+ v-for="p in s.ports"
1185
+ :key="p.name"
1186
+ class="space-y-1 pl-0.5"
1187
+ :title="resolveLocalized(p.description) || void 0"
1188
+ >
1102
1189
  <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
1103
1190
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1104
1191
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
@@ -1207,6 +1294,19 @@ async function runTestStep() {
1207
1294
  <!-- Same TabBar as the workflow page — one tab look everywhere. -->
1208
1295
  <TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
1209
1296
 
1297
+ <!-- The pinned version has no built code, so nothing here can be tested
1298
+ or resolved. Above the tabs' content rather than inside one of them:
1299
+ the state belongs to the node, not to a tab, and a reader who opens
1300
+ this node on Parameters would otherwise meet the card's mark outside
1301
+ and no word about it inside. Outside the scrolling area so it cannot
1302
+ be scrolled away from. -->
1303
+ <p
1304
+ v-if="isNode && !versionExecutable"
1305
+ class="mx-4 mb-1 shrink-0 rounded-md border border-rose-200 bg-rose-50 px-2.5 py-1.5 text-sm text-rose-700"
1306
+ >
1307
+ {{ readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1308
+ </p>
1309
+
1210
1310
  <div
1211
1311
  class="min-h-0 flex-1 overflow-y-auto p-4"
1212
1312
  :class="dragActive ? 'chip-drag-live' : ''"
@@ -1654,6 +1754,7 @@ async function runTestStep() {
1654
1754
  :version="draftNode.nodeVersion"
1655
1755
  :secret-keys="secretKeys"
1656
1756
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1757
+ :sibling-fields="manifest.config ?? []"
1657
1758
  :depth="0"
1658
1759
  :variables="variables"
1659
1760
  :upstream-outputs="upstreamOutputs"
@@ -1693,6 +1794,7 @@ async function runTestStep() {
1693
1794
  :version="draftNode.nodeVersion"
1694
1795
  :secret-keys="secretKeys"
1695
1796
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1797
+ :sibling-fields="manifest.config ?? []"
1696
1798
  :depth="0"
1697
1799
  :variables="variables"
1698
1800
  :upstream-outputs="upstreamOutputs"
@@ -1734,6 +1836,7 @@ async function runTestStep() {
1734
1836
  :version="draftNode.nodeVersion"
1735
1837
  :secret-keys="secretKeys"
1736
1838
  :static-keys="(manifest?.config ?? []).map((f2) => f2.key)"
1839
+ :sibling-fields="manifest?.config ?? []"
1737
1840
  :depth="0"
1738
1841
  :variables="variables"
1739
1842
  :upstream-outputs="upstreamOutputs"
@@ -1852,6 +1955,28 @@ async function runTestStep() {
1852
1955
  rather than a permanent column — it is the last thing done, if it
1853
1956
  is done at all, and it needs the width when it is. -->
1854
1957
  <div v-else-if="tab === 'output' && draftNode" class="mx-auto w-full max-w-5xl space-y-5">
1958
+ <!-- An iterator's own tab leads with what it repeats (PO-277): the
1959
+ ports below say what leaves Item and Done, and this says how much
1960
+ of the workflow hangs off the first of them. Above the port
1961
+ schema on purpose — the count is the thing a reader came here to
1962
+ check, and it is the one statement the canvas cannot make. -->
1963
+ <section v-if="loopBody" class="space-y-1.5 rounded-md border border-black/[0.06] bg-white p-3">
1964
+ <span class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.output.loopBody.heading") }}</span>
1965
+ <p class="text-sm text-foreground/80">
1966
+ {{ t("integration.nodeInspector.output.loopBody.size", loopBody.size) }}
1967
+ </p>
1968
+ <p v-if="loopBody.names.length" class="text-xs text-muted-foreground">
1969
+ {{ t("integration.nodeInspector.output.loopBody.names", { names: loopBody.names.join(", ") }) }}
1970
+ </p>
1971
+ <!-- Amber, not rose, and not a validation finding: a body wired
1972
+ into the Done side may be exactly what the author wanted, so
1973
+ this states what the engine will do and leaves the judgement
1974
+ to them. Routing it through the findings would refuse an
1975
+ activation that is legitimate. -->
1976
+ <p v-if="loopBody.joined.length" class="text-xs text-amber-700">
1977
+ {{ t("integration.nodeInspector.output.loopBody.joined", loopBody.joined.length, { names: loopBody.joined.join(", ") }) }}
1978
+ </p>
1979
+ </section>
1855
1980
  <section class="space-y-2">
1856
1981
  <div class="flex items-center justify-between gap-2">
1857
1982
  <p class="text-xs text-muted-foreground">
@@ -1878,8 +2003,8 @@ async function runTestStep() {
1878
2003
  <Button
1879
2004
  size="sm"
1880
2005
  class="h-8"
1881
- :disabled="testRunning"
1882
- :title="t('integration.nodeInspector.output.test.runTitle')"
2006
+ :disabled="testRunning || !versionExecutable"
2007
+ :title="versionExecutable ? t('integration.nodeInspector.output.test.runTitle') : void 0"
1883
2008
  @click="runTestStep"
1884
2009
  >
1885
2010
  {{ testRunning ? t("integration.nodeInspector.output.test.running") : t("integration.nodeInspector.output.test.action") }}
@@ -1892,7 +2017,7 @@ async function runTestStep() {
1892
2017
  {{ testInputOpen ? t("integration.nodeInspector.output.test.hideInput") : t("integration.nodeInspector.output.test.editInput") }}
1893
2018
  </button>
1894
2019
  <span class="text-xs text-muted-foreground">
1895
- {{ t("integration.nodeInspector.output.test.runHint") }}
2020
+ {{ versionExecutable ? t("integration.nodeInspector.output.test.runHint") : "" }}
1896
2021
  </span>
1897
2022
  </div>
1898
2023
  <Textarea
@@ -1999,7 +2124,7 @@ async function runTestStep() {
1999
2124
  {{ t("integration.common.action.cancel") }}
2000
2125
  </Button>
2001
2126
  <Button size="sm" class="h-8" @click="save">
2002
- {{ t("integration.common.action.save") }}
2127
+ {{ t("integration.nodeInspector.footer.done") }}
2003
2128
  </Button>
2004
2129
  </div>
2005
2130
  </div>
@@ -28,13 +28,13 @@ type __VLS_Props = {
28
28
  refusedFields?: Record<string, string>;
29
29
  };
30
30
  declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
31
- close: () => any;
32
31
  update: (next: WorkflowTrigger | WorkflowNode) => any;
32
+ close: () => any;
33
33
  remove: () => any;
34
34
  navigate: (id: string) => any;
35
35
  }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
36
- onClose?: (() => any) | undefined;
37
36
  onUpdate?: ((next: WorkflowTrigger | WorkflowNode) => any) | undefined;
37
+ onClose?: (() => any) | undefined;
38
38
  onRemove?: (() => any) | undefined;
39
39
  onNavigate?: ((id: string) => any) | undefined;
40
40
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
@@ -3,7 +3,8 @@ import type { PortEntry } from '../../data/nodeInputData.js';
3
3
  * The ports a node hands downstream, with their declared fields and whatever
4
4
  * sample value is known for each.
5
5
  *
6
- * Rendered from one place — the Output tab (specs/node-configuration.md AC-4). It
6
+ * Rendered from one place — the Output tab (specs/node-configuration.md AC-4,
7
+ * and AC-19 for the sentences the publisher wrote about a port and its fields). It
7
8
  * keeps its own file for readability rather than for reuse: the schema list is
8
9
  * a self-contained tree with its own type glyphs and sample formatting, and
9
10
  * inlining it would bury the tab's own markup.
@@ -1,6 +1,7 @@
1
1
  <script setup>
2
2
  import { portDotClass } from "../../data/portTone";
3
- defineProps({
3
+ import { resolveLocalized } from "../../utils/localize";
4
+ const props = defineProps({
4
5
  ports: { type: Array, required: true },
5
6
  valueAt: { type: Function, required: true }
6
7
  });
@@ -16,6 +17,15 @@ const TYPE_GLYPHS = {
16
17
  function typeGlyph(dataType) {
17
18
  return dataType ? TYPE_GLYPHS[dataType] ?? null : null;
18
19
  }
20
+ function portText(p) {
21
+ return resolveLocalized(p.description);
22
+ }
23
+ function fieldText(f) {
24
+ return resolveLocalized(f.description);
25
+ }
26
+ function saysNoFields(p) {
27
+ return !portText(p) || props.valueAt(p.name) !== null;
28
+ }
19
29
  </script>
20
30
 
21
31
  <template>
@@ -25,14 +35,30 @@ function typeGlyph(dataType) {
25
35
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
26
36
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
27
37
  </div>
28
- <div v-if="p.fields.length" class="space-y-0.5">
29
- <div v-for="f in p.fields" :key="f.name" class="flex min-w-0 items-baseline gap-1 font-mono text-[11px]">
30
- <span v-if="typeGlyph(f.dataType)" class="shrink-0 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span>
31
- <span class="shrink-0 text-slate-700">{{ f.name }}</span>
32
- <span v-if="valueAt(p.name, f.name) !== null" class="min-w-0 truncate text-slate-400">: {{ valueAt(p.name, f.name) }}</span>
33
- </div>
38
+ <p v-if="portText(p)" class="text-[11px] leading-snug text-muted-foreground">
39
+ {{ portText(p) }}
40
+ </p>
41
+ <!-- Two columns, sized to the widest field rather than split down the
42
+ middle: `max-content` puts every sentence one gap after the longest
43
+ name, so short names like `count` do not leave the reader crossing the
44
+ dialog to find what they mean, and the names still line up with each
45
+ other. The sample value is capped for the same reason — left free it
46
+ would widen the column and push the sentences back out. Both cells are
47
+ always emitted, or a field with nothing to say would slide the next
48
+ row's name into the sentence column. -->
49
+ <div v-if="p.fields.length" class="grid grid-cols-[max-content_minmax(0,1fr)] items-baseline gap-x-6 gap-y-0.5">
50
+ <template v-for="f in p.fields" :key="f.name">
51
+ <div class="flex min-w-0 items-baseline gap-1 font-mono text-[11px]">
52
+ <span v-if="typeGlyph(f.dataType)" class="shrink-0 font-semibold" :class="typeGlyph(f.dataType).cls">{{ typeGlyph(f.dataType).char }}</span>
53
+ <span class="shrink-0 text-slate-700">{{ f.name }}</span>
54
+ <span v-if="valueAt(p.name, f.name) !== null" class="max-w-56 truncate text-slate-400">: {{ valueAt(p.name, f.name) }}</span>
55
+ </div>
56
+ <p class="min-w-0 text-[11px] leading-snug text-muted-foreground">
57
+ {{ fieldText(f) }}
58
+ </p>
59
+ </template>
34
60
  </div>
35
- <p v-else class="text-[11px] leading-snug text-muted-foreground">
61
+ <p v-else-if="saysNoFields(p)" class="text-[11px] leading-snug text-muted-foreground">
36
62
  {{ t("integration.nodeInspector.output.schema.noFields") }}<template v-if="valueAt(p.name) !== null">{{ t("integration.nodeInspector.output.schema.sampledSuffix") }} <span class="font-mono">{{ valueAt(p.name) }}</span></template>.
37
63
  </p>
38
64
  </div>
@@ -3,7 +3,8 @@ import type { PortEntry } from '../../data/nodeInputData.js';
3
3
  * The ports a node hands downstream, with their declared fields and whatever
4
4
  * sample value is known for each.
5
5
  *
6
- * Rendered from one place — the Output tab (specs/node-configuration.md AC-4). It
6
+ * Rendered from one place — the Output tab (specs/node-configuration.md AC-4,
7
+ * and AC-19 for the sentences the publisher wrote about a port and its fields). It
7
8
  * keeps its own file for readability rather than for reuse: the schema list is
8
9
  * a self-contained tree with its own type glyphs and sample formatting, and
9
10
  * inlining it would bury the tab's own markup.