@revenexx/studio-integrations 0.7.0 → 1.0.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 (71) hide show
  1. package/CHANGELOG.md +416 -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/ExpressionInput.vue +5 -0
  7. package/dist/runtime/components/Integrations/NodeConfigDynamicField.d.vue.ts +3 -0
  8. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +16 -3
  9. package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue.d.ts +3 -0
  10. package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +1 -0
  11. package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +3 -0
  12. package/dist/runtime/components/Integrations/NodeConfigField.vue +2 -0
  13. package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +3 -0
  14. package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +2 -2
  15. package/dist/runtime/components/Integrations/NodeInspector.vue +95 -10
  16. package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +2 -2
  17. package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +2 -1
  18. package/dist/runtime/components/Integrations/NodeOutputSchema.vue +34 -8
  19. package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +2 -1
  20. package/dist/runtime/components/Integrations/NodePalette.vue +12 -8
  21. package/dist/runtime/components/Integrations/StickyNoteView.vue +45 -11
  22. package/dist/runtime/components/Integrations/SurfaceCard.d.vue.ts +50 -0
  23. package/dist/runtime/components/Integrations/SurfaceCard.vue +55 -0
  24. package/dist/runtime/components/Integrations/SurfaceCard.vue.d.ts +50 -0
  25. package/dist/runtime/components/Integrations/TemplateCard.vue +3 -3
  26. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +12 -3
  27. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +5 -4
  28. package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +16 -0
  29. package/dist/runtime/components/Integrations/WorkflowEditor.vue +52 -11
  30. package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +16 -0
  31. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.d.vue.ts +6 -0
  32. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -2
  33. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue.d.ts +6 -0
  34. package/dist/runtime/components/Integrations/WorkflowNodeView.vue +59 -28
  35. package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +3 -4
  36. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.d.vue.ts +16 -17
  37. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +37 -26
  38. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue.d.ts +16 -17
  39. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -5
  40. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +53 -14
  41. package/dist/runtime/composables/useIntegrationsCredentials.d.ts +11 -0
  42. package/dist/runtime/composables/useIntegrationsCredentials.js +2 -2
  43. package/dist/runtime/composables/useIntegrationsNodes.js +11 -1
  44. package/dist/runtime/composables/useIntegrationsSchedules.d.ts +3 -2
  45. package/dist/runtime/composables/useIntegrationsTriggers.d.ts +23 -3
  46. package/dist/runtime/composables/useIntegrationsTriggers.js +18 -7
  47. package/dist/runtime/composables/useIntegrationsWorkflows.d.ts +10 -1
  48. package/dist/runtime/composables/useIntegrationsWorkflows.js +25 -4
  49. package/dist/runtime/composables/useNodeConfigResolve.js +2 -1
  50. package/dist/runtime/data/integrationNodeCatalog.d.ts +22 -1
  51. package/dist/runtime/data/integrationNodeCatalog.js +8 -4
  52. package/dist/runtime/data/nodeInputData.d.ts +58 -1
  53. package/dist/runtime/data/nodeInputData.js +30 -5
  54. package/dist/runtime/data/triggerGlyphs.d.ts +47 -0
  55. package/dist/runtime/data/triggerGlyphs.js +11 -0
  56. package/dist/runtime/i18n/messages/de.json +20 -3
  57. package/dist/runtime/i18n/messages/en.json +20 -3
  58. package/dist/runtime/pages/integrations/credentials.vue +4 -1
  59. package/dist/runtime/pages/integrations/index.vue +16 -17
  60. package/dist/runtime/pages/integrations/templates/[slug].vue +13 -5
  61. package/dist/runtime/pages/integrations/workflows/[id].vue +193 -104
  62. package/dist/runtime/types/integrations.d.ts +25 -1
  63. package/dist/runtime/utils/cockpitSources.d.ts +22 -3
  64. package/dist/runtime/utils/credentialState.d.ts +6 -1
  65. package/dist/runtime/utils/credentialState.js +1 -1
  66. package/dist/runtime/utils/localize.d.ts +34 -0
  67. package/dist/runtime/utils/localize.js +22 -7
  68. package/dist/runtime/utils/pollBudget.d.ts +1 -1
  69. package/dist/runtime/utils/servedPaths.d.ts +22 -0
  70. package/dist/runtime/utils/servedPaths.js +3 -0
  71. package/package.json +1 -1
