@revenexx/studio-integrations 2.7.0 → 2.8.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 (50) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +2 -1
  4. package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +2 -1
  5. package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +2 -1
  6. package/dist/runtime/components/Integrations/NodeInspector.vue +11 -7
  7. package/dist/runtime/components/Integrations/NodeMark.vue +1 -1
  8. package/dist/runtime/components/Integrations/NodePalette.vue +8 -1
  9. package/dist/runtime/components/Integrations/SftpAccountImportDialog.vue +2 -1
  10. package/dist/runtime/components/Integrations/StateNamespaceCreateDialog.vue +2 -1
  11. package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +1 -1
  12. package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +2 -1
  13. package/dist/runtime/components/Integrations/WorkflowDuplicateDialog.vue +2 -1
  14. package/dist/runtime/components/Integrations/WorkflowEditor.vue +5 -2
  15. package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +21 -2
  16. package/dist/runtime/components/Integrations/WorkflowIconView.vue +1 -1
  17. package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +5 -1
  18. package/dist/runtime/components/Integrations/WorkflowStateNamespacesDialog.vue +5 -1
  19. package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +5 -1
  20. package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +5 -1
  21. package/dist/runtime/composables/useIntegrationsAudit.d.ts +3 -0
  22. package/dist/runtime/composables/useIntegrationsNodes.d.ts +12 -1
  23. package/dist/runtime/composables/useIntegrationsNodes.js +24 -5
  24. package/dist/runtime/composables/useIntegrationsRevisions.d.ts +6 -0
  25. package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
  26. package/dist/runtime/composables/useIntegrationsTemplates.js +25 -16
  27. package/dist/runtime/composables/useIntegrationsWorkflows.js +4 -1
  28. package/dist/runtime/data/eventTrigger.d.ts +22 -0
  29. package/dist/runtime/data/eventTrigger.js +18 -0
  30. package/dist/runtime/data/integrationNodeCatalog.js +1 -1
  31. package/dist/runtime/i18n/messages/de.json +3 -0
  32. package/dist/runtime/i18n/messages/en.json +3 -0
  33. package/dist/runtime/pages/integrations/audit.vue +23 -3
  34. package/dist/runtime/pages/integrations/credentials.vue +2 -1
  35. package/dist/runtime/pages/integrations/secrets.vue +2 -1
  36. package/dist/runtime/pages/integrations/templates/[slug].vue +3 -2
  37. package/dist/runtime/pages/integrations/templates/index.vue +1 -1
  38. package/dist/runtime/pages/integrations/workflows/[id].vue +4 -3
  39. package/dist/runtime/plugins/realtime.client.d.ts +15 -13
  40. package/dist/runtime/plugins/realtime.client.js +0 -1
  41. package/dist/runtime/types/integrations.d.ts +16 -0
  42. package/dist/runtime/utils/detailLoader.d.ts +52 -0
  43. package/dist/runtime/utils/detailLoader.js +65 -0
  44. package/dist/runtime/utils/dialog.d.ts +20 -0
  45. package/dist/runtime/utils/dialog.js +3 -0
  46. package/dist/runtime/utils/nodeCatalogLevel.d.ts +18 -0
  47. package/dist/runtime/utils/nodeCatalogLevel.js +7 -0
  48. package/dist/runtime/utils/personLabel.d.ts +18 -0
  49. package/dist/runtime/utils/personLabel.js +6 -0
  50. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # @revenexx/studio-integrations
2
2
 
