@revenexx/studio-integrations 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +201 -0
- package/README.md +17 -4
- package/dist/module.json +1 -1
- package/dist/runtime/components/Integrations/CredentialCreateDialog.vue +15 -14
- package/dist/runtime/components/Integrations/CredentialDetailDialog.vue +91 -19
- package/dist/runtime/components/Integrations/CredentialRevenexxSource.vue +58 -21
- package/dist/runtime/components/Integrations/CredentialTypePicker.vue +4 -8
- package/dist/runtime/components/Integrations/EventTypeCombobox.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue +4 -2
- package/dist/runtime/components/Integrations/EventTypeCombobox.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/ExpressionInput.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/ExpressionInput.vue +54 -25
- package/dist/runtime/components/Integrations/ExpressionInput.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/GroupFrameView.vue +12 -12
- package/dist/runtime/components/Integrations/Layout.vue +21 -2
- package/dist/runtime/components/Integrations/NodeConfigDynamicField.vue +10 -11
- package/dist/runtime/components/Integrations/NodeConfigDynamicSchema.vue +2 -5
- package/dist/runtime/components/Integrations/NodeConfigField.d.vue.ts +2 -0
- package/dist/runtime/components/Integrations/NodeConfigField.vue +160 -67
- package/dist/runtime/components/Integrations/NodeConfigField.vue.d.ts +2 -0
- package/dist/runtime/components/Integrations/NodeInspector.d.vue.ts +6 -0
- package/dist/runtime/components/Integrations/NodeInspector.vue +1075 -303
- package/dist/runtime/components/Integrations/NodeInspector.vue.d.ts +6 -0
- package/dist/runtime/components/Integrations/NodeLogo.d.vue.ts +12 -0
- package/dist/runtime/components/Integrations/NodeLogo.vue +2 -30
- package/dist/runtime/components/Integrations/NodeLogo.vue.d.ts +12 -0
- package/dist/runtime/components/Integrations/NodeMark.d.vue.ts +15 -0
- package/dist/runtime/components/Integrations/NodeMark.vue +79 -0
- package/dist/runtime/components/Integrations/NodeMark.vue.d.ts +15 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.d.vue.ts +21 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue +42 -0
- package/dist/runtime/components/Integrations/NodeOutputSchema.vue.d.ts +21 -0
- package/dist/runtime/components/Integrations/NodePalette.vue +100 -34
- package/dist/runtime/components/Integrations/QueryBuilder.vue +8 -1
- package/dist/runtime/components/Integrations/RunActionsMenu.vue +3 -1
- package/dist/runtime/components/Integrations/RunPayloadInput.vue +17 -4
- package/dist/runtime/components/Integrations/StickyNoteView.vue +2 -1
- package/dist/runtime/components/Integrations/TemplateCard.d.vue.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue +109 -0
- package/dist/runtime/components/Integrations/TemplateCard.vue.d.ts +18 -0
- package/dist/runtime/components/Integrations/TemplateFlowPreview.d.vue.ts +2 -2
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue +7 -10
- package/dist/runtime/components/Integrations/TemplateFlowPreview.vue.d.ts +2 -2
- package/dist/runtime/components/Integrations/WorkflowCreateDialog.vue +21 -37
- package/dist/runtime/components/Integrations/WorkflowEdgeView.d.vue.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue +23 -7
- package/dist/runtime/components/Integrations/WorkflowEdgeView.vue.d.ts +18 -4
- package/dist/runtime/components/Integrations/WorkflowEditor.d.vue.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowEditor.vue +167 -46
- package/dist/runtime/components/Integrations/WorkflowEditor.vue.d.ts +20 -1
- package/dist/runtime/components/Integrations/WorkflowHistoryPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowIconPicker.d.vue.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue +11 -2
- package/dist/runtime/components/Integrations/WorkflowIconPicker.vue.d.ts +19 -1
- package/dist/runtime/components/Integrations/WorkflowNodeView.d.vue.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue +189 -46
- package/dist/runtime/components/Integrations/WorkflowNodeView.vue.d.ts +4 -0
- package/dist/runtime/components/Integrations/WorkflowRunReplayPanel.vue +4 -20
- package/dist/runtime/components/Integrations/WorkflowSettingsDialog.vue +15 -9
- package/dist/runtime/components/Integrations/WorkflowTagsInput.d.vue.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue +7 -2
- package/dist/runtime/components/Integrations/WorkflowTagsInput.vue.d.ts +1 -0
- package/dist/runtime/components/Integrations/WorkflowTriggerView.vue +4 -2
- package/dist/runtime/components/Integrations/WorkflowVariablesDialog.vue +30 -17
- package/dist/runtime/components/Integrations/WorkflowsTable.vue +47 -7
- package/dist/runtime/composables/useCredentialTypes.js +4 -4
- package/dist/runtime/composables/useIntegrationsAudit.js +4 -3
- package/dist/runtime/composables/useIntegrationsCredentials.js +4 -4
- package/dist/runtime/composables/useIntegrationsDeadLetters.js +7 -6
- package/dist/runtime/composables/useIntegrationsNodes.js +4 -4
- package/dist/runtime/composables/useIntegrationsRuns.js +11 -6
- package/dist/runtime/composables/useIntegrationsSchedules.d.ts +4 -0
- package/dist/runtime/composables/useIntegrationsSchedules.js +10 -3
- package/dist/runtime/composables/useIntegrationsSecrets.js +10 -7
- package/dist/runtime/composables/useIntegrationsTemplates.d.ts +1 -1
- package/dist/runtime/composables/useIntegrationsTemplates.js +12 -8
- package/dist/runtime/composables/useIntegrationsTriggers.js +6 -6
- package/dist/runtime/composables/useIntegrationsWorkflows.js +8 -4
- package/dist/runtime/composables/useLoadGate.d.ts +17 -0
- package/dist/runtime/composables/useLoadGate.js +8 -0
- package/dist/runtime/data/branchReorder.d.ts +37 -0
- package/dist/runtime/data/branchReorder.js +71 -0
- package/dist/runtime/data/handleId.d.ts +23 -0
- package/dist/runtime/data/handleId.js +6 -0
- package/dist/runtime/data/integrationTemplates.d.ts +39 -0
- package/dist/runtime/data/integrationTemplates.js +15 -0
- package/dist/runtime/data/integrationWorkflowSamples.js +1 -1
- package/dist/runtime/data/nodeIcons.d.ts +2 -0
- package/dist/runtime/data/nodeIcons.js +73 -0
- package/dist/runtime/data/nodeInputData.d.ts +16 -4
- package/dist/runtime/data/nodeInputData.js +4 -0
- package/dist/runtime/data/payloadForm.d.ts +9 -2
- package/dist/runtime/data/payloadForm.js +27 -3
- package/dist/runtime/data/portTone.d.ts +17 -0
- package/dist/runtime/data/portTone.js +7 -0
- package/dist/runtime/data/webhookAuth.d.ts +18 -0
- package/dist/runtime/data/webhookAuth.js +19 -0
- package/dist/runtime/data/workflowIconCatalog.js +2 -5
- package/dist/runtime/pages/integrations/audit.vue +91 -6
- package/dist/runtime/pages/integrations/credentials/oauth-complete.vue +6 -4
- package/dist/runtime/pages/integrations/credentials.vue +119 -21
- package/dist/runtime/pages/integrations/index.vue +49 -44
- package/dist/runtime/pages/integrations/runs.vue +1 -1
- package/dist/runtime/pages/integrations/schedules.vue +51 -7
- package/dist/runtime/pages/integrations/secrets.vue +29 -7
- package/dist/runtime/pages/integrations/templates/[slug].vue +52 -30
- package/dist/runtime/pages/integrations/templates/index.vue +57 -86
- package/dist/runtime/pages/integrations/workflows/[id].vue +196 -8
- package/dist/runtime/pages/integrations/workflows/index.vue +15 -2
- package/dist/runtime/types/integrations.d.ts +65 -6
- package/dist/runtime/utils/apiError.d.ts +47 -0
- package/dist/runtime/utils/apiError.js +32 -0
- package/dist/runtime/utils/cockpitSources.d.ts +153 -0
- package/dist/runtime/utils/cockpitSources.js +14 -0
- package/dist/runtime/utils/loadGate.d.ts +61 -0
- package/dist/runtime/utils/loadGate.js +22 -0
- package/dist/runtime/utils/localize.d.ts +29 -0
- package/dist/runtime/utils/localize.js +4 -0
- package/dist/runtime/utils/notify.d.ts +59 -0
- package/dist/runtime/utils/notify.js +21 -0
- package/dist/runtime/utils/revenexxCredentialSource.d.ts +26 -0
- package/dist/runtime/utils/revenexxCredentialSource.js +8 -0
- package/dist/runtime/utils/schedule.d.ts +141 -0
- package/dist/runtime/utils/schedule.js +207 -0
- package/dist/runtime/utils/validationFindings.d.ts +101 -0
- package/dist/runtime/utils/validationFindings.js +53 -0
- package/dist/runtime/utils/whenIdle.d.ts +42 -0
- package/dist/runtime/utils/whenIdle.js +12 -0
- package/package.json +8 -2
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { useIntegrationsApi } from "./integrationsApi.js";
|
|
2
2
|
import { IntegrationsApiError } from "../utils/apiError.js";
|
|
3
|
+
import { useLoadGate } from "./useLoadGate.js";
|
|
3
4
|
export function useIntegrationsTriggers() {
|
|
4
5
|
const all = useState("integrations.triggers.all", () => []);
|
|
5
6
|
const loading = useState("integrations.triggers.loading", () => false);
|
|
6
7
|
const error = useState("integrations.triggers.error", () => null);
|
|
7
8
|
const loaded = useState("integrations.triggers.loaded", () => false);
|
|
8
9
|
const api = useIntegrationsApi();
|
|
9
|
-
const { workflows, load: loadWorkflows } = useIntegrationsWorkflows();
|
|
10
|
+
const { workflows, load: loadWorkflows, loaded: workflowsLoaded } = useIntegrationsWorkflows();
|
|
11
|
+
const gate = useLoadGate("integrations.triggers", { loading, loaded });
|
|
10
12
|
function unwrapList(res) {
|
|
11
13
|
if (Array.isArray(res)) return res;
|
|
12
14
|
return res?.data ?? [];
|
|
@@ -15,9 +17,7 @@ export function useIntegrationsTriggers() {
|
|
|
15
17
|
return unwrapList(await api.get(`/workflows/${workflowId}/triggers`));
|
|
16
18
|
}
|
|
17
19
|
async function loadAll(force = false) {
|
|
18
|
-
if (
|
|
19
|
-
if (loaded.value && !force) return all.value;
|
|
20
|
-
loading.value = true;
|
|
20
|
+
if (!await gate.claim(force)) return all.value;
|
|
21
21
|
error.value = null;
|
|
22
22
|
try {
|
|
23
23
|
await loadWorkflows();
|
|
@@ -25,11 +25,11 @@ export function useIntegrationsTriggers() {
|
|
|
25
25
|
workflows.value.map(async (wf) => (await list(wf.id)).map((t) => ({ ...t, workflowName: wf.name })))
|
|
26
26
|
);
|
|
27
27
|
all.value = settled.flatMap((p) => p.status === "fulfilled" ? p.value : []);
|
|
28
|
-
loaded.value =
|
|
28
|
+
loaded.value = workflowsLoaded.value && settled.every((p) => p.status === "fulfilled");
|
|
29
29
|
} catch (e) {
|
|
30
30
|
error.value = e instanceof IntegrationsApiError ? e.message : e?.message ?? "Could not load triggers.";
|
|
31
31
|
} finally {
|
|
32
|
-
|
|
32
|
+
gate.release();
|
|
33
33
|
}
|
|
34
34
|
return all.value;
|
|
35
35
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { computed } from "vue";
|
|
2
2
|
import { useIntegrationsApi } from "./integrationsApi.js";
|
|
3
3
|
import { IntegrationsApiError } from "../utils/apiError.js";
|
|
4
|
+
import { useLoadGate } from "./useLoadGate.js";
|
|
4
5
|
const BLOB_VERSION = "v0-draft";
|
|
5
6
|
function toCockpit(api) {
|
|
6
7
|
const apiBlob = api.blob ?? {};
|
|
@@ -35,6 +36,10 @@ function toCockpit(api) {
|
|
|
35
36
|
execution_mode: api.execution_mode,
|
|
36
37
|
buildStatus: api.build_status ?? null,
|
|
37
38
|
buildError: api.build_error ?? null,
|
|
39
|
+
// Carried through as-is: `undefined` (e.g. the list response, which omits
|
|
40
|
+
// it) means "unknown", NOT "valid" — callers must fail open and only gate
|
|
41
|
+
// activation on a non-empty array.
|
|
42
|
+
warnings: api.warnings,
|
|
38
43
|
created_at: api.created_at ?? "",
|
|
39
44
|
updated_at: api.updated_at ?? ""
|
|
40
45
|
};
|
|
@@ -76,6 +81,7 @@ export function useIntegrationsWorkflows() {
|
|
|
76
81
|
const loading = useState("integrations.workflows.loading", () => false);
|
|
77
82
|
const error = useState("integrations.workflows.error", () => null);
|
|
78
83
|
const loaded = useState("integrations.workflows.loaded", () => false);
|
|
84
|
+
const gate = useLoadGate("integrations.workflows", { loading, loaded });
|
|
79
85
|
const api = useIntegrationsApi();
|
|
80
86
|
function unwrap(res) {
|
|
81
87
|
return res?.data ?? res;
|
|
@@ -89,9 +95,7 @@ export function useIntegrationsWorkflows() {
|
|
|
89
95
|
workflows.value = idx < 0 ? [...workflows.value, wf] : workflows.value.map((w) => w.id === wf.id ? wf : w);
|
|
90
96
|
}
|
|
91
97
|
async function load(force = false) {
|
|
92
|
-
if (
|
|
93
|
-
if (loaded.value && !force) return workflows.value;
|
|
94
|
-
loading.value = true;
|
|
98
|
+
if (!await gate.claim(force)) return workflows.value;
|
|
95
99
|
error.value = null;
|
|
96
100
|
try {
|
|
97
101
|
const list2 = unwrap(await api.get("/workflows")) ?? [];
|
|
@@ -100,7 +104,7 @@ export function useIntegrationsWorkflows() {
|
|
|
100
104
|
} catch (e) {
|
|
101
105
|
error.value = captureError(e, "Could not load workflows.");
|
|
102
106
|
} finally {
|
|
103
|
-
|
|
107
|
+
gate.release();
|
|
104
108
|
}
|
|
105
109
|
return workflows.value;
|
|
106
110
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { LoadGate } from '../utils/loadGate.js';
|
|
3
|
+
/**
|
|
4
|
+
* A composable's load gate, keyed by the namespace its own state already uses:
|
|
5
|
+
* `useLoadGate('integrations.triggers', { loading, loaded })`.
|
|
6
|
+
*
|
|
7
|
+
* The rule lives in `utils/loadGate.ts`; this is the wiring, and the wiring is the
|
|
8
|
+
* reason it is a composable. The counters have to share the flag's fate — one per
|
|
9
|
+
* Nuxt app on the client, one per request on the server — so they are `useState`
|
|
10
|
+
* like the flag itself. A module-level counter would be shared between two
|
|
11
|
+
* concurrent server-rendered requests, and then one tenant's finished load would
|
|
12
|
+
* answer another tenant's forced caller.
|
|
13
|
+
*/
|
|
14
|
+
export declare function useLoadGate(key: string, flags: {
|
|
15
|
+
loading: Ref<boolean>;
|
|
16
|
+
loaded?: Ref<boolean>;
|
|
17
|
+
}): LoadGate;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure core of the tidy-up branch reorder (see WorkflowEditor's
|
|
3
|
+
* `reorderBranchesByHandle`): given laid-out node positions and the outgoing
|
|
4
|
+
* edges tagged with their output-port index, compute the x moves that put
|
|
5
|
+
* each source's branch subtrees into handle order.
|
|
6
|
+
*
|
|
7
|
+
* dagre places nodes as points and is blind to which output HANDLE an edge
|
|
8
|
+
* leaves from, so a node's branches can land in the opposite left-to-right
|
|
9
|
+
* order to their handles and cross right under the node. Only self-contained,
|
|
10
|
+
* pairwise-disjoint subtrees are moved — a reconverging branch (diamond,
|
|
11
|
+
* shared descendant, outside parent) leaves the whole node untouched rather
|
|
12
|
+
* than corrupted. Idempotent: an already-ordered node yields no moves.
|
|
13
|
+
*
|
|
14
|
+
* The crossing is owned by the direct CHILD NODE's own x, not the subtree's
|
|
15
|
+
* extent: dagre centres a node over its (possibly wide) subtree, so a spine —
|
|
16
|
+
* a long main chain where each step also hangs off a short error/leaf branch —
|
|
17
|
+
* puts the leaf to the LEFT while the continuation node sits at its subtree's
|
|
18
|
+
* centre to the right, crossing out/error even though the two subtrees never
|
|
19
|
+
* share a node. So we reorder by the child nodes' positions: the children keep
|
|
20
|
+
* exactly the set of x slots they already occupy, re-slotted into handle order.
|
|
21
|
+
* That's a pure permutation — the rank's footprint (and the source's centre
|
|
22
|
+
* over it) is preserved and no branch is flung wide, whatever its subtree size.
|
|
23
|
+
*
|
|
24
|
+
* Kept free of vue-flow/component state so it can be unit-tested against
|
|
25
|
+
* hand-built graphs.
|
|
26
|
+
*/
|
|
27
|
+
export interface ReorderNode {
|
|
28
|
+
x: number;
|
|
29
|
+
}
|
|
30
|
+
export interface ReorderEdge {
|
|
31
|
+
source: string;
|
|
32
|
+
target: string;
|
|
33
|
+
/** Left-to-right index of the source's output handle this edge leaves from. */
|
|
34
|
+
portIndex: number;
|
|
35
|
+
}
|
|
36
|
+
/** Node id → new x, for exactly the nodes that need to move. */
|
|
37
|
+
export declare function reorderBranchSubtrees(nodes: Map<string, ReorderNode>, edges: ReorderEdge[]): Map<string, number>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function reorderBranchSubtrees(nodes, edges) {
|
|
2
|
+
const inGraph = (e) => nodes.has(e.source) && nodes.has(e.target);
|
|
3
|
+
const children = /* @__PURE__ */ new Map();
|
|
4
|
+
const parents = /* @__PURE__ */ new Map();
|
|
5
|
+
for (const e of edges) {
|
|
6
|
+
if (!inGraph(e)) continue;
|
|
7
|
+
(children.get(e.source) ?? (children.set(e.source, []), children.get(e.source))).push(e.target);
|
|
8
|
+
(parents.get(e.target) ?? (parents.set(e.target, []), parents.get(e.target))).push(e.source);
|
|
9
|
+
}
|
|
10
|
+
const subtreeOf = (root) => {
|
|
11
|
+
const seen = /* @__PURE__ */ new Set();
|
|
12
|
+
const stack = [root];
|
|
13
|
+
while (stack.length) {
|
|
14
|
+
const v = stack.pop();
|
|
15
|
+
if (seen.has(v)) continue;
|
|
16
|
+
seen.add(v);
|
|
17
|
+
for (const c of children.get(v) ?? []) if (!seen.has(c)) stack.push(c);
|
|
18
|
+
}
|
|
19
|
+
return seen;
|
|
20
|
+
};
|
|
21
|
+
const branchesBySource = /* @__PURE__ */ new Map();
|
|
22
|
+
for (const e of edges) {
|
|
23
|
+
if (!inGraph(e)) continue;
|
|
24
|
+
const byTarget = branchesBySource.get(e.source) ?? (branchesBySource.set(e.source, /* @__PURE__ */ new Map()), branchesBySource.get(e.source));
|
|
25
|
+
byTarget.set(e.target, Math.min(byTarget.get(e.target) ?? Infinity, e.portIndex));
|
|
26
|
+
}
|
|
27
|
+
const moved = /* @__PURE__ */ new Map();
|
|
28
|
+
const xOf = (id) => moved.get(id) ?? nodes.get(id).x;
|
|
29
|
+
for (const [source, byTarget] of branchesBySource) {
|
|
30
|
+
const targets = [...byTarget].map(([target, idx]) => ({ target, idx }));
|
|
31
|
+
if (targets.length < 2) continue;
|
|
32
|
+
const subtrees = new Map(targets.map((t) => [t.target, subtreeOf(t.target)]));
|
|
33
|
+
let safe = true;
|
|
34
|
+
const union = /* @__PURE__ */ new Set();
|
|
35
|
+
outer: for (const t of targets) {
|
|
36
|
+
const st = subtrees.get(t.target);
|
|
37
|
+
if (st.has(source)) {
|
|
38
|
+
safe = false;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
for (const m of st) {
|
|
42
|
+
if (union.has(m)) {
|
|
43
|
+
safe = false;
|
|
44
|
+
break outer;
|
|
45
|
+
}
|
|
46
|
+
union.add(m);
|
|
47
|
+
for (const p of parents.get(m) ?? []) {
|
|
48
|
+
if (p !== source && !st.has(p)) {
|
|
49
|
+
safe = false;
|
|
50
|
+
break outer;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!safe) continue;
|
|
56
|
+
const desired = [...targets].sort((a, b) => a.idx - b.idx || a.target.localeCompare(b.target));
|
|
57
|
+
const current = [...targets].sort((a, b) => xOf(a.target) - xOf(b.target) || a.target.localeCompare(b.target));
|
|
58
|
+
if (current.every((t, i) => t.target === desired[i].target)) continue;
|
|
59
|
+
const slots = current.map((t) => xOf(t.target));
|
|
60
|
+
for (let i = 0; i < desired.length; i++) {
|
|
61
|
+
const target = desired[i].target;
|
|
62
|
+
const dx = slots[i] - xOf(target);
|
|
63
|
+
if (dx !== 0) {
|
|
64
|
+
for (const m of subtrees.get(target)) {
|
|
65
|
+
moved.set(m, xOf(m) + dx);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return moved;
|
|
71
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading the logical port name out of a canvas handle id.
|
|
3
|
+
*
|
|
4
|
+
* Each node on the canvas renders four connection points per port, one per
|
|
5
|
+
* side, so that an edge can leave and arrive without crossing a card body. The
|
|
6
|
+
* side rides along in the handle id as a `__<side>` suffix; the workflow blob
|
|
7
|
+
* stores only the port name. Everything that persists an edge, or reasons about
|
|
8
|
+
* which port an edge left from, has to read the name back out — and an id whose
|
|
9
|
+
* name is missing has to be told apart from one whose name is unusual.
|
|
10
|
+
*
|
|
11
|
+
* An output dot is `<port>__<side>`. A node's single input dot is `__in__<side>`
|
|
12
|
+
* and a group frame's is `__in__top`: the separator sits at the very front, so
|
|
13
|
+
* there is no port name in front of it. Those answer `null` — **not** the empty
|
|
14
|
+
* string, which is what a `slice(0, 0)` would hand back. Every caller writes
|
|
15
|
+
* `portFromHandleId(id) ?? 'default'`, and `??` does not fire on `''`, so an
|
|
16
|
+
* empty name would travel on past the fallback that exists to catch it.
|
|
17
|
+
*
|
|
18
|
+
* It lives here rather than in the editor because it is the one place that knows
|
|
19
|
+
* how to read an id, and because that is what makes it testable: the editor is
|
|
20
|
+
* an SFC, out of reach of the unit suite, and the defect above shipped once
|
|
21
|
+
* already without anything going red.
|
|
22
|
+
*/
|
|
23
|
+
export declare function portFromHandleId(handleId: string | null | undefined): string | null;
|
|
@@ -3,9 +3,48 @@ import type { WorkflowTemplate } from '../types/integrations.js';
|
|
|
3
3
|
* Category lens config for the Templates catalogue. The templates themselves
|
|
4
4
|
* come live from the Integrations registry (`GET /templates`) — there is no
|
|
5
5
|
* mock catalogue; an empty registry is an empty catalogue.
|
|
6
|
+
*
|
|
7
|
+
* The two categories that templates actually ship with today are `commerce` and
|
|
8
|
+
* `sales`: the eight templates in `@revenexx/integrations-nodes-core` are all
|
|
9
|
+
* `commerce`, the two in the Business Central package `sales`. Neither was named
|
|
10
|
+
* here, so both were shown by the fallback rather than by a label — the studio's
|
|
11
|
+
* one list of categories described none of the catalogue it had. The other six
|
|
12
|
+
* are named on spec and nothing publishes them yet.
|
|
6
13
|
*/
|
|
7
14
|
export declare const TEMPLATE_CATEGORIES: Array<{
|
|
8
15
|
key: WorkflowTemplate['category'];
|
|
9
16
|
label: string;
|
|
10
17
|
color: string;
|
|
11
18
|
}>;
|
|
19
|
+
/**
|
|
20
|
+
* What a template's category is called, wherever the studio shows one.
|
|
21
|
+
*
|
|
22
|
+
* Four surfaces draw a category chip — the gallery, a template's own page, the
|
|
23
|
+
* dashboard's template panel and the start-from-a-template list in the dialog that
|
|
24
|
+
* creates a workflow — and each worked the name out for itself: a lookup with a
|
|
25
|
+
* capitalisation fallback, the raw registry key, and two separate capitalisations.
|
|
26
|
+
* They agreed only by coincidence, because every label in `TEMPLATE_CATEGORIES`
|
|
27
|
+
* above happens to be its key capitalised.
|
|
28
|
+
*
|
|
29
|
+
* That coincidence ends with the first category whose name is not a capitalisation
|
|
30
|
+
* — `crm`, `b2b`, `erp` — and categories arrive with node packages rather than with
|
|
31
|
+
* a release of the studio, so it is a matter of when. Every chip goes through here
|
|
32
|
+
* instead.
|
|
33
|
+
*
|
|
34
|
+
* The fallback is the catalogue's own promise (`specs/templates.md` AC-2): a
|
|
35
|
+
* category the studio has never been taught still gets a chip, named as well as it
|
|
36
|
+
* can be rather than dropped. That guarantee held on the gallery alone before; it
|
|
37
|
+
* now holds on all four.
|
|
38
|
+
*/
|
|
39
|
+
export declare function templateCategoryLabel(key: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* The integration a template ships with, as a card badges it — and as the
|
|
42
|
+
* gallery's search matches it. The core toolbox stays unbadged: every template
|
|
43
|
+
* would carry it, so it would say nothing.
|
|
44
|
+
*
|
|
45
|
+
* It lives here for the same reason `templateCategoryLabel` does. The gallery
|
|
46
|
+
* both shows this label and searches by it, so a copy inside the card component
|
|
47
|
+
* would be a second implementation of a name — the exact duplication PO-243
|
|
48
|
+
* removes.
|
|
49
|
+
*/
|
|
50
|
+
export declare function templatePackageLabel(tpl: Pick<WorkflowTemplate, 'package'>): string | null;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export const TEMPLATE_CATEGORIES = [
|
|
2
2
|
// Uniform muted pill — the label carries the meaning, no rainbow needed.
|
|
3
|
+
{ key: "commerce", label: "Commerce", color: "bg-slate-100 text-slate-700" },
|
|
4
|
+
{ key: "sales", label: "Sales", color: "bg-slate-100 text-slate-700" },
|
|
3
5
|
{ key: "sync", label: "Sync", color: "bg-slate-100 text-slate-700" },
|
|
4
6
|
{ key: "notification", label: "Notification", color: "bg-slate-100 text-slate-700" },
|
|
5
7
|
{ key: "fulfilment", label: "Fulfilment", color: "bg-slate-100 text-slate-700" },
|
|
@@ -7,3 +9,16 @@ export const TEMPLATE_CATEGORIES = [
|
|
|
7
9
|
{ key: "finance", label: "Finance", color: "bg-slate-100 text-slate-700" },
|
|
8
10
|
{ key: "reporting", label: "Reporting", color: "bg-slate-100 text-slate-700" }
|
|
9
11
|
];
|
|
12
|
+
export function templateCategoryLabel(key) {
|
|
13
|
+
if (!key) return "";
|
|
14
|
+
const known = TEMPLATE_CATEGORIES.find((c) => c.key === key);
|
|
15
|
+
if (known) return known.label;
|
|
16
|
+
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
17
|
+
}
|
|
18
|
+
export function templatePackageLabel(tpl) {
|
|
19
|
+
const pkg = tpl.package;
|
|
20
|
+
if (!pkg) return null;
|
|
21
|
+
const tail = (pkg.name.split("/").pop() ?? pkg.name).replace(/^integrations-nodes-/, "");
|
|
22
|
+
if (tail === "core") return null;
|
|
23
|
+
return pkg.label ?? tail.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join(" ");
|
|
24
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as Lucide from "lucide-vue-next";
|
|
2
|
+
const MDI_TO_LUCIDE = {
|
|
3
|
+
"account-group-outline": "Users",
|
|
4
|
+
"account-outline": "User",
|
|
5
|
+
"account-plus-outline": "UserPlus",
|
|
6
|
+
"alert-octagon": "OctagonAlert",
|
|
7
|
+
"api": "Webhook",
|
|
8
|
+
"book-open-outline": "BookOpen",
|
|
9
|
+
"bullseye-arrow": "Target",
|
|
10
|
+
"calendar-check-outline": "CalendarCheck",
|
|
11
|
+
"card-account-details-outline": "IdCard",
|
|
12
|
+
"cart-outline": "ShoppingCart",
|
|
13
|
+
"cart-plus": "ShoppingBasket",
|
|
14
|
+
"circle-outline": "Circle",
|
|
15
|
+
"clock-outline": "Clock",
|
|
16
|
+
"cloud-download": "CloudDownload",
|
|
17
|
+
"cloud-upload": "CloudUpload",
|
|
18
|
+
"cloud-upload-outline": "CloudUpload",
|
|
19
|
+
"code-brackets": "Brackets",
|
|
20
|
+
"code-json": "Braces",
|
|
21
|
+
"code-tags": "Code",
|
|
22
|
+
"compare-horizontal": "ArrowLeftRight",
|
|
23
|
+
"currency-eur": "Euro",
|
|
24
|
+
"database-cog-outline": "Database",
|
|
25
|
+
"database-export-outline": "HardDriveUpload",
|
|
26
|
+
"database-import-outline": "HardDriveDownload",
|
|
27
|
+
"domain": "Building2",
|
|
28
|
+
"email-send": "Send",
|
|
29
|
+
"file-edit-outline": "FilePen",
|
|
30
|
+
"file-export-outline": "FileOutput",
|
|
31
|
+
"file-import-outline": "FileInput",
|
|
32
|
+
"file-move-outline": "FileSymlink",
|
|
33
|
+
"file-plus": "FilePlus",
|
|
34
|
+
"file-question-outline": "FileQuestion",
|
|
35
|
+
"file-remove-outline": "FileX",
|
|
36
|
+
"filter": "Filter",
|
|
37
|
+
"find-replace": "Replace",
|
|
38
|
+
"folder-download-outline": "FolderDown",
|
|
39
|
+
"folder-plus-outline": "FolderPlus",
|
|
40
|
+
"folder-search-outline": "FolderSearch",
|
|
41
|
+
"folder-upload-outline": "FolderUp",
|
|
42
|
+
"folder-zip-outline": "FolderArchive",
|
|
43
|
+
"format-list-bulleted-type": "List",
|
|
44
|
+
"handshake": "Handshake",
|
|
45
|
+
"handshake-outline": "Handshake",
|
|
46
|
+
"link-lock": "Link2",
|
|
47
|
+
"magnify": "Search",
|
|
48
|
+
"office-building-outline": "Building",
|
|
49
|
+
"pencil-box-outline": "SquarePen",
|
|
50
|
+
"pipe": "Workflow",
|
|
51
|
+
"receipt-outline": "Receipt",
|
|
52
|
+
"receipt-text-outline": "ReceiptText",
|
|
53
|
+
"regex": "Regex",
|
|
54
|
+
"repeat": "Repeat",
|
|
55
|
+
"ruler": "Ruler",
|
|
56
|
+
"source-branch": "GitBranch",
|
|
57
|
+
"tag-multiple-outline": "Tags",
|
|
58
|
+
"transfer": "ArrowRightLeft",
|
|
59
|
+
"translate": "Languages",
|
|
60
|
+
"truck-outline": "Truck",
|
|
61
|
+
"web": "Globe",
|
|
62
|
+
"xml": "CodeXml"
|
|
63
|
+
};
|
|
64
|
+
export function nodeIcon(name) {
|
|
65
|
+
if (!name) return Lucide.Box;
|
|
66
|
+
const bare = name.includes(":") ? name.split(":").pop() : name;
|
|
67
|
+
const mapped = MDI_TO_LUCIDE[bare];
|
|
68
|
+
if (mapped && Lucide[mapped]) {
|
|
69
|
+
return Lucide[mapped];
|
|
70
|
+
}
|
|
71
|
+
const pascal = bare.split("-").map((s) => s.charAt(0).toUpperCase() + s.slice(1)).join("");
|
|
72
|
+
return Lucide[pascal] || Lucide.Box;
|
|
73
|
+
}
|
|
@@ -27,11 +27,23 @@ export interface PortEntry {
|
|
|
27
27
|
/** Declared payload fields of this port (schema; may be empty). */
|
|
28
28
|
fields: FieldEntry[];
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* A manifest declaring zero output ports describes a TERMINAL node (PO-201):
|
|
32
|
+
* the run ends there, so it gets no output handle and no edge may leave it.
|
|
33
|
+
*
|
|
34
|
+
* The distinction that matters is **declared empty vs. not known**. An absent
|
|
35
|
+
* manifest — a node that is not installed — is not terminal; we simply cannot
|
|
36
|
+
* work out its ports, and it has to stay wirable. Same for a `sourceFromConfig`
|
|
37
|
+
* or `resolveOutputs` set that expands to nothing: unresolved, not terminal.
|
|
38
|
+
* Only an explicit `[]` is the node making a statement about itself.
|
|
39
|
+
*/
|
|
40
|
+
export declare function isTerminalManifest(manifest: NodeManifest | null | undefined): boolean;
|
|
30
41
|
/**
|
|
31
42
|
* The output ports one upstream node exposes, mirroring the canvas port
|
|
32
|
-
* derivation
|
|
33
|
-
* from the node's config values (plus the
|
|
34
|
-
*
|
|
35
|
-
* dynamic outputs, when present, replace
|
|
43
|
+
* derivation in `WorkflowNodeView.vue`. Named ports map straight over,
|
|
44
|
+
* `sourceFromConfig` ports expand from the node's config values (plus the
|
|
45
|
+
* optional fallback), and a node whose ports could not be worked out still
|
|
46
|
+
* gets its `default`. Server-resolved dynamic outputs, when present, replace
|
|
47
|
+
* the manifest's static list.
|
|
36
48
|
*/
|
|
37
49
|
export declare function portsForNode(node: WorkflowNode, manifest: NodeManifest | null, resolvedOutputs?: NodeManifestOutput[]): PortEntry[];
|
|
@@ -44,7 +44,11 @@ function fieldEntries(raw) {
|
|
|
44
44
|
}
|
|
45
45
|
return [];
|
|
46
46
|
}
|
|
47
|
+
export function isTerminalManifest(manifest) {
|
|
48
|
+
return !!manifest && Array.isArray(manifest.outputs) && !manifest.outputs.length;
|
|
49
|
+
}
|
|
47
50
|
export function portsForNode(node, manifest, resolvedOutputs) {
|
|
51
|
+
if (isTerminalManifest(manifest)) return [];
|
|
48
52
|
const list = resolvedOutputs?.length ? resolvedOutputs : manifest?.outputs ?? [];
|
|
49
53
|
const ports = [];
|
|
50
54
|
for (const o of list) {
|
|
@@ -16,8 +16,15 @@ export interface PayloadField {
|
|
|
16
16
|
path: string;
|
|
17
17
|
label: string;
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Payload form fields: fields DECLARED on a manual trigger come first (the
|
|
21
|
+
* author's explicit contract, e.g. a file field for a manual import), then
|
|
22
|
+
* every `${{ trigger.payload.* }}` reference found in node configs that the
|
|
23
|
+
* declarations don't already cover.
|
|
24
|
+
*/
|
|
25
|
+
export declare function payloadFieldsFromBlob(blob: Pick<WorkflowBlob, 'nodes'> & {
|
|
26
|
+
triggers?: WorkflowBlob['triggers'];
|
|
27
|
+
}): PayloadField[];
|
|
21
28
|
export declare function setPath(target: Record<string, unknown>, path: string, value: unknown): void;
|
|
22
29
|
export declare function getPath(source: Record<string, unknown>, path: string): unknown;
|
|
23
30
|
export interface AttachedFile {
|
|
@@ -14,9 +14,23 @@ function labelFor(path) {
|
|
|
14
14
|
return last.replace(/[_-]/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (c) => c.toUpperCase());
|
|
15
15
|
}
|
|
16
16
|
export function payloadFieldsFromBlob(blob) {
|
|
17
|
+
const declared = [];
|
|
18
|
+
for (const t of blob.triggers ?? []) {
|
|
19
|
+
if (t.type !== "manual") continue;
|
|
20
|
+
for (const d of t.config?.payloadFields ?? []) {
|
|
21
|
+
const path = d.path?.trim();
|
|
22
|
+
if (!path && d.kind !== "file") continue;
|
|
23
|
+
declared.push({
|
|
24
|
+
kind: d.kind === "file" ? "file" : "text",
|
|
25
|
+
path: path ?? "",
|
|
26
|
+
label: d.label?.trim() || labelFor(path || "file")
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
17
30
|
const refs = /* @__PURE__ */ new Set();
|
|
18
31
|
for (const node of blob.nodes ?? []) collectRefs(node.config, refs);
|
|
19
|
-
if (refs.size === 0) return [];
|
|
32
|
+
if (refs.size === 0 && declared.length === 0) return [];
|
|
33
|
+
const declaredPaths = new Set(declared.map((f) => f.path));
|
|
20
34
|
const byPrefix = /* @__PURE__ */ new Map();
|
|
21
35
|
for (const ref of refs) {
|
|
22
36
|
const idx = ref.lastIndexOf(".");
|
|
@@ -26,9 +40,15 @@ export function payloadFieldsFromBlob(blob) {
|
|
|
26
40
|
byPrefix.get(prefix).add(leaf);
|
|
27
41
|
}
|
|
28
42
|
const fields = [];
|
|
29
|
-
const consumed =
|
|
43
|
+
const consumed = new Set(declaredPaths);
|
|
30
44
|
for (const [prefix, leaves] of byPrefix) {
|
|
31
45
|
if (leaves.has("data")) {
|
|
46
|
+
if (declaredPaths.has(prefix)) {
|
|
47
|
+
for (const leaf of leaves) {
|
|
48
|
+
if (FILE_KEYS.has(leaf)) consumed.add(prefix === "" ? leaf : `${prefix}.${leaf}`);
|
|
49
|
+
}
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
32
52
|
fields.push({ kind: "file", path: prefix, label: prefix === "" ? "File" : labelFor(prefix) });
|
|
33
53
|
for (const leaf of leaves) {
|
|
34
54
|
if (FILE_KEYS.has(leaf)) consumed.add(prefix === "" ? leaf : `${prefix}.${leaf}`);
|
|
@@ -37,9 +57,13 @@ export function payloadFieldsFromBlob(blob) {
|
|
|
37
57
|
}
|
|
38
58
|
for (const ref of [...refs].sort()) {
|
|
39
59
|
if (consumed.has(ref)) continue;
|
|
60
|
+
if (declared.some((d) => d.kind === "file" && (d.path === "" ? FILE_KEYS.has(ref) : ref.startsWith(d.path + ".")))) continue;
|
|
40
61
|
fields.push({ kind: "text", path: ref, label: labelFor(ref) });
|
|
41
62
|
}
|
|
42
|
-
return
|
|
63
|
+
return [
|
|
64
|
+
...declared,
|
|
65
|
+
...fields.sort((a, b) => a.kind === b.kind ? a.path.localeCompare(b.path) : a.kind === "file" ? -1 : 1)
|
|
66
|
+
];
|
|
43
67
|
}
|
|
44
68
|
export function setPath(target, path, value) {
|
|
45
69
|
if (path === "") {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { OutputKind } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* The colour of the dot in front of a port name, wherever a port is listed
|
|
4
|
+
* rather than drawn on the canvas: the Input panel's upstream trees and the
|
|
5
|
+
* node's own output schema.
|
|
6
|
+
*
|
|
7
|
+
* The legend mirrors `portTone` on the canvas node (PO-197): green = a real,
|
|
8
|
+
* active output — the normal continuation, including switch cases, iterator
|
|
9
|
+
* `item` and If/Filter `true`; soft rose = the error output that only runs on
|
|
10
|
+
* failure; neutral slate for `false` and fallbacks. There is no separate branch
|
|
11
|
+
* colour — the labels already say "one of several".
|
|
12
|
+
*
|
|
13
|
+
* It lives here rather than in a component because two lists render it and they
|
|
14
|
+
* must never disagree. They did once: the schema list kept a private copy and
|
|
15
|
+
* silently held the pre-PO-197 palette while the Input panel moved on.
|
|
16
|
+
*/
|
|
17
|
+
export declare function portDotClass(kind: OutputKind, name: string): string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export function portDotClass(kind, name) {
|
|
2
|
+
if (name === "true") return "bg-emerald-500";
|
|
3
|
+
if (name === "false") return "bg-slate-500";
|
|
4
|
+
if (name === "error" || kind === "error") return "bg-rose-300";
|
|
5
|
+
if (name === "done") return "bg-emerald-500";
|
|
6
|
+
return "bg-emerald-500";
|
|
7
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { WorkflowTrigger } from '../types/integrations.js';
|
|
2
|
+
/**
|
|
3
|
+
* Mapping between the webhook Protection select and the ingress verification
|
|
4
|
+
* profiles stored on the trigger (`authProfile`/`authConfig`). Bearer is the
|
|
5
|
+
* token profile with a fixed Authorization + "Bearer " prefix.
|
|
6
|
+
*
|
|
7
|
+
* An ABSENT authProfile reads as 'token': that is what the exposure defaults
|
|
8
|
+
* to server-side (and what every endpoint provisioned before this feature
|
|
9
|
+
* got), so the select shows the effective behavior. The UI persists the
|
|
10
|
+
* choice explicitly the moment the public toggle is enabled, so anything
|
|
11
|
+
* exposed from here on carries what it shows.
|
|
12
|
+
*/
|
|
13
|
+
export type WebhookAuthChoice = 'none' | 'token' | 'bearer' | 'basic';
|
|
14
|
+
type TriggerConfig = WorkflowTrigger['config'];
|
|
15
|
+
export declare function authChoiceFromConfig(config: TriggerConfig | undefined): WebhookAuthChoice;
|
|
16
|
+
/** The config patch that makes a Protection choice stored state. */
|
|
17
|
+
export declare function configPatchForAuthChoice(choice: WebhookAuthChoice): Partial<TriggerConfig>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function authChoiceFromConfig(config) {
|
|
2
|
+
if (config?.authProfile === "none") return "none";
|
|
3
|
+
if (config?.authProfile === "basic") return "basic";
|
|
4
|
+
if (config?.authProfile === "token" && config?.authConfig?.prefix === "Bearer ") return "bearer";
|
|
5
|
+
return "token";
|
|
6
|
+
}
|
|
7
|
+
export function configPatchForAuthChoice(choice) {
|
|
8
|
+
switch (choice) {
|
|
9
|
+
case "none":
|
|
10
|
+
return { authProfile: "none", authConfig: void 0 };
|
|
11
|
+
case "bearer":
|
|
12
|
+
return { authProfile: "token", authConfig: { header: "Authorization", prefix: "Bearer " } };
|
|
13
|
+
case "basic":
|
|
14
|
+
return { authProfile: "basic", authConfig: void 0 };
|
|
15
|
+
case "token":
|
|
16
|
+
default:
|
|
17
|
+
return { authProfile: "token", authConfig: void 0 };
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
WalletMoney
|
|
38
38
|
} from "#solar-icons";
|
|
39
39
|
import { INTEGRATION_NODE_CATALOG } from "./integrationNodeCatalog.js";
|
|
40
|
+
import { resolveLocalized } from "../utils/localize.js";
|
|
40
41
|
export const WORKFLOW_SOLAR_ICONS = {
|
|
41
42
|
Programming,
|
|
42
43
|
Bolt,
|
|
@@ -78,10 +79,6 @@ export const WORKFLOW_SOLAR_ICONS = {
|
|
|
78
79
|
export function solarIconFor(name) {
|
|
79
80
|
return (name ? WORKFLOW_SOLAR_ICONS[name] : void 0) ?? Programming;
|
|
80
81
|
}
|
|
81
|
-
function loc(v) {
|
|
82
|
-
if (!v) return "";
|
|
83
|
-
return typeof v === "string" ? v : v.en ?? Object.values(v)[0] ?? "";
|
|
84
|
-
}
|
|
85
82
|
const UUID = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}";
|
|
86
83
|
const IMAGE_URL_ID_RE = new RegExp(`node-packages/images/(${UUID})`, "i");
|
|
87
84
|
const BARE_UUID_RE = new RegExp(`^${UUID}$`, "i");
|
|
@@ -109,7 +106,7 @@ export function nodeLogoOptions() {
|
|
|
109
106
|
if (!logo?.src) continue;
|
|
110
107
|
const id = imageIdFromSrc(logo.src) ?? logo.src;
|
|
111
108
|
if (byId.has(id)) continue;
|
|
112
|
-
const label = m.__package?.label || (m.groups?.length ?
|
|
109
|
+
const label = m.__package?.label || (m.groups?.length ? resolveLocalized(m.groups[0]) : "") || resolveLocalized(m.name);
|
|
113
110
|
byId.set(id, { label: label || "Integration", src: logo.src });
|
|
114
111
|
}
|
|
115
112
|
return [...byId.entries()].map(([id, v]) => ({ id, src: v.src, label: v.label })).sort((a, b) => a.label.localeCompare(b.label));
|