@@ -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,9 +28,10 @@ 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,
@@ -87,14 +89,21 @@ const manifest = computed(() => {
87
89
  const wn = draftNode.value;
88
90
  return wn ? findManifest(wn.nodeSlug, wn.nodeVersion) : null;
89
91
  });
90
- const TRIGGER_GLYPHS = { manual: Play, schedule: Clock, webhook: Webhook, event: Zap };
92
+ const versionExecutable = computed(() => {
93
+ const wn = draftNode.value;
94
+ return wn ? isVersionExecutable(wn.nodeSlug, wn.nodeVersion) : true;
95
+ });
96
+ const readyVersion = computed(() => {
97
+ const wn = draftNode.value;
98
+ return wn ? newestExecutableVersion(wn.nodeSlug) : null;
99
+ });
91
100
  const TRIGGER_LABELS = computed(() => ({
92
101
  manual: t("integration.nodeInspector.trigger.type.manual"),
93
102
  schedule: t("integration.nodeInspector.trigger.type.schedule"),
94
103
  webhook: t("integration.nodeInspector.trigger.type.webhook"),
95
104
  event: t("integration.nodeInspector.trigger.type.event")
96
105
  }));
97
- const triggerGlyph = computed(() => TRIGGER_GLYPHS[draftTrigger.value?.type ?? "manual"]);
106
+ const triggerGlyph = computed(() => glyphForTrigger(draftTrigger.value?.type));
98
107
  const namePlaceholder = computed(() => {
99
108
  if (draftNode.value) return resolveLocalized(manifest.value?.name) || draftNode.value.nodeSlug;
100
109
  return t("integration.nodeInspector.trigger.placeholder", { type: TRIGGER_LABELS.value[draftTrigger.value?.type ?? "manual"] });
@@ -115,6 +124,7 @@ const nameValue = computed(() => {
115
124
  const name = draft.value.name;
116
125
  return resolveDisplayName(name);
117
126
  });
127
+ const dialogTitle = computed(() => t("integration.nodeInspector.dialogTitle", { name: nameValue.value || namePlaceholder.value }));
118
128
  function writeName(name) {
119
129
  draft.value = { ...draft.value, name };
120
130
  }
@@ -759,6 +769,7 @@ function navigate(offset) {
759
769
  emit("navigate", target);
760
770
  }
761
771
  const outputView = ref("schema");
772
+ const selfInputs = computed(() => inputPortsForManifest(manifest.value));
762
773
  const selfSample = computed(() => draftNode.value ? sampleFor(draftNode.value.id) : void 0);
763
774
  const selfPorts = computed(() => draftNode.value ? withSampledFields(portsForNode(draftNode.value, manifest.value, props.dynamicOutputs?.[draftNode.value.id]), selfSample.value) : []);
764
775
  const selfSampleJson = computed(() => selfSample.value ? JSON.stringify(selfSample.value, null, 2) : "");
@@ -867,6 +878,11 @@ async function runTestStep() {
867
878
  <!-- Fills the editor's near-fullscreen config lightbox; the Dialog owns
868
879
  width/height, border and the close control. -->
869
880
  <div class="flex min-h-0 flex-1 flex-col bg-white">
881
+ <!-- The dialog's accessible name. Not drawn: the header below says the same
882
+ thing in the name field and the line under it, and says it better. -->
883
+ <DialogTitle class="sr-only">
884
+ {{ dialogTitle }}
885
+ </DialogTitle>
870
886
  <!-- Header -->
871
887
  <div class="flex shrink-0 items-center gap-3 border-b border-black/[0.06] px-4 py-3">
872
888
  <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 +1048,36 @@ async function runTestStep() {
1032
1048
  <p class="mt-0.5 text-xs leading-snug text-muted-foreground">
1033
1049
  {{ t("integration.nodeInspector.input.description") }}
1034
1050
  </p>
1051
+ <!-- What this node accepts, as opposed to the list below, which is what
1052
+ other nodes offer it. It sits in the header because somebody
1053
+ choosing a value is already reading here, and because the port
1054
+ belongs to the node being configured rather than to any row in the
1055
+ list (AC-20). -->
1056
+ <div v-if="isNode && selfInputs.length" class="mt-2 space-y-1 border-t border-black/[0.06] pt-2">
1057
+ <div v-for="p in selfInputs" :key="p.name" class="space-y-0.5">
1058
+ <div class="flex items-baseline gap-1.5">
1059
+ <!-- Not the uppercase treatment the section labels below use, and
1060
+ that is the point: NODES / TRIGGER / VARIABLES each head a list
1061
+ of values a click inserts, so they are a series of *sources*.
1062
+ This heads nothing and is not a source — it is a statement about
1063
+ the node being configured. Dressed as a fourth member of that
1064
+ series it told the reader there was a fourth place to take data
1065
+ from. -->
1066
+ <span
1067
+ class="text-xs font-medium text-muted-foreground"
1068
+ :class="selfInputs.length > 1 ? 'font-mono' : ''"
1069
+ >
1070
+ {{ selfInputs.length > 1 ? `${p.name}:` : t("integration.nodeInspector.input.expects.heading") }}
1071
+ </span>
1072
+ <span class="font-mono text-[10px] text-slate-500">
1073
+ {{ p.dataType }}<template v-if="p.dataType"> · </template>{{ p.required ? t("integration.nodeInspector.input.expects.required") : t("integration.nodeInspector.input.expects.optional") }}
1074
+ </span>
1075
+ </div>
1076
+ <p v-if="resolveLocalized(p.description)" class="text-xs leading-snug text-muted-foreground">
1077
+ {{ resolveLocalized(p.description) }}
1078
+ </p>
1079
+ </div>
1080
+ </div>
1035
1081
  <div class="relative mt-2">
1036
1082
  <Search :size="12" class="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
1037
1083
  <input
@@ -1098,7 +1144,30 @@ async function runTestStep() {
1098
1144
  </span>
1099
1145
  </button>
1100
1146
  <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">
1147
+ <!-- These ports belong to an UPSTREAM node, so the sentence is that
1148
+ node's. On a 300px column it is a tooltip rather than text: the
1149
+ values below are what this panel is for, and a sentence per port
1150
+ pushes them out of view. The readable copy is the Output tab
1151
+ (AC-19).
1152
+
1153
+ The title hangs on the port's whole block, not on its name row:
1154
+ that row is only drawn for a node with several ports or a
1155
+ branch/error one, so a single `default` port — which is most
1156
+ nodes, No Operation among them — would have had nowhere to carry
1157
+ it and the tooltip would have been dead in the commonest case.
1158
+
1159
+ What that buys is uneven, and it is the reason this is a
1160
+ convenience rather than a promise: each field row carries its own
1161
+ title (the expression to copy), and a child title wins, so the
1162
+ sentence is read by resting on the port's name or the space around
1163
+ its rows rather than on a row itself. Where a reader is owed the
1164
+ text outright, that is the Output tab. -->
1165
+ <div
1166
+ v-for="p in s.ports"
1167
+ :key="p.name"
1168
+ class="space-y-1 pl-0.5"
1169
+ :title="resolveLocalized(p.description) || void 0"
1170
+ >
1102
1171
  <div v-if="s.ports.length > 1 || p.kind !== 'default'" class="flex items-center gap-1.5">
1103
1172
  <span class="size-1.5 rounded-full" :class="portDotClass(p.kind, p.name)" />
1104
1173
  <span class="text-[10px] font-medium uppercase tracking-wider text-muted-foreground">{{ p.name }}</span>
@@ -1207,6 +1276,19 @@ async function runTestStep() {
1207
1276
  <!-- Same TabBar as the workflow page — one tab look everywhere. -->
1208
1277
  <TabBar v-model="tab" :tabs="inspectorTabs" class="shrink-0 px-4" />
1209
1278
 
1279
+ <!-- The pinned version has no built code, so nothing here can be tested
1280
+ or resolved. Above the tabs' content rather than inside one of them:
1281
+ the state belongs to the node, not to a tab, and a reader who opens
1282
+ this node on Parameters would otherwise meet the card's mark outside
1283
+ and no word about it inside. Outside the scrolling area so it cannot
1284
+ be scrolled away from. -->
1285
+ <p
1286
+ v-if="isNode && !versionExecutable"
1287
+ 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"
1288
+ >
1289
+ {{ readyVersion ? t("integration.nodeInspector.notice.versionNotReady.upgrade", { version: draftNode?.nodeVersion ?? "", ready: readyVersion }) : t("integration.nodeInspector.notice.versionNotReady.none", { version: draftNode?.nodeVersion ?? "" }) }}
1290
+ </p>
1291
+
1210
1292
  <div
1211
1293
  class="min-h-0 flex-1 overflow-y-auto p-4"
1212
1294
  :class="dragActive ? 'chip-drag-live' : ''"
@@ -1654,6 +1736,7 @@ async function runTestStep() {
1654
1736
  :version="draftNode.nodeVersion"
1655
1737
  :secret-keys="secretKeys"
1656
1738
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1739
+ :sibling-fields="manifest.config ?? []"
1657
1740
  :depth="0"
1658
1741
  :variables="variables"
1659
1742
  :upstream-outputs="upstreamOutputs"
@@ -1693,6 +1776,7 @@ async function runTestStep() {
1693
1776
  :version="draftNode.nodeVersion"
1694
1777
  :secret-keys="secretKeys"
1695
1778
  :static-keys="(manifest.config ?? []).map((f2) => f2.key)"
1779
+ :sibling-fields="manifest.config ?? []"
1696
1780
  :depth="0"
1697
1781
  :variables="variables"
1698
1782
  :upstream-outputs="upstreamOutputs"
@@ -1734,6 +1818,7 @@ async function runTestStep() {
1734
1818
  :version="draftNode.nodeVersion"
1735
1819
  :secret-keys="secretKeys"
1736
1820
  :static-keys="(manifest?.config ?? []).map((f2) => f2.key)"
1821
+ :sibling-fields="manifest?.config ?? []"
1737
1822
  :depth="0"
1738
1823
  :variables="variables"
1739
1824
  :upstream-outputs="upstreamOutputs"
@@ -1878,8 +1963,8 @@ async function runTestStep() {
1878
1963
  <Button
1879
1964
  size="sm"
1880
1965
  class="h-8"
1881
- :disabled="testRunning"
1882
- :title="t('integration.nodeInspector.output.test.runTitle')"
1966
+ :disabled="testRunning || !versionExecutable"
1967
+ :title="versionExecutable ? t('integration.nodeInspector.output.test.runTitle') : void 0"
1883
1968
  @click="runTestStep"
1884
1969
  >
1885
1970
  {{ testRunning ? t("integration.nodeInspector.output.test.running") : t("integration.nodeInspector.output.test.action") }}
@@ -1892,7 +1977,7 @@ async function runTestStep() {
1892
1977
  {{ testInputOpen ? t("integration.nodeInspector.output.test.hideInput") : t("integration.nodeInspector.output.test.editInput") }}
1893
1978
  </button>
1894
1979
  <span class="text-xs text-muted-foreground">
1895
- {{ t("integration.nodeInspector.output.test.runHint") }}
1980
+ {{ versionExecutable ? t("integration.nodeInspector.output.test.runHint") : "" }}
1896
1981
  </span>
1897
1982
  </div>
1898
1983
  <Textarea
@@ -1999,7 +2084,7 @@ async function runTestStep() {
1999
2084
  {{ t("integration.common.action.cancel") }}
2000
2085
  </Button>
2001
2086
  <Button size="sm" class="h-8" @click="save">
2002
- {{ t("integration.common.action.save") }}
2087
+ {{ t("integration.nodeInspector.footer.done") }}
2003
2088
  </Button>
2004
2089
  </div>
2005
2090
  </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.
@@ -1,8 +1,9 @@
1
1
  <script setup>
2
2
  import { computed, ref, watch } from "vue";
3
+ import { AltArrowLeft as ArrowLeft, Bolt as Zap, Magnifier as Search } from "#solar-icons";
4
+ import { triggerGlyph } from "../../data/triggerGlyphs";
3
5
  import * as Lucide from "lucide-vue-next";
4
6
  import { INTEGRATION_NODE_CATALOG, NODE_CATEGORIES, TRIGGER_TEMPLATES } from "../../data/integrationNodeCatalog";
5
- import { nodeIcon } from "../../data/nodeIcons";
6
7
  import { resolveLocalized } from "../../utils/localize";
7
8
  const { load: loadNodes } = useIntegrationsNodes();
8
9
  onMounted(() => {
@@ -13,7 +14,6 @@ const emit = defineEmits(["insert"]);
13
14
  const search = ref("");
14
15
  const searchInput = ref(null);
15
16
  defineExpose({ focusSearch: () => searchInput.value?.focus() });
16
- const iconFor = nodeIcon;
17
17
  const CORE_GROUP_BY_CATEGORY = {
18
18
  control: "Flow",
19
19
  transform: "Data",
@@ -76,11 +76,14 @@ function isIntegration(m) {
76
76
  function makeGroup(label) {
77
77
  return { label, children: /* @__PURE__ */ new Map(), leaves: [], count: 0 };
78
78
  }
79
+ function isExecutable(m) {
80
+ return m.__hasBundle !== false;
81
+ }
79
82
  const latestManifests = computed(() => {
80
83
  const seen = /* @__PURE__ */ new Set();
81
84
  const out = [];
82
85
  for (const m of INTEGRATION_NODE_CATALOG) {
83
- if (seen.has(m.slug)) continue;
86
+ if (seen.has(m.slug) || !isExecutable(m)) continue;
84
87
  seen.add(m.slug);
85
88
  out.push(m);
86
89
  }
@@ -89,6 +92,7 @@ const latestManifests = computed(() => {
89
92
  const versionsBySlug = computed(() => {
90
93
  const map = /* @__PURE__ */ new Map();
91
94
  for (const m of INTEGRATION_NODE_CATALOG) {
95
+ if (!isExecutable(m)) continue;
92
96
  const list = map.get(m.slug);
93
97
  if (list) list.push(m);
94
98
  else map.set(m.slug, [m]);
@@ -282,14 +286,14 @@ const searching = computed(() => search.value.trim().length > 0);
282
286
  :title="t('integration.canvas.palette.action.back')"
283
287
  @click="popLevel"
284
288
  >
285
- <Lucide.ArrowLeft :size="14" />
289
+ <ArrowLeft :size="14" />
286
290
  </button>
287
291
  <div class="min-w-0 flex-1 truncate text-mono text-muted-foreground">
288
292
  {{ levelTitle }}
289
293
  </div>
290
294
  </div>
291
295
  <div class="relative">
292
- <Lucide.Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
296
+ <Search :size="12" class="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground" />
293
297
  <input
294
298
  ref="searchInput"
295
299
  v-model="search"
@@ -333,7 +337,7 @@ const searching = computed(() => search.value.trim().length > 0);
333
337
  @click="insertTrigger(tpl)"
334
338
  >
335
339
  <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
336
- <component :is="iconFor(tpl.icon)" :size="13" />
340
+ <component :is="triggerGlyph(tpl.type)" :size="13" />
337
341
  </span>
338
342
  <div class="min-w-0">
339
343
  <div class="truncate text-sm font-medium">{{ resolveLocalized(tpl.name) }}</div>
@@ -397,7 +401,7 @@ const searching = computed(() => search.value.trim().length > 0);
397
401
  @click="insertTrigger(tpl)"
398
402
  >
399
403
  <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-emerald-100 text-emerald-600">
400
- <component :is="iconFor(tpl.icon)" :size="13" />
404
+ <component :is="triggerGlyph(tpl.type)" :size="13" />
401
405
  </span>
402
406
  <div class="min-w-0">
403
407
  <div class="truncate text-sm font-medium">{{ resolveLocalized(tpl.name) }}</div>
@@ -417,7 +421,7 @@ const searching = computed(() => search.value.trim().length > 0);
417
421
  @click="pushPath(['__triggers'])"
418
422
  >
419
423
  <span class="flex size-6 shrink-0 items-center justify-center rounded-md bg-slate-100 text-emerald-600/80">
420
- <Lucide.Zap :size="13" />
424
+ <Zap :size="13" />
421
425
  </span>
422
426
  <span class="min-w-0 flex-1 truncate text-sm font-medium">{{ t("integration.canvas.palette.group.triggers") }}</span>
423
427
  <span class="text-sm tabular-nums text-muted-foreground">{{ TRIGGER_TEMPLATES.length }}</span>
@@ -100,24 +100,58 @@ function onHoverLeave() {
100
100
  }
101
101
  const toolbarVisible = computed(() => editable.value && !editing.value && (hovered.value || !!props.selected));
102
102
  function escapeHtml(s) {
103
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
103
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
104
104
  }
105
105
  function inline(s) {
106
106
  return s.replace(/`([^`\n]+)`/g, '<code class="rounded bg-black/[0.07] px-1 py-px font-mono text-[0.85em]">$1</code>').replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="font-medium underline decoration-current/40 underline-offset-2 hover:decoration-current">$1</a>').replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
107
107
  }
108
+ const NOTE_HEADINGS = [
109
+ { tag: "h2", cls: "m-0 text-base font-semibold" },
110
+ { tag: "h3", cls: "m-0 text-sm font-semibold" },
111
+ { tag: "h4", cls: "m-0 text-sm font-medium" }
112
+ ];
113
+ const HEADING_RE = /^(#{1,3})\s+(.+)$/;
114
+ const UL_RE = /^[-*]\s+(.+)$/;
115
+ const OL_RE = /^(\d+)\.\s+(.+)$/;
116
+ function renderList(tag, items) {
117
+ const li = items.map(({ marker, text }) => {
118
+ const numeric = tag === "ol" ? " tabular-nums" : "";
119
+ const hidden = tag === "ul" ? ' aria-hidden="true"' : "";
120
+ const ordinal = tag === "ol" ? ` value="${Number.parseInt(marker, 10)}"` : "";
121
+ return `<li${ordinal} class="flex gap-1.5"><span${hidden} class="select-none${numeric}">${marker}</span><span class="min-w-0">${text}</span></li>`;
122
+ }).join("");
123
+ return `<${tag} role="list" class="m-0 list-none p-0 pl-1">${li}</${tag}>`;
124
+ }
108
125
  function renderMarkdownLite(src) {
109
- return escapeHtml(src).split("\n").map((line) => {
110
- const h = /^(#{1,3})\s+(.+)$/.exec(line);
126
+ const lines = escapeHtml(src).split("\n");
127
+ const out = [];
128
+ let i = 0;
129
+ while (i < lines.length) {
130
+ const line = lines[i] ?? "";
131
+ const h = HEADING_RE.exec(line);
111
132
  if (h) {
112
- const cls = h[1].length === 1 ? "text-base font-semibold" : h[1].length === 2 ? "text-sm font-semibold" : "text-sm font-medium";
113
- return `<div class="${cls}">${inline(h[2])}</div>`;
133
+ const { tag, cls } = NOTE_HEADINGS[(h[1] ?? "#").length - 1] ?? NOTE_HEADINGS[2];
134
+ out.push(`<${tag} class="${cls}">${inline(h[2] ?? "")}</${tag}>`);
135
+ i++;
136
+ continue;
114
137
  }
115
- const ul = /^[-*]\s+(.+)$/.exec(line);
116
- if (ul) return `<div class="flex gap-1.5 pl-1"><span class="select-none">\u2022</span><span class="min-w-0">${inline(ul[1])}</span></div>`;
117
- const ol = /^(\d+)\.\s+(.+)$/.exec(line);
118
- if (ol) return `<div class="flex gap-1.5 pl-1"><span class="select-none tabular-nums">${ol[1]}.</span><span class="min-w-0">${inline(ol[2])}</span></div>`;
119
- return line ? `<div>${inline(line)}</div>` : "<div>&nbsp;</div>";
120
- }).join("");
138
+ const listRe = UL_RE.test(line) ? UL_RE : OL_RE.test(line) ? OL_RE : null;
139
+ if (listRe) {
140
+ const tag = listRe === UL_RE ? "ul" : "ol";
141
+ const items = [];
142
+ while (i < lines.length) {
143
+ const m = listRe.exec(lines[i] ?? "");
144
+ if (!m) break;
145
+ items.push({ marker: tag === "ul" ? "\u2022" : `${m[1]}.`, text: inline(m[m.length - 1] ?? "") });
146
+ i++;
147
+ }
148
+ out.push(renderList(tag, items));
149
+ continue;
150
+ }
151
+ out.push(line ? `<div>${inline(line)}</div>` : "<div>&nbsp;</div>");
152
+ i++;
153
+ }
154
+ return out.join("");
121
155
  }
122
156
  const rendered = computed(() => renderMarkdownLite(props.data.sticky.content));
123
157
  function onViewMouseDown(e) {
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One shortcut into a surface the *host* provides — Import & Export, the SFTP
3
+ * server, the event stream, inbound webhooks. None of the four is a page of this
4
+ * module: they are the cockpit's, and this card is the studio's landing page
5
+ * pointing at them.
6
+ *
7
+ * So the card has two states, and which one it is in is not its own decision:
8
+ *
9
+ * - **`available`** — the host has the surface. The card is a link and behaves
10
+ * exactly as it did before PO-223.
11
+ * - **not available** — the card stays on screen, looks the same, and is a button
12
+ * that explains where the surface lives instead of leading into a route the host
13
+ * does not serve. Before PO-223 it was a link into nothing: in the playground
14
+ * and in the standalone dev studio all four led to a 404.
15
+ *
16
+ * **Staying on screen is the point**, and it is the call the managed credential
17
+ * sources already made (`CredentialRevenexxSource.vue`, PO-222): a control that
18
+ * vanishes leaves nothing to read the explanation from, and the reader cannot tell
19
+ * a broken feature from one that belongs somewhere else.
20
+ *
21
+ * **No `aria-label`, on purpose** — like `TemplateCard`, the card takes its name
22
+ * from what it shows. The state is carried by the *role*: "Import & Export,
23
+ * button" instead of "link" is what says to a screen reader that this does not
24
+ * navigate, and it says it without announcing something no sighted reader can see
25
+ * (`specs/accessibility.md` AC-1 names the on-screen text as the source of a name).
26
+ *
27
+ * Who decides `available`, and why it is the router rather than a guess about the
28
+ * host, is `docs/host-surfaces.md`.
29
+ *
30
+ * The two branches draw the same body twice rather than sharing it: `as-child`
31
+ * wants a real element as its child, and the alternatives — a wrapper element
32
+ * around the card, or a second component for eight lines — cost either the grid
33
+ * layout or a file. **Keep the two bodies identical.**
34
+ */
35
+ import type { Component } from 'vue';
36
+ type __VLS_Props = {
37
+ /** What the surface is called, in the reader's language. */
38
+ label: string;
39
+ /** One line on what it is for. Shown in both states. */
40
+ hint: string;
41
+ icon: Component;
42
+ /** Where the surface lives in a host that has it. */
43
+ to: string;
44
+ /** Whether this host has it. See the comment above; no default on purpose. */
45
+ available: boolean;
46
+ /** What the popover says when it does not. */
47
+ note: string;
48
+ };
49
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
50
+ export default _default;
@@ -0,0 +1,55 @@
1
+ <script setup>
2
+ import { ArrowRight } from "#solar-icons";
3
+ import { Popover, PopoverContent, PopoverTrigger } from "@revenexx/studio";
4
+ defineProps({
5
+ label: { type: String, required: true },
6
+ hint: { type: String, required: true },
7
+ icon: { type: null, required: true },
8
+ to: { type: String, required: true },
9
+ available: { type: Boolean, required: true },
10
+ note: { type: String, required: true }
11
+ });
12
+ const CARD_CLASS = "group rounded-lg border border-black/[0.06] bg-white p-3 transition hover:border-black/20";
13
+ </script>
14
+
15
+ <template>
16
+ <NuxtLink v-if="available" :to="to" :class="CARD_CLASS">
17
+ <div class="flex items-center gap-2">
18
+ <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
19
+ <component :is="icon" :size="12" />
20
+ </span>
21
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
22
+ <ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
23
+ </div>
24
+ <p class="mt-2 text-sm text-muted-foreground">
25
+ {{ hint }}
26
+ </p>
27
+ </NuxtLink>
28
+
29
+ <Popover v-else>
30
+ <PopoverTrigger as-child>
31
+ <!-- No `id` here: `PopoverTrigger` puts reka-ui's generated `triggerId` on
32
+ its child and `PopoverContentImpl` points the opened dialog's
33
+ `aria-labelledby` at exactly that id, so the popover is named by this
34
+ card. Ours would replace it (reka's `Slot` merges with the child's
35
+ props winning) and leave the dialog naming nothing. -->
36
+ <button type="button" :class="[CARD_CLASS, 'w-full text-left']">
37
+ <div class="flex items-center gap-2">
38
+ <span class="flex size-6 items-center justify-center rounded-md bg-slate-100 text-slate-500">
39
+ <component :is="icon" :size="12" />
40
+ </span>
41
+ <span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ label }}</span>
42
+ <ArrowRight :size="11" class="text-muted-foreground transition-transform group-hover:translate-x-0.5" />
43
+ </div>
44
+ <p class="mt-2 text-sm text-muted-foreground">
45
+ {{ hint }}
46
+ </p>
47
+ </button>
48
+ </PopoverTrigger>
49
+ <PopoverContent align="start" class="w-72 rounded-lg border border-black/[0.06] bg-white p-3 shadow-lg">
50
+ <p class="text-sm text-muted-foreground">
51
+ {{ note }}
52
+ </p>
53
+ </PopoverContent>
54
+ </Popover>
55
+ </template>
@@ -0,0 +1,50 @@
1
+ /**
2
+ * One shortcut into a surface the *host* provides — Import & Export, the SFTP
3
+ * server, the event stream, inbound webhooks. None of the four is a page of this
4
+ * module: they are the cockpit's, and this card is the studio's landing page
5
+ * pointing at them.
6
+ *
7
+ * So the card has two states, and which one it is in is not its own decision:
8
+ *
9
+ * - **`available`** — the host has the surface. The card is a link and behaves
10
+ * exactly as it did before PO-223.
11
+ * - **not available** — the card stays on screen, looks the same, and is a button
12
+ * that explains where the surface lives instead of leading into a route the host
13
+ * does not serve. Before PO-223 it was a link into nothing: in the playground
14
+ * and in the standalone dev studio all four led to a 404.
15
+ *
16
+ * **Staying on screen is the point**, and it is the call the managed credential
17
+ * sources already made (`CredentialRevenexxSource.vue`, PO-222): a control that
18
+ * vanishes leaves nothing to read the explanation from, and the reader cannot tell
19
+ * a broken feature from one that belongs somewhere else.
20
+ *
21
+ * **No `aria-label`, on purpose** — like `TemplateCard`, the card takes its name
22
+ * from what it shows. The state is carried by the *role*: "Import & Export,
23
+ * button" instead of "link" is what says to a screen reader that this does not
24
+ * navigate, and it says it without announcing something no sighted reader can see
25
+ * (`specs/accessibility.md` AC-1 names the on-screen text as the source of a name).
26
+ *
27
+ * Who decides `available`, and why it is the router rather than a guess about the
28
+ * host, is `docs/host-surfaces.md`.
29
+ *
30
+ * The two branches draw the same body twice rather than sharing it: `as-child`
31
+ * wants a real element as its child, and the alternatives — a wrapper element
32
+ * around the card, or a second component for eight lines — cost either the grid
33
+ * layout or a file. **Keep the two bodies identical.**
34
+ */
35
+ import type { Component } from 'vue';
36
+ type __VLS_Props = {
37
+ /** What the surface is called, in the reader's language. */
38
+ label: string;
39
+ /** One line on what it is for. Shown in both states. */
40
+ hint: string;
41
+ icon: Component;
42
+ /** Where the surface lives in a host that has it. */
43
+ to: string;
44
+ /** Whether this host has it. See the comment above; no default on purpose. */
45
+ available: boolean;
46
+ /** What the popover says when it does not. */
47
+ note: string;
48
+ };
49
+ declare const _default: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
50
+ export default _default;