3
+ ## 2.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - f7ae810: The dashboard, the template gallery, a template's own page and the workflow icons read a slim node catalogue (`GET /nodes?fields=summary`) instead of every manifest in full; the editor still reads the full catalogue and upgrades to it when a summary is in hand. Template previews read their definitions in one request (`GET /templates?include=definition&slugs=…`), and two surfaces asking for the same template at once share one request. Against an Integrations API that predates either parameter the studio falls back to what it did before: the full catalogue, and one request per template, four at a time (PE-990).
8
+
9
+ The studio no longer subscribes its eleven realtime channels at app boot: the first Integration Studio surface that needs them (the runs list, a workflow, a run panel, the workflows list) takes them and keeps them for the rest of the tab. A host page outside the studio no longer mints eleven subscription tokens on every load (PE-990).
10
+
11
+ - 25755bc: The Audit Log names whoever made a change by name, with their e-mail beside it, and the search finds them by either; the entry's panel keeps their identifier for tracing. A workflow's History names whoever saved each version, the current one included, and dates each by when it was saved rather than by when a later save replaced it. Entries and versions from before the platform recorded names show the identifier, or nobody, as before. Needs an Integrations API that sends `actor_name`/`actor_email`, `saved_by_*` and `saved_at` (revenexx/integrations#344); against an older one the page looks as it did (PO-609).
12
+
13
+ ### Patch Changes
14
+
15
+ - 1772593: A workflow with an event trigger can be saved again. The studio stored the chosen event as `event_type`, which the platform does not read, so every such save was refused with "The event subject is required for event triggers." The event is now stored as the trigger's `subject`; a trigger still carrying `event_type` is read and sent as its subject (PO-641).
16
+ - c3ea68f: A click beside a dialog with an entry in it no longer closes it. Every dialog in the studio where something is typed or chosen took a click on the dimmed page as Cancel — a node's or a trigger's configuration, the workflow's settings, variables and state namespaces, the run dialog, declaring a state namespace, a new endpoint secret, adding, editing or opening a credential, a new revenexx key, importing an SFTP account, a new secret, a new or duplicated workflow, and using a template. A host draws its messages under that dimming, so dismissing the message after "Test step" threw a node's settings away before Done (PO-648). Cancel, Escape and the ✕ still close at once, without asking. Dialogs that only show something — the canvas shortcuts, a run's response, a timeline span — still close on a click beside them.
17
+ - 406f9db: A search at the root of the node palette lists its hits in the order the folders show them — folder by folder, and inside each folder in the order the node's package gives — instead of by node identifier (PO-661).
18
+
3
19
  ## 2.7.0
4
20
 
5
21
  ### 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.7.0",
4
+ "version": "2.8.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
@@ -22,6 +22,7 @@ import { toast } from "vue-sonner";
22
22
  import { resolveLocalized } from "../../utils/localize";
23
23
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
24
24
  import { errorReason } from "../../utils/apiError";
25
+ import { keepOpenBeside } from "../../utils/dialog";
25
26
  const props = defineProps({
26
27
  open: { type: Boolean, required: true },
27
28
  credentialType: { type: [String, Array], required: false }
@@ -136,7 +137,7 @@ async function submit() {
136
137
 
137
138
  <template>
138
139
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
139
- <DialogContent class="sm:max-w-lg">
140
+ <DialogContent class="sm:max-w-lg" @interact-outside="keepOpenBeside">
140
141
  <DialogHeader>
141
142
  <DialogTitle>{{ t("integration.credentials.shared.dialog.title.create") }}</DialogTitle>
142
143
  <DialogDescription class="text-sm">
@@ -38,6 +38,7 @@ import { useNow } from "../../composables/useNow";
38
38
  import { hasRevenexxSource } from "../../utils/revenexxCredentialSource";
39
39
  import { resolveLocalized } from "../../utils/localize";
40
40
  import { fromModelValue, toItemValue, toModelValue } from "../../utils/selectOptions";
41
+ import { keepOpenBeside } from "../../utils/dialog";
41
42
  const props = defineProps({
42
43
  open: { type: Boolean, required: true },
43
44
  credential: { type: [Object, null], required: true },
@@ -207,7 +208,7 @@ async function connect() {
207
208
 
208
209
  <template>
209
210
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
210
- <DialogContent class="sm:max-w-3xl">
211
+ <DialogContent class="sm:max-w-3xl" @interact-outside="keepOpenBeside">
211
212
  <DialogHeader>
212
213
  <div class="flex items-start gap-3">
213
214
  <IntegrationsCredentialLogo :type="type" :size="22" />
@@ -26,6 +26,7 @@ import { toast } from "vue-sonner";
26
26
  import { REVENEXX_SOURCE_KIND_BY_SLUG } from "../../utils/revenexxCredentialSource";
27
27
  import { cockpitSources } from "../../utils/cockpitSources";
28
28
  import { errorReason } from "../../utils/apiError";
29
+ import { keepOpenBeside } from "../../utils/dialog";
29
30
  const props = defineProps({
30
31
  type: { type: [Object, null], required: true }
31
32
  });
@@ -371,7 +372,7 @@ function submitCreate() {
371
372
 
372
373
  <!-- Stacked "New …" lightbox -->
373
374
  <Dialog v-model:open="createOpen">
374
- <DialogContent class="sm:max-w-md">
375
+ <DialogContent class="sm:max-w-md" @interact-outside="keepOpenBeside">
375
376
  <DialogHeader>
376
377
  <DialogTitle>{{ kind ? NEW_LABEL[kind] : t("integration.credentials.revenexxSource.new.fallback") }}</DialogTitle>
377
378
  <DialogDescription class="text-sm">
@@ -45,6 +45,7 @@ import { deepClone } from "../../utils/deepClone";
45
45
  import { copyToClipboard } from "../../utils/clipboard";
46
46
  import { nodeDisplayLabel } from "../../utils/validationFindings";
47
47
  import { authChoiceFromConfig, configPatchForAuthChoice } from "../../data/webhookAuth";
48
+ import { eventSubject, eventTopicOf, withEventSubject } from "../../data/eventTrigger";
48
49
  import {
49
50
  dataTypeOf,
50
51
  portValuesFromStepOutput,
@@ -55,6 +56,7 @@ import { resolveDisplayName, resolveLocalized } from "../../utils/localize";
55
56
  import { nodeDisplayName } from "../../utils/nodeDisplayName";
56
57
  import { describeRecurrence, parseCron, scheduleTimezone, toCron } from "../../utils/schedule";
57
58
  import { errorReason } from "../../utils/apiError";
59
+ import { keepOpenBeside } from "../../utils/dialog";
58
60
  const t = useIntegrationsT();
59
61
  const props = defineProps({
60
62
  selection: { type: [Object, null], required: true },
@@ -446,6 +448,11 @@ function writeTriggerConfigPatch(patch) {
446
448
  if (!wt) return;
447
449
  patchTrigger({ config: { ...wt.config, ...patch } });
448
450
  }
451
+ function writeEventSubject(subject) {
452
+ const wt = draftTrigger.value;
453
+ if (!wt) return;
454
+ patchTrigger({ config: withEventSubject(wt.config, subject) });
455
+ }
449
456
  function applyAuthChoice(v) {
450
457
  writeTriggerConfigPatch(configPatchForAuthChoice(v));
451
458
  }
@@ -494,10 +501,7 @@ async function copyWebhookUrl() {
494
501
  }
495
502
  const definedEvents = typeof useDefinedEvents === "function" ? useDefinedEvents() : { emits: ref([]), load: async () => {
496
503
  } };
497
- const eventTopic = computed(() => {
498
- const raw = readTriggerConfig("event_type");
499
- return typeof raw === "string" && raw.trim() !== "" ? raw.trim() : null;
500
- });
504
+ const eventTopic = computed(() => eventTopicOf(draftTrigger.value?.config));
501
505
  watch(eventTopic, (topic) => {
502
506
  if (topic) void definedEvents.load();
503
507
  }, { immediate: true });
@@ -1687,7 +1691,7 @@ async function runTestStep() {
1687
1691
  </div>
1688
1692
 
1689
1693
  <Dialog v-model:open="rotateOpen">
1690
- <DialogContent class="sm:max-w-md">
1694
+ <DialogContent class="sm:max-w-md" @interact-outside="keepOpenBeside">
1691
1695
  <DialogHeader>
1692
1696
  <DialogTitle>{{ t("integration.nodeInspector.trigger.webhook.rotate.dialogTitle") }}</DialogTitle>
1693
1697
  <DialogDescription>
@@ -1720,8 +1724,8 @@ async function runTestStep() {
1720
1724
  <Label for="ni-event-type" class="text-mono text-muted-foreground">{{ t("integration.nodeInspector.trigger.event.label") }}</Label>
1721
1725
  <EventTypeCombobox
1722
1726
  input-id="ni-event-type"
1723
- :model-value="readTriggerConfig('event_type') ?? ''"
1724
- @update:model-value="(v) => writeTriggerConfig('event_type', v)"
1727
+ :model-value="eventSubject(draftTrigger?.config) ?? ''"
1728
+ @update:model-value="(v) => writeEventSubject(v)"
1725
1729
  />
1726
1730
  <p class="text-xs text-muted-foreground">
1727
1731
  {{ t("integration.nodeInspector.trigger.event.hint.intro") }} <code class="font-mono">customer.created</code>, <code class="font-mono">order.placed</code>{{ t("integration.nodeInspector.trigger.event.hint.mid") }} <code class="font-mono">trigger.payload</code>.
@@ -17,7 +17,7 @@ watch(() => props.manifest?.slug, () => {
17
17
  function onImageError() {
18
18
  if (!refreshed.value) {
19
19
  refreshed.value = true;
20
- void reloadCatalog(true);
20
+ void reloadCatalog(true, { summary: true });
21
21
  return;
22
22
  }
23
23
  failed.value = true;
@@ -164,6 +164,13 @@ function subtreeManifests(g) {
164
164
  for (const child of g.children.values()) out.push(...subtreeManifests(child));
165
165
  return out;
166
166
  }
167
+ function inBrowseOrder(g) {
168
+ const out = [];
169
+ for (const child of g.children.values()) out.push(...inBrowseOrder(child));
170
+ out.push(...g.leaves);
171
+ return out;
172
+ }
173
+ const rootBrowseOrder = computed(() => [...inBrowseOrder(tree.value.core), ...inBrowseOrder(tree.value.apps)]);
167
174
  function groupLogoManifest(g) {
168
175
  const all = subtreeManifests(g);
169
176
  return all.find(hasLogo) ?? all.find((m) => (m.images ?? []).length > 0) ?? null;
@@ -207,7 +214,7 @@ const levelKey = computed(() => stack.value.length);
207
214
  const searchHits = computed(() => {
208
215
  const q = search.value.trim().toLowerCase();
209
216
  if (!q || triggersLevel.value) return [];
210
- const scope = currentGroup.value ? subtreeManifests(currentGroup.value) : latestManifests.value.filter((m) => m.category !== "trigger");
217
+ const scope = currentGroup.value ? inBrowseOrder(currentGroup.value) : rootBrowseOrder.value;
211
218
  const hits = [];
212
219
  for (const m of scope) {
213
220
  const hay = `${resolveLocalized(m.name)} ${m.slug} ${resolveLocalized(m.description)}`.toLowerCase();
@@ -4,6 +4,7 @@ import { RefreshCircle as Loader2 } from "#solar-icons";
4
4
  import { Badge, Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@revenexx/studio";
5
5
  import LoadingAnnouncement from "./LoadingAnnouncement.vue";
6
6
  import { useIntegrationsCredentials } from "../../composables/useIntegrationsCredentials";
7
+ import { keepOpenBeside } from "../../utils/dialog";
7
8
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
8
9
  const emit = defineEmits(["created"]);
9
10
  const t = useIntegrationsT();
@@ -68,7 +69,7 @@ async function importAccount(account) {
68
69
 
69
70
  <template>
70
71
  <Dialog v-model:open="open">
71
- <DialogContent class="sm:max-w-md">
72
+ <DialogContent class="sm:max-w-md" @interact-outside="keepOpenBeside">
72
73
  <DialogHeader>
73
74
  <DialogTitle>{{ t("integration.credentials.sftpImport.title") }}</DialogTitle>
74
75
  <DialogDescription class="text-sm">
@@ -17,6 +17,7 @@ import {
17
17
  SelectValue
18
18
  } from "@revenexx/studio";
19
19
  import { stateNamespaceErrorKey } from "../../utils/stateNamespaceName";
20
+ import { keepOpenBeside } from "../../utils/dialog";
20
21
  const props = defineProps({
21
22
  open: { type: Boolean, required: true },
22
23
  role: { type: String, required: true },
@@ -45,7 +46,7 @@ function declare() {
45
46
 
46
47
  <template>
47
48
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
48
- <DialogContent class="max-w-[94vw] sm:max-w-lg">
49
+ <DialogContent class="max-w-[94vw] sm:max-w-lg" @interact-outside="keepOpenBeside">
49
50
  <DialogHeader>
50
51
  <DialogTitle>{{ t("integration.nodeConfig.state.create.title") }}</DialogTitle>
51
52
  <DialogDescription class="text-sm">
@@ -15,7 +15,7 @@ const props = defineProps({
15
15
  const t = useIntegrationsT();
16
16
  const { load: loadNodeCatalog } = useIntegrationsNodes();
17
17
  onMounted(() => {
18
- void loadNodeCatalog();
18
+ void loadNodeCatalog(false, { summary: true });
19
19
  });
20
20
  const STOP_SLUG = "revenexx:stop-and-error";
21
21
  const NODE_W = 220;
@@ -27,6 +27,7 @@ import WorkflowIconPicker from "./WorkflowIconPicker.vue";
27
27
  import WorkflowTagsInput from "./WorkflowTagsInput.vue";
28
28
  import { resolveLocalized } from "../../utils/localize";
29
29
  import { errorReason } from "../../utils/apiError";
30
+ import { keepOpenBeside } from "../../utils/dialog";
30
31
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
31
32
  const t = useIntegrationsT();
32
33
  const router = useRouter();
@@ -136,7 +137,7 @@ async function installed(id) {
136
137
 
137
138
  <template>
138
139
  <Dialog v-model:open="open">
139
- <DialogContent class="sm:max-w-4xl">
140
+ <DialogContent class="sm:max-w-4xl" @interact-outside="keepOpenBeside">
140
141
  <DialogHeader>
141
142
  <DialogTitle>{{ t("integration.workflows.create.dialog.title") }}</DialogTitle>
142
143
  </DialogHeader>
@@ -4,6 +4,7 @@ import { RefreshCircle as Loader2 } from "#solar-icons";
4
4
  import { Button, Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, Input } from "@revenexx/studio";
5
5
  import { useIntegrationsWorkflows } from "../../composables/useIntegrationsWorkflows";
6
6
  import { errorReason } from "../../utils/apiError";
7
+ import { keepOpenBeside } from "../../utils/dialog";
7
8
  const open = defineModel("open", { type: Boolean, ...{ default: false } });
8
9
  const props = defineProps({
9
10
  workflow: { type: [Object, null], required: true }
@@ -39,7 +40,7 @@ async function submit() {
39
40
 
40
41
  <template>
41
42
  <Dialog v-model:open="open">
42
- <DialogContent class="sm:max-w-md">
43
+ <DialogContent class="sm:max-w-md" @interact-outside="keepOpenBeside">
43
44
  <DialogHeader>
44
45
  <DialogTitle>{{ t("integration.workflows.duplicate.dialog.title") }}</DialogTitle>
45
46
  <DialogDescription class="text-sm">
@@ -60,6 +60,7 @@ import { groupFindingsByNode, findingsByNodeField } from "../../utils/validation
60
60
  import { reorderBranchSubtrees } from "../../data/branchReorder";
61
61
  import { resolveLocalized } from "../../utils/localize";
62
62
  import { nodeDisplayName } from "../../utils/nodeDisplayName";
63
+ import { keepOpenBeside } from "../../utils/dialog";
63
64
  const props = defineProps({
64
65
  workflow: { type: Object, required: true },
65
66
  draft: { type: [Object, null], required: false },
@@ -1936,8 +1937,9 @@ const edgeTypes = {
1936
1937
  <CanvasShortcutsDialog v-model:open="shortcutsOpen" />
1937
1938
 
1938
1939
  <!-- Node/trigger configuration — a near-fullscreen workbench lightbox
1939
- (Input pane / Parameters+Settings tabs / Save+Cancel footer). The
1940
- inspector edits a draft; closing without Save discards it. -->
1940
+ (Input pane / Parameters+Settings tabs / Done+Cancel footer). The
1941
+ inspector edits a draft; closing without Done discards it, and a click
1942
+ beside it does not close it (dialog-dismissal AC-1). -->
1941
1943
  <Dialog :open="!!selection" @update:open="(v) => {
1942
1944
  if (!v) {
1943
1945
  selectedId = null;
@@ -1960,6 +1962,7 @@ const edgeTypes = {
1960
1962
  class="flex max-w-none flex-col gap-0 overflow-hidden rounded-lg p-0 sm:max-w-none"
1961
1963
  :class="selectionIsTrigger ? 'h-[min(520px,86vh)] w-[min(600px,94vw)]' : 'h-[min(680px,86vh)] w-[min(1100px,94vw)]'"
1962
1964
  @open-auto-focus="onConfigOpenAutoFocus"
1965
+ @interact-outside="keepOpenBeside"
1963
1966
  >
1964
1967
  <NodeInspector
1965
1968
  v-if="selection"
@@ -24,6 +24,7 @@ import { inputHandleId, outputHandleId } from "../../data/handleId";
24
24
  import { iteratorNodeIds, nodeLoopData } from "../../data/loopBody";
25
25
  import { useIntegrationsRevisions } from "../../composables/useIntegrationsRevisions";
26
26
  import { errorReason } from "../../utils/apiError";
27
+ import { personLabel } from "../../utils/personLabel";
27
28
  const props = defineProps({
28
29
  workflow: { type: Object, required: true },
29
30
  draftPending: { type: Boolean, required: false }
@@ -53,6 +54,9 @@ async function loadList(reset = false) {
53
54
  listLoading.value = false;
54
55
  }
55
56
  }
57
+ function revisionAuthor(rev) {
58
+ return { sub: rev.saved_by_sub, name: rev.saved_by_name, email: rev.saved_by_email };
59
+ }
56
60
  function loadMore() {
57
61
  if (page.value >= lastPage.value) return;
58
62
  page.value += 1;
@@ -348,7 +352,11 @@ onMounted(() => {
348
352
  @click="select(null)"
349
353
  >
350
354
  <div class="flex items-center justify-between gap-2">
351
- <span class="text-sm font-medium tabular-nums">{{ formatDateTime(workflow.updated_at) }}</span>
355
+ <!-- Dated by when this version was saved, beside whoever saved it
356
+ (PO-609): `updated_at` also moves on a rename, which writes no
357
+ version. It stays as the fallback for a version from before the
358
+ moment was recorded. -->
359
+ <span class="text-sm font-medium tabular-nums">{{ formatDateTime(workflow.savedAt ?? workflow.updated_at) }}</span>
352
360
  <Badge variant="outline" class="text-xs font-medium uppercase border-emerald-500/30 text-emerald-700">{{ t("integration.history.badge.current") }}</Badge>
353
361
  <Badge
354
362
  variant="outline"
@@ -360,6 +368,11 @@ onMounted(() => {
360
368
  </Badge>
361
369
  </div>
362
370
  <div class="mt-0.5 text-sm text-muted-foreground">{{ t("integration.history.list.revisionLabel", { revision: workflow.revision }) }}</div>
371
+ <!-- Nobody recorded — a platform write, or a version from before
372
+ authors were — says nothing rather than guessing. -->
373
+ <div v-if="personLabel(workflow.savedBy)" class="mt-0.5 truncate text-sm text-muted-foreground" :title="workflow.savedBy?.name ? workflow.savedBy.email ?? void 0 : void 0">
374
+ {{ t("integration.history.list.savedBy", { name: personLabel(workflow.savedBy) }) }}
375
+ </div>
363
376
  </button>
364
377
 
365
378
  <!-- Archived revisions -->
@@ -371,9 +384,15 @@ onMounted(() => {
371
384
  >
372
385
  <button type="button" class="w-full p-2.5 text-left" @click="select(rev.revision)">
373
386
  <div class="flex items-center justify-between gap-2">
374
- <span class="text-sm font-medium tabular-nums">{{ formatDateTime(rev.archived_at) }}</span>
387
+ <!-- Not `archived_at` first: that is when the NEXT save replaced
388
+ this version, and beside its author's name it would name the
389
+ wrong person for that moment. -->
390
+ <span class="text-sm font-medium tabular-nums">{{ formatDateTime(rev.saved_at ?? rev.archived_at) }}</span>
375
391
  </div>
376
392
  <div class="mt-0.5 text-sm text-muted-foreground">{{ t("integration.history.list.revisionLabel", { revision: rev.revision }) }}</div>
393
+ <div v-if="personLabel(revisionAuthor(rev))" class="mt-0.5 truncate text-sm text-muted-foreground" :title="rev.saved_by_name ? rev.saved_by_email ?? void 0 : void 0">
394
+ {{ t("integration.history.list.savedBy", { name: personLabel(revisionAuthor(rev)) }) }}
395
+ </div>
377
396
  </button>
378
397
  <div class="absolute right-2 top-1/2 -translate-y-1/2 opacity-0 transition group-hover:opacity-100">
379
398
  <Button
@@ -8,7 +8,7 @@ const props = defineProps({
8
8
  });
9
9
  const { load } = useIntegrationsNodes();
10
10
  onMounted(() => {
11
- if (props.icon?.kind === "image") void load();
11
+ if (props.icon?.kind === "image") void load(false, { summary: true });
12
12
  });
13
13
  const resolvedSrc = computed(() => {
14
14
  if (props.icon?.kind !== "image" || !props.icon.src) return null;
@@ -19,6 +19,7 @@ import {
19
19
  } from "@revenexx/studio";
20
20
  import WorkflowIconPicker from "./WorkflowIconPicker.vue";
21
21
  import WorkflowTagsInput from "./WorkflowTagsInput.vue";
22
+ import { keepOpenBeside } from "../../utils/dialog";
22
23
  const props = defineProps({
23
24
  open: { type: Boolean, required: true },
24
25
  workflow: { type: Object, required: true },
@@ -64,7 +65,10 @@ function formatDateTime(iso) {
64
65
 
65
66
  <template>
66
67
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
67
- <DialogContent class="sm:max-w-lg">
68
+ <DialogContent
69
+ class="sm:max-w-lg"
70
+ @interact-outside="keepOpenBeside"
71
+ >
68
72
  <DialogHeader>
69
73
  <DialogTitle>{{ t("integration.workflows.settings.dialog.title") }}</DialogTitle>
70
74
  <DialogDescription class="text-sm">
@@ -19,6 +19,7 @@ import {
19
19
  } from "@revenexx/studio";
20
20
  import { deepClone } from "../../utils/deepClone";
21
21
  import { stateNamespaceErrorKey } from "../../utils/stateNamespaceName";
22
+ import { keepOpenBeside } from "../../utils/dialog";
22
23
  const props = defineProps({
23
24
  open: { type: Boolean, required: true },
24
25
  state: { type: Array, required: true }
@@ -66,7 +67,10 @@ function addNamespace() {
66
67
 
67
68
  <template>
68
69
  <Dialog :open="open" @update:open="(v) => emit('update:open', v)">
69
- <DialogContent class="max-w-[94vw] sm:max-w-3xl">
70
+ <DialogContent
71
+ class="max-w-[94vw] sm:max-w-3xl"
72
+ @interact-outside="keepOpenBeside"
73
+ >
70
74
  <DialogHeader>
71
75
  <DialogTitle>{{ t("integration.workflows.state.dialog.title") }}</DialogTitle>
72
76
  <DialogDescription class="text-sm">
@@ -9,6 +9,7 @@ import { triggerGlyph as glyphForTrigger } from "../../data/triggerGlyphs";
9
9
  import { resolveDisplayName } from "../../utils/localize";
10
10
  import { NODE_CATEGORIES, triggerTypeLabel } from "../../data/integrationNodeCatalog";
11
11
  import { describeCron } from "../../utils/schedule";
12
+ import { eventSubject } from "../../data/eventTrigger";
12
13
  const props = defineProps({
13
14
  id: { type: String, required: true },
14
15
  data: { type: Object, required: true },
@@ -23,7 +24,10 @@ const subtitle = computed(() => {
23
24
  const c = props.data.config;
24
25
  if (props.data.type === "schedule") return describeCron(c.cron) ?? c.cron ?? t("integration.canvas.trigger.subtitle.noSchedule");
25
26
  if (props.data.type === "webhook") return `${c.method ?? "POST"} ${c.path ?? "/"}`;
26
- if (props.data.type === "event") return c.event_type ? t("integration.canvas.trigger.subtitle.onEvent", { event: c.event_type }) : t("integration.canvas.trigger.subtitle.noEvent");
27
+ if (props.data.type === "event") {
28
+ const event = eventSubject(c);
29
+ return event ? t("integration.canvas.trigger.subtitle.onEvent", { event }) : t("integration.canvas.trigger.subtitle.noEvent");
30
+ }
27
31
  return t("integration.canvas.trigger.subtitle.onDemand");
28
32
  });
29
33
  const title = computed(() => {
@@ -21,6 +21,7 @@ import {
21
21
  import { deepClone } from "../../utils/deepClone";
22
22
  import { copyToClipboard } from "../../utils/clipboard";
23
23
  import { languageKeyRead, languageKeyToWrite, resolveDisplayName } from "../../utils/localize";
24
+ import { keepOpenBeside } from "../../utils/dialog";
24
25
  const props = defineProps({
25
26
  open: { type: Boolean, required: true },
26
27
  variables: { type: Object, required: true }
@@ -156,7 +157,10 @@ async function copyUsage(key) {
156
157
 
157
158
  <template>
158
159
  <Dialog :open="open" @update:open="(v2) => emit('update:open', v2)">
159
- <DialogContent class="max-w-[94vw] sm:max-w-3xl">
160
+ <DialogContent
161
+ class="max-w-[94vw] sm:max-w-3xl"
162
+ @interact-outside="keepOpenBeside"
163
+ >
160
164
  <DialogHeader>
161
165
  <DialogTitle>{{ t("integration.workflows.variables.dialog.title") }}</DialogTitle>
162
166
  <DialogDescription class="text-sm">
@@ -5,6 +5,9 @@ export interface AuditEntry {
5
5
  org_id?: string;
6
6
  tenant_id?: string;
7
7
  actor_sub?: string | null;
8
+ /** As the actor's sign-in stated it when the entry was written; null for the platform, and for entries from before names were recorded (PO-609). */
9
+ actor_name?: string | null;
10
+ actor_email?: string | null;
8
11
  action: string;
9
12
  resource_type: string;
10
13
  resource_id?: string;
@@ -1,10 +1,21 @@
1
1
  import { findManifest } from '../data/integrationNodeCatalog.js';
2
2
  import { resolveLocalized } from '../utils/localize.js';
3
+ export interface NodeCatalogLoadOptions {
4
+ /**
5
+ * Enough to name, draw and group a node — no settings schemas (PE-990). For the
6
+ * surfaces that only show nodes: the dashboard, the template gallery and pages,
7
+ * a workflow's icon. The editor asks without it and gets every manifest whole; a
8
+ * summary is never asked for once the full catalogue is in, so it cannot replace
9
+ * what the editor reads.
10
+ */
11
+ summary?: boolean;
12
+ }
3
13
  export declare function useIntegrationsNodes(): {
4
14
  loading: any;
5
15
  error: any;
6
16
  loaded: any;
7
- load: (force?: boolean) => Promise<void>;
17
+ level: any;
18
+ load: (force?: boolean, opts?: NodeCatalogLoadOptions) => Promise<void>;
8
19
  findManifest: typeof findManifest;
9
20
  resolveLocalized: typeof resolveLocalized;
10
21
  };
@@ -5,21 +5,38 @@ import { useIntegrationsT } from "./useIntegrationsT.js";
5
5
  import { errorReason } from "../utils/apiError.js";
6
6
  import { unwrapList } from "../utils/apiEnvelope.js";
7
7
  import { useLoadGate } from "./useLoadGate.js";
8
+ import { whenIdle } from "../utils/whenIdle.js";
9
+ import { catalogNeedsLoad, catalogRequest } from "../utils/nodeCatalogLevel.js";
8
10
  const RETRY_COOLDOWN_MS = 3e4;
9
11
  export function useIntegrationsNodes() {
10
12
  const loading = useState("integrations.nodes.loading", () => false);
11
13
  const error = useState("integrations.nodes.error", () => null);
12
14
  const loaded = useState("integrations.nodes.loaded", () => false);
15
+ const level = useState("integrations.nodes.level", () => null);
13
16
  const failedAt = useState("integrations.nodes.failedAt", () => 0);
14
17
  const gate = useLoadGate("integrations.nodes", { loading, loaded });
15
18
  const api = useIntegrationsApi();
16
19
  const t = useIntegrationsT();
17
- async function load(force = false) {
18
- if (!force && failedAt.value && Date.now() - failedAt.value < RETRY_COOLDOWN_MS) return;
19
- if (!await gate.claim(force)) return;
20
+ async function load(force = false, opts = {}) {
21
+ const wanted = opts.summary ? "summary" : "full";
22
+ const coolingDown = () => !!failedAt.value && Date.now() - failedAt.value < RETRY_COOLDOWN_MS;
23
+ if (!force && coolingDown()) return;
24
+ if (!force) {
25
+ while (loading.value) await whenIdle(loading);
26
+ if (coolingDown()) return;
27
+ if (!catalogNeedsLoad(loaded.value ? level.value : null, wanted)) return;
28
+ }
29
+ if (!await gate.claim(force || loaded.value)) return;
20
30
  error.value = null;
31
+ const request = catalogRequest(loaded.value ? level.value : null, wanted);
32
+ if (request === "full" && level.value === "summary") {
33
+ applyNodeCatalog([]);
34
+ loaded.value = false;
35
+ level.value = null;
36
+ }
21
37
  try {
22
- const list = unwrapList(await api.get("/nodes"));
38
+ const res = await api.get(request === "summary" ? "/nodes?fields=summary" : "/nodes");
39
+ const list = unwrapList(res);
23
40
  const manifests = list.filter((n) => n.manifest).map((n) => ({
24
41
  ...n.manifest,
25
42
  slug: n.manifest.slug ?? n.slug,
@@ -40,10 +57,12 @@ export function useIntegrationsNodes() {
40
57
  __packagePosition: typeof n.package_position === "number" ? n.package_position : void 0
41
58
  }));
42
59
  applyNodeCatalog(manifests);
60
+ level.value = request === "summary" && res?.fields === "summary" ? "summary" : "full";
43
61
  loaded.value = true;
44
62
  failedAt.value = 0;
45
63
  } catch (e) {
46
64
  applyNodeCatalog([]);
65
+ level.value = null;
47
66
  loaded.value = false;
48
67
  failedAt.value = Date.now();
49
68
  error.value = errorReason(e, t("integration.nodes.error.loadFailed"));
@@ -51,5 +70,5 @@ export function useIntegrationsNodes() {
51
70
  gate.release();
52
71
  }
53
72
  }
54
- return { loading, error, loaded, load, findManifest, resolveLocalized };
73
+ return { loading, error, loaded, level, load, findManifest, resolveLocalized };
55
74
  }
@@ -15,6 +15,12 @@ export interface WorkflowRevisionMeta {
15
15
  description: string | null;
16
16
  active: boolean;
17
17
  blob_definition_version: string;
18
+ /** Who saved this revision (PO-609); all null for a platform write and for a revision from before authors were recorded. */
19
+ saved_by_sub?: string | null;
20
+ saved_by_name?: string | null;
21
+ saved_by_email?: string | null;
22
+ /** When that person saved it — not `archived_at`, which is when the next save replaced it. */
23
+ saved_at?: string | null;
18
24
  created_at: string | null;
19
25
  updated_at: string | null;
20
26
  archived_at: string | null;
@@ -10,7 +10,7 @@ export declare function useIntegrationsTemplates(): {
10
10
  list: () => WorkflowTemplate[];
11
11
  get: (slug: string) => WorkflowTemplate | null;
12
12
  fetchDetail: (slug: string) => Promise<WorkflowTemplate | null>;
13
- ensureDetails: (slugs: string[], concurrency?: number) => Promise<void>;
13
+ ensureDetails: (slugs: string[], _concurrency?: number) => Promise<void>;
14
14
  requirements: (slug: string) => Promise<TemplateRequirements | null>;
15
15
  instantiate: (slug: string, opts?: {
16
16
  name?: string;
@@ -4,6 +4,7 @@ import { errorReason } from "../utils/apiError.js";
4
4
  import { unwrapList, unwrapRecord } from "../utils/apiEnvelope.js";
5
5
  import { resolveLocalized } from "../utils/localize.js";
6
6
  import { useLoadGate } from "./useLoadGate.js";
7
+ import { createDetailLoader, keyedBatch } from "../utils/detailLoader.js";
7
8
  const BLOB_VERSION = "v0-draft";
8
9
  const INDUSTRY_VALUES = ["mechanical-engineering", "chemicals", "wholesale", "manufacturing", "retail", "any"];
9
10
  function plainExcerpt(md) {
@@ -89,6 +90,7 @@ function applyDetail(base, api) {
89
90
  outline: nodes.map((n) => n.nodeSlug ?? n.id).filter(Boolean)
90
91
  };
91
92
  }
93
+ let sharedLoader = null;
92
94
  export function useIntegrationsTemplates() {
93
95
  const t = useIntegrationsT();
94
96
  const rawList = useState("integrations.templates.raw", () => []);
@@ -127,26 +129,33 @@ export function useIntegrationsTemplates() {
127
129
  function get(slug) {
128
130
  return templates.value.find((t2) => t2.slug === slug) ?? null;
129
131
  }
132
+ const loader = sharedLoader ??= createDetailLoader({
133
+ fetchOne: async (slug) => unwrapRecord(await api.get(`/templates/${slug}`)),
134
+ // A host that predates `include=definition` ignores it and sends the plain
135
+ // gallery list, whose items carry no `definition`: that reads as "no batch".
136
+ fetchMany: async (slugs) => keyedBatch(unwrapList(await api.get(
137
+ `/templates?include=definition&slugs=${slugs.map(encodeURIComponent).join(",")}`
138
+ )), "definition")
139
+ }, {
140
+ // The most slugs the service accepts in one `slugs` filter; more is a 422.
141
+ batchSize: 100
142
+ });
143
+ function remember(details) {
144
+ const found = [...details].filter((entry) => entry[1] !== null);
145
+ if (!found.length) return;
146
+ rawDetails.value = { ...rawDetails.value, ...Object.fromEntries(found) };
147
+ }
130
148
  async function fetchDetail(slug) {
131
149
  const base = get(slug);
132
150
  if (!base?.fromApi) return base;
133
- try {
134
- const detail = unwrapRecord(await api.get(`/templates/${slug}`));
135
- rawDetails.value = { ...rawDetails.value, [slug]: detail };
136
- return get(slug);
137
- } catch {
138
- return base;
139
- }
151
+ const detail = await loader.one(slug);
152
+ if (detail) remember(/* @__PURE__ */ new Map([[slug, detail]]));
153
+ return get(slug);
140
154
  }
141
- async function ensureDetails(slugs, concurrency = 4) {
142
- const queue = slugs.filter((slug) => get(slug)?.fromApi && !get(slug)?.detailLoaded);
143
- const workers = Array.from({ length: Math.min(concurrency, queue.length) }, async () => {
144
- while (queue.length) {
145
- const slug = queue.shift();
146
- if (slug) await fetchDetail(slug);
147
- }
148
- });
149
- await Promise.all(workers);
155
+ async function ensureDetails(slugs, _concurrency = 4) {
156
+ const wanted = slugs.filter((slug) => get(slug)?.fromApi && !get(slug)?.detailLoaded);
157
+ if (!wanted.length) return;
158
+ remember(await loader.many(wanted));
150
159
  }
151
160
  async function requirements(slug) {
152
161
  